From 4265cfe4aa6ee731e2e32b4ee79d5cf32d2fab22 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 7 Oct 2026 10:11:32 +0200 Subject: [PATCH] fix: signing baskets follow NextGenPSD2 1.3.16, belong to their TPP and book what they authorise A minimal signing basket for the mapped connector. Responses and validation: transactionStatus is upper case, _links.scaStatus is a href object, the PUT answer links to the basket's authorisation, an empty or duplicated id list is a format error, and authorisation bodies the server does not implement are refused by name instead of discarded. Codes outside the standard's list (wrong OTP, unknown basket or authorisation, status conflicts) are mapped to ones inside it. Ownership: a basket records the consumer that created it and every operation is limited to that consumer; an unknown basket, another TPP's basket and a basket created before ownership was recorded answer 403 RESOURCE_UNKNOWN. State: delete and the move to AUTHORISING are conditional updates, so an answer and a delete racing each other have one winner. Answering the authorisation checks everything first (instance setting, ownership, basket and challenge state, every payment still waiting for SCA), then the answer, and only a challenge recorded as finalised authorises anything. The payments are then booked one after another, each awaited, and the basket is ACTC only if every one was. Authorisation is off by default (signing_basket_authorisation_enabled). Payments are admitted only if the caller lodged them and they await SCA. Consents in a basket are refused until their authorisation exists. --- .../resources/props/sample.props.template | 5 + .../code/api/berlin/group/ConstantsBG.scala | 10 + .../berlin/group/v1_3/Http4sBGv13PIS.scala | 2 +- .../v1_3/Http4sBGv13SigningBaskets.scala | 222 +++-- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 35 +- .../code/api/util/BerlinGroupError.scala | 15 + .../scala/code/api/util/ErrorMessages.scala | 10 + .../util/newstyle/SigningBasketNewStyle.scala | 59 +- .../MappedSigningBasketProvider.scala | 22 +- .../code/signingbaskets/SigningBasket.scala | 9 + .../v1_3/SigningBasketServiceSBSApiTest.scala | 770 +++++++++++++++--- .../MappedSigningBasketProviderTest.scala | 84 ++ .../commons/model/CommonModelTrait.scala | 2 + 13 files changed, 1049 insertions(+), 196 deletions(-) create mode 100644 obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index c6f144da83..d6c84ff927 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1682,6 +1682,11 @@ default_auth_context_update_request_key=CUSTOMER_NUMBER ## Berlin Group Create Consent ASPSP-SCA-Approach response header value #berlin_group_aspsp_sca_approach = redirect +# Whether a Berlin Group signing basket may be authorised (PUT /signing-baskets/{basketId}/authorisations/{authorisationId}). +# Default false: the call answers 403 SERVICE_BLOCKED. Answering the authorisation books the basket's payments one +# after another. Creating, reading, starting an authorisation on and deleting baskets are not affected. +#signing_basket_authorisation_enabled = false + # Support multiple brands on one instance. Note this needs checking on a clustered environment #brands_enabled=false diff --git a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala index 6802d93566..99c41d7bfd 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/ConstantsBG.scala @@ -47,5 +47,15 @@ object ConstantsBG { // 4) CANC (Cancelled) and // 5) RJCT (Rejected) are supported for signing baskets. val RCVD, PATC, ACTC, CANC, RJCT = Value + + /** + * Stored from the moment a correct answer claims the basket until its payments have been booked. It is + * never reported: to a TPP a basket in this state is still RCVD, since the authorisation has not + * completed from its point of view. + */ + val AUTHORISING_INTERNAL = "AUTHORISING" + + def external(storedStatus: String): String = + if (storedStatus == AUTHORISING_INTERNAL) RCVD.toString else storedStatus } } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala index 0f9e0e5a83..7f6909384a 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13PIS.scala @@ -122,7 +122,7 @@ object Http4sBGv13PIS extends MdcLoggable { * a payment lodged on a client-credentials token can still be authorised under the PSU's token * and the other way round. A payment carrying neither identity belongs to nobody. */ - private def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] = + def getOwnPaymentImpl(paymentId: String, callContext: Option[CallContext]): OBPReturnType[TransactionRequest] = for { (transactionRequest, callContext) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) initiators = Set(transactionRequest.user_id, transactionRequest.on_behalf_of_user_id).flatten.filter(_.nonEmpty) diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala index aa8ca2788e..38b88ecc7a 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13SigningBaskets.scala @@ -32,11 +32,11 @@ import cats.data.{Kleisli, OptionT} import cats.effect._ import code.api.berlin.group.ConstantsBG import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3._ -import code.api.util.APIUtil.{EmptyBody, ResourceDoc, connectorEmptyResponse, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, getPropsAsBoolValue, getSuggestedDefaultScaMethod, mockedDataText, passesPsd2Pisp, unboxFullOrFail} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.CustomJsonFormats -import code.api.util.{ApiTag, NewStyle} +import code.api.util.{ApiTag, CallContext, NewStyle} import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.newstyle.SigningBasketNewStyle import code.bankconnectors.Connector @@ -46,8 +46,8 @@ import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.enums.TransactionRequestStatus.{COMPLETED, REJECTED} import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthenticationStatus, SuppliedAnswerType} -import com.openbankproject.commons.model.{ChallengeTrait, TransactionRequestId} -import net.liftweb.common.Empty +import com.openbankproject.commons.model.{AccountId, BankId, ChallengeTrait, TransactionRequestId} +import net.liftweb.common.{Box, Empty, Failure, Full} import com.openbankproject.commons.util.json import org.json4s.Formats import org.http4s._ @@ -73,27 +73,63 @@ object Http4sBGv13SigningBaskets extends MdcLoggable { val bgV13Prefix = Root / ConstantsBG.berlinGroupVersion1.urlPrefix / ConstantsBG.berlinGroupVersion1.apiShortVersion + /** + * Berlin Group hangs several request bodies off the authorisation paths. Baskets support the two that need + * no data this ASPSP holds back: an empty body, which starts the authorisation, and + * `transactionAuthorisation`, which answers it. The others are Embedded-approach steps that are not + * implemented for any Berlin Group resource here. They are refused by name rather than answered as if the + * credential or the choice had been processed, and a body that matches no variant is a format error. + */ + private def requireSupportedAuthorisationBody(rawBody: String, answering: Boolean, failMsg: String, callContext: Option[CallContext]): Future[Boolean] = { + val parsed = scala.util.Try(json.parse(rawBody)).getOrElse(json.JNothing) + val supported = if (answering) checkTransactionAuthorisation(parsed) else startsAuthorisation(parsed) + val knownButUnsupported = !supported && ( + checkUpdatePsuAuthentication(parsed) || checkSelectPsuAuthenticationMethod(parsed) || + checkAuthorisationConfirmation(parsed) || (answering && parsed == json.JObject(Nil))) + for { + _ <- booleanToFuture(SigningBasketAuthorisationVariantNotSupported, cc = callContext)(!knownButUnsupported) + _ <- booleanToFuture(failMsg, cc = callContext)(supported) + } yield true + } + + /** The authorisation, if it is one of this basket's. */ + private def getBasketAuthorisation(basketId: String, authorisationId: String, callContext: Option[CallContext]): Future[ChallengeTrait] = + for { + (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketId, callContext) + found = challenges.find(_.challengeId == authorisationId) + _ <- booleanToFuture(SigningBasketAuthorisationNotFound, failCode = 404, cc = callContext)(found.isDefined) + } yield found.get + // ── POST /signing-baskets ────────────────────────────────────────────── val createSigningBasket: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `bgV13Prefix` / "signing-baskets" => EndpointHelpers.executeFutureCreated(req) { val cc = req.callContext val callContext = Some(cc) + val failMsg = s"$InvalidJsonFormat The Json body should be the $PostSigningBasketJsonV13 " for { _ <- passesPsd2Pisp(callContext) - failMsg = s"$InvalidJsonFormat The Json body should be the $PostSigningBasketJsonV13 " postJson <- NewStyle.function.tryons(failMsg, 400, callContext) { json.parse(cc.httpBody.getOrElse("")).extract[PostSigningBasketJsonV13] } + // The body shall contain at least one entry, and each list that is present at least one id + // (minItems: 1). A list naming the same id twice is refused as well, rather than silently + // collapsed, so the TPP learns its request was malformed. + idLists = List(postJson.paymentIds, postJson.consentIds).flatten _ <- booleanToFuture(failMsg, cc = callContext) { - !(postJson.paymentIds.isEmpty && postJson.consentIds.isEmpty) + idLists.nonEmpty && idLists.forall(ids => ids.nonEmpty && ids.distinct.size == ids.size) } + // Authorising a consent through a basket is not implemented yet, and a basket that silently + // ignored its consents would claim an authorisation that never happened. + _ <- booleanToFuture(SigningBasketConsentsNotSupported, cc = callContext)(postJson.consentIds.forall(_.isEmpty)) + // The basket belongs to the TPP that creates it; nothing else identifies who may address it later. + consumerId <- Future.successful(cc.consumer.toOption.map(_.consumerId.get)) + .map(unboxFullOrFail(_, callContext, AuthenticatedUserIsRequired, 401)) + // Every payment must be one this TPP lodged and SCA can still authorise. + _ <- SigningBasketNewStyle.admitPayments(postJson.paymentIds.getOrElse(Nil), callContext) signingBasket <- Future { - SigningBasketX.signingBasketProvider.vend.createSigningBasket( - postJson.paymentIds, - postJson.consentIds, - ) - }.map(connectorEmptyResponse(_, callContext)) + SigningBasketX.signingBasketProvider.vend.createSigningBasket(postJson.paymentIds, None, consumerId) + }.map(unboxFullOrFail(_, callContext, UnknownError)) } yield { createSigningBasketResponseJson(signingBasket) } @@ -149,9 +185,14 @@ The resource identifications of these transactions are contained in the payload val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - _ <- Future { - SigningBasketX.signingBasketProvider.vend.deleteSigningBasket(basketid) - }.map(connectorEmptyResponse(_, callContext)) + basket <- SigningBasketNewStyle.getOwnBasket(basketid, callContext) + alreadyCancelled = basket.basket.status == ConstantsBG.SigningBasketsStatus.CANC.toString + // One conditional update: a basket whose authorisation has been answered is no longer RCVD and is not + // deletable. Deleting one that is already deleted changes nothing. + cancelled <- if (alreadyCancelled) Future.successful(Full(true)) + else Future(SigningBasketX.signingBasketProvider.vend.transitionSigningBasketStatus( + basketid, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.CANC.toString)) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(cancelled.openOr(false)) } yield () } } @@ -183,9 +224,7 @@ Nevertheless, single transactions might be cancelled on an individual basis on t val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - basket <- Future { - SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) - }.map(connectorEmptyResponse(_, callContext)) + basket <- SigningBasketNewStyle.getOwnBasket(basketid, callContext) } yield { getSigningBasketResponseJson(basket) } @@ -218,6 +257,7 @@ Returns the content of an signing basket object.""", val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketid, callContext) (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketid, callContext) } yield { JSONFactory_BERLIN_GROUP_1_3.AuthorisationJsonV13(challenges.map(_.challengeId)) @@ -252,13 +292,10 @@ This function returns an array of hyperlinks to all generated authorisation sub- val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - _ <- Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId)) - .map(unboxFullOrFail(_, callContext, s"$ConsentNotFound ($basketId)", 403)) - (challenges, _) <- NewStyle.function.getChallengesByBasketId(basketId, callContext) + _ <- SigningBasketNewStyle.getOwnBasket(basketId, callContext) + challenge <- getBasketAuthorisation(basketId, authorisationId, callContext) } yield { - val challengeStatus = challenges.filter(_.challengeId == authorisationId) - .flatMap(_.scaStatus).headOption.map(_.toString).getOrElse("None") - JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challengeStatus) + JSONFactory_BERLIN_GROUP_1_3.ScaStatusJsonV13(challenge.scaStatus.map(_.toString).getOrElse("None")) } } } @@ -288,9 +325,7 @@ This method returns the SCA status of a signing basket's authorisation sub-resou val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) - basket <- Future { - SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketid) - }.map(connectorEmptyResponse(_, callContext)) + basket <- SigningBasketNewStyle.getOwnBasket(basketid, callContext) } yield { getSigningBasketStatusResponseJson(basket) } @@ -323,6 +358,13 @@ Returns the status of a signing basket object. val callContext = Some(cc) for { _ <- passesPsd2Pisp(callContext) + basket <- SigningBasketNewStyle.getOwnBasket(basketId, callContext) + failMsg = s"$InvalidJsonFormat The Json body should be empty, or one of updatePsuAuthentication, selectPsuAuthenticationMethod or transactionAuthorisation." + _ <- requireSupportedAuthorisationBody(cc.httpBody.getOrElse(""), answering = false, failMsg, callContext) + // Only a basket still waiting for its authorisation can be given one. + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + basket.basket.status == ConstantsBG.SigningBasketsStatus.RCVD.toString + } (challenges, _) <- NewStyle.function.createChallengesC3( List(cc.user.map(_.userId).openOr("")), ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, @@ -414,17 +456,80 @@ This applies in the following scenarios: ) // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + /** + * A wrong, expired or used-up one-time password is the PSU's credentials being refused, not a malformed + * request: 401, which the standard has a code for. An answer given a second time, concurrently or later, is + * a conflict. + */ + private def challengeFailure(message: String): (String, Int) = + if (message.contains("Challenge already answered")) (SigningBasketStatusInvalid, 409) + else if (message.contains("OBP-40016") || message.contains("OBP-20211") || message.contains("OBP-40014")) (message, 401) + else (message, 400) + + /** + * Books the payments one after another, each awaited, so that the money has moved when the response is sent. + * Stops at the first that cannot be booked and says so; the ones before it stay booked, which each + * payment's own status shows. + */ + private def bookPayments(paymentIds: List[String], callContext: Option[CallContext]): Future[Boolean] = + paymentIds match { + case Nil => Future.successful(true) + case paymentId :: rest => + bookPayment(paymentId, callContext).flatMap(booked => if (booked) bookPayments(rest, callContext) else Future.successful(false)) + } + + private def bookPayment(paymentId: String, callContext: Option[CallContext]): Future[Boolean] = + (for { + (payment, _) <- NewStyle.function.getTransactionRequestImpl(TransactionRequestId(paymentId), callContext) + (fromAccount, _) <- NewStyle.function.checkBankAccountExists(BankId(payment.from.bank_id), AccountId(payment.from.account_id), callContext) + _ <- NewStyle.function.createTransactionAfterChallengeV210(fromAccount, payment, callContext) + _ <- NewStyle.function.saveTransactionRequestStatusImpl(payment.id, COMPLETED.toString, callContext) + } yield true).recover { + case error => + logger.warn(s"Signing basket: payment $paymentId could not be booked: ${error.getMessage}") + false + } + + // ── PUT /signing-baskets/BASKETID/authorisations/AUTHORISATIONID ─────── + // + // Order matters, and nothing may be changed until the answer has been checked: + // 1. whether the instance allows it, and whether this is the caller's basket and one of its authorisations; + // 2. whether the request can succeed at all (basket and challenge state, every payment still waiting for SCA); + // 3. the answer; + // 4. the basket is claimed with one conditional update, so two answers racing each other have one winner; + // 5. only then are the payments booked, and the basket becomes ACTC if every one was. val updateSigningBasketPsuData: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ PUT -> `bgV13Prefix` / "signing-baskets" / basketId / "authorisations" / authorisationId => EndpointHelpers.executeAndRespond(req) { cc => val callContext = Some(cc) + val provider = SigningBasketX.signingBasketProvider.vend for { _ <- passesPsd2Pisp(callContext) + _ <- booleanToFuture(SigningBasketAuthorisationDisabled, failCode = 403, cc = callContext) { + getPropsAsBoolValue("signing_basket_authorisation_enabled", false) + } + basket <- SigningBasketNewStyle.getOwnBasket(basketId, callContext) + startedChallenge <- getBasketAuthorisation(basketId, authorisationId, callContext) failMsg = s"$InvalidJsonFormat The Json body should be the $UpdatePaymentPsuDataJson " + _ <- requireSupportedAuthorisationBody(cc.httpBody.getOrElse(""), answering = true, failMsg, callContext) updateBasketPsuDataJson <- NewStyle.function.tryons(failMsg, 400, callContext) { json.parse(cc.httpBody.getOrElse("")).extract[UpdatePaymentPsuDataJson] } - _ <- SigningBasketNewStyle.checkSigningBasketPayments(basketId, callContext) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + basket.basket.status == ConstantsBG.SigningBasketsStatus.RCVD.toString + } + // An authorisation already answered, for good or for bad, cannot be answered again. + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext) { + !startedChallenge.scaStatus.exists(status => + status == StrongCustomerAuthenticationStatus.finalised || status == StrongCustomerAuthenticationStatus.failed) + } + paymentIds = basket.payments.getOrElse(Nil) + payments <- Future(paymentIds.map(id => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(id), callContext))) + _ <- booleanToFuture(SigningBasketMemberNotFound, failCode = 400, cc = callContext)(payments.forall(_.isDefined)) + // Every payment has to be waiting for SCA now, so that the answer does not book some of them and then stop. + _ <- booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + payments.forall(_.exists(payment => SigningBasketNewStyle.awaitingScaPaymentStatuses.contains(payment._1.status))) + } (boxedChallenge, _) <- NewStyle.function.validateChallengeAnswerC5( ChallengeType.BERLIN_GROUP_SIGNING_BASKETS_CHALLENGE, None, @@ -435,51 +540,28 @@ This applies in the following scenarios: SuppliedAnswerType.PLAIN_TEXT_VALUE, callContext ) - (challenge, updatedCC) <- NewStyle.function.getChallenge(authorisationId, callContext) - _ <- challenge.scaStatus match { - case Some(status) if status.toString == StrongCustomerAuthenticationStatus.finalised.toString => - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - val existAll = - basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).isDefined))) - val alreadyCompleted: List[String] = - basket.flatMap(_.payments).getOrElse(Nil).filter { i => - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC) - .exists(_._1.status == COMPLETED.toString) - } - if (alreadyCompleted.nonEmpty) { - unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${alreadyCompleted.mkString(",")}] are already completed") - } else if (existAll.getOrElse(false)) { - basket.map { i => - i.payments.map(_.map { i => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), COMPLETED.toString, updatedCC) - Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), updatedCC).map { t => - Connector.connector.vend.makePaymentV400(t._1, None, updatedCC) - } - }) - } - SigningBasketX.signingBasketProvider.vend.saveSigningBasketStatus(basketId, ConstantsBG.SigningBasketsStatus.ACTC.toString) - unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") - } else { - val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") - unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") - } - } - case Some(status) if status.toString == StrongCustomerAuthenticationStatus.failed.toString => - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - basket.map { i => - i.payments.map(_.map { i => - NewStyle.function.saveTransactionRequestStatusImpl(TransactionRequestId(i), REJECTED.toString, updatedCC) - }) - } - unboxFullOrFail(boxedChallenge, updatedCC, s"$InvalidConnectorResponse validateChallengeAnswerC5") - } - case _ => - Future(unboxFullOrFail(Empty, updatedCC, s"$InvalidConnectorResponse getChallenge")) + // Only an answer the challenge records as finalised authorises anything. A connector may hand back the + // challenge itself with another status, which is a refusal, not a success. + challenge <- Future { + boxedChallenge match { + case Full(answered) if answered.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised) => answered + case other => + val (message, status) = challengeFailure(other match { + case f: Failure => f.msg + case _ => InvalidChallengeAnswer + }) + unboxFullOrFail(Empty: Box[ChallengeTrait], callContext, message, status) + } } + claimed <- Future(provider.transitionSigningBasketStatus( + basketId, ConstantsBG.SigningBasketsStatus.RCVD.toString, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL)) + _ <- booleanToFuture(SigningBasketStatusInvalid, failCode = 409, cc = callContext)(claimed.openOr(false)) + allBooked <- bookPayments(paymentIds, callContext) + _ <- if (allBooked) Future(provider.transitionSigningBasketStatus( + basketId, ConstantsBG.SigningBasketsStatus.AUTHORISING_INTERNAL, ConstantsBG.SigningBasketsStatus.ACTC.toString)) + else Future.successful(()) } yield { - JSONFactory_BERLIN_GROUP_1_3.createStartPaymentAuthorisationJson(challenge) + JSONFactory_BERLIN_GROUP_1_3.createUpdateSigningBasketPsuDataJson(basketId, challenge, executionIncomplete = !allBooked) } } } diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index 1d4eaa2a10..29bbf77ddb 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -70,6 +70,13 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ transactionStatus: String, basketId: String, _links: SigningBasketLinksV13) + // The links of a signing basket authorisation: scaStatus is a hyperlink object (hrefType), not a bare string. + case class SigningBasketScaLinksV13(scaStatus: LinkHrefJson) + case class StartSigningBasketAuthorisationJson( + scaStatus: String, + authorisationId: String, + psuMessage: String, + _links: SigningBasketScaLinksV13) case class SigningBasketGetResponseJson( transactionStatus: String, payments: Option[List[String]], @@ -875,19 +882,35 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ } - def createStartSigningBasketAuthorisationJson(basketId: String, challenge: ChallengeTrait): StartPaymentAuthorisationJson = { - StartPaymentAuthorisationJson( + def createStartSigningBasketAuthorisationJson(basketId: String, challenge: ChallengeTrait): StartSigningBasketAuthorisationJson = { + StartSigningBasketAuthorisationJson( scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""), authorisationId = challenge.challengeId, psuMessage = "Please check your SMS at a mobile device.", - _links = ScaStatusJsonV13(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}") + _links = SigningBasketScaLinksV13( + scaStatus = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}") + ) + ) + } + + /** The 200 answer to a transaction authorisation on a signing basket: a scaStatusResponse whose link names the basket's authorisation. */ + def createUpdateSigningBasketPsuDataJson(basketId: String, challenge: ChallengeTrait, executionIncomplete: Boolean = false) = { + ScaStatusResponse( + scaStatus = challenge.scaStatus.map(_.toString).getOrElse(""), + // The authorisation itself succeeded either way. When not every payment could be booked, the basket + // stays RCVD and each payment's own status says which was. + psuMessage = Some( + if (executionIncomplete) "The authorisation was accepted, but not every payment in the basket could be booked." + else "Please check your SMS at a mobile device."), + _links = Some(LinksAll(scaStatus = Some(HrefType(Some( + s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basketId}/authorisations/${challenge.challengeId}"))))) ) } def createSigningBasketResponseJson(basket: SigningBasketTrait): SigningBasketResponseJson = { SigningBasketResponseJson( basketId = basket.basketId, - transactionStatus = basket.status.toLowerCase(), + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.status), _links = SigningBasketLinksV13( self = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}"), status = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/signing-baskets/${basket.basketId}/status"), @@ -898,7 +921,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status.toLowerCase(), + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), payments = basket.payments, consents = basket.consents, ) @@ -906,7 +929,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ def getSigningBasketStatusResponseJson(basket: SigningBasketContent): SigningBasketGetResponseJson = { SigningBasketGetResponseJson( - transactionStatus = basket.basket.status.toLowerCase(), + transactionStatus = ConstantsBG.SigningBasketsStatus.external(basket.basket.status), payments = None, consents = None, ) diff --git a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala index cadb2c65cc..a1aaa0527b 100644 --- a/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala +++ b/obp-api/src/main/scala/code/api/util/BerlinGroupError.scala @@ -115,6 +115,21 @@ object BerlinGroupError { case "403" if message.contains("OBP-20060") => "ROLE_INVALID" case "400" if message.contains("OBP-10034") => "PARAMETER_NOT_CONSISTENT" + case "400" if message.contains("OBP-35050") => "SERVICE_INVALID" + // One answer for a signing basket that does not exist and one the caller may not address, so the + // endpoint is not a way to learn which basket ids exist. + case "403" if message.contains("OBP-35051") => "RESOURCE_UNKNOWN" + case "404" if message.contains("OBP-35052") => "RESOURCE_UNKNOWN" + case "409" if message.contains("OBP-35053") => "STATUS_INVALID" + case "403" if message.contains("OBP-35054") => "SERVICE_BLOCKED" + case "400" if message.contains("OBP-35056") => "RESOURCE_UNKNOWN" + case "409" if message.contains("OBP-35057") => "REFERENCE_STATUS_INVALID" + case "400" if message.contains("OBP-35058") => "SERVICE_INVALID" + // A wrong or expired one-time password on a signing basket. The standard's code for "the + // password/OTP is incorrect" is a 401 one; the basket answers these at 401 so it can use it. + case "401" if message.contains("OBP-40016") => "PSU_CREDENTIALS_INVALID" + case "401" if message.contains("OBP-20211") => "PSU_CREDENTIALS_INVALID" + case "401" if message.contains("OBP-40014") => "PSU_CREDENTIALS_INVALID" case "400" if message.contains("OBP-35018") => "CONSENT_UNKNOWN" case "400" if message.contains("OBP-35001") => "CONSENT_UNKNOWN" diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 94c1d07426..db7a28b6a6 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -881,6 +881,16 @@ object ErrorMessages { val InvalidUKConsentPermissions = "OBP-35038: The Permissions array is not a valid combination for UK Open Banking. " val BerlinGroupPsuNotIdentified = "OBP-35039: The PSU this authorisation is for cannot be identified. Send the PSU-ID header, or authenticate as the PSU. " val ConsentNamesNoAccount = "OBP-35040: The Consent names no account, so it grants no access. It was authorised before consents were bound to accounts; re-authorise it to select which accounts it applies to. " + val SigningBasketAuthorisationVariantNotSupported = "OBP-35050: This request body is not supported on a signing basket authorisation. " + + "Send an empty body to start the authorisation, or {\"scaAuthenticationData\": ...} to answer it. " + + "PSU authentication, authentication method selection and confirmation code requests are not available for signing baskets. " + val SigningBasketNotFound = "OBP-35051: Signing basket not found by BASKET_ID. " + val SigningBasketAuthorisationNotFound = "OBP-35052: Signing basket authorisation not found by AUTHORISATION_ID. " + val SigningBasketStatusInvalid = "OBP-35053: The signing basket's status does not allow this operation. " + val SigningBasketAuthorisationDisabled = "OBP-35054: Authorising signing baskets is not enabled at this instance. " + val SigningBasketMemberNotFound = "OBP-35056: A payment named for the signing basket was not found. " + val SigningBasketMemberStatusInvalid = "OBP-35057: A payment named for the signing basket is not waiting for SCA. " + val SigningBasketConsentsNotSupported = "OBP-35058: Consents in a signing basket are not supported yet. Name payments only. " val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. " val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. " val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. " diff --git a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala index 046e8c6e9b..fa6c7fbdcd 100644 --- a/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/newstyle/SigningBasketNewStyle.scala @@ -27,39 +27,56 @@ TESOBE (http://www.tesobe.com/) package code.api.util.newstyle -import code.api.util.APIUtil.{OBPReturnType, unboxFullOrFail} +import code.api.berlin.group.v1_3.Http4sBGv13PIS +import code.api.util.APIUtil.unboxFullOrFail import code.api.util.CallContext -import code.api.util.ErrorMessages.{InvalidConnectorResponse, RegulatedEntityNotDeleted} -import code.bankconnectors.Connector +import code.api.util.ErrorMessages.{SigningBasketMemberNotFound, SigningBasketMemberStatusInvalid, SigningBasketNotFound} import code.signingbaskets.SigningBasketX -import com.openbankproject.commons.model.TransactionRequestId +import code.util.Helper.booleanToFuture +import com.openbankproject.commons.model.SigningBasketContent import net.liftweb.common.{Box, Empty} import scala.concurrent.Future +import scala.util.{Failure, Success} object SigningBasketNewStyle { import com.openbankproject.commons.ExecutionContext.Implicits.global - def checkSigningBasketPayments(basketId: String, - callContext: Option[CallContext] - ): OBPReturnType[Boolean] = { - Future { - val basket = SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId) - val existAll: Box[Boolean] = - basket.flatMap(_.payments.map(_.forall(i => Connector.connector.vend.getTransactionRequestImpl(TransactionRequestId(i), callContext).isDefined))) - if (existAll.getOrElse(false)) { - Some(true) - } else { // Fail due to nonexistent payment - val paymentIds = basket.flatMap(_.payments).getOrElse(Nil).mkString(",") - unboxFullOrFail(Empty, callContext, s"$InvalidConnectorResponse Some of paymentIds [${paymentIds}] are invalid") - } - } map { - (_, callContext) - } map { - x => (unboxFullOrFail(x._1, callContext, RegulatedEntityNotDeleted, 400), x._2) + /** + * The basket, if the caller may address it: only the consumer (TPP) that created it. A basket that does + * not exist, one another TPP created, and one created before ownership was recorded all answer the same + * way, so the endpoint is not a way to learn which basket ids exist. + */ + def getOwnBasket(basketId: String, callContext: Option[CallContext]): Future[SigningBasketContent] = { + val callingConsumer = callContext.flatMap(_.consumer.toOption).map(_.consumerId.get) + Future(SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId).toOption).flatMap { found => + booleanToFuture(SigningBasketNotFound, failCode = 403, cc = callContext) { + found.exists(content => content.basket.consumerId.isDefined && content.basket.consumerId == callingConsumer) + }.map(_ => found.get) } } + // A payment lodged for SCA is stored RCVD (BG initiation) or INITIATED; anything else has been booked, + // rejected or cancelled, or is being authorised some other way. + val awaitingScaPaymentStatuses = Set("RCVD", "INITIATED") + /** + * A payment may join a basket if the caller lodged it (the rule the payment routes use) and it is still + * waiting for SCA. One that does not exist and one that is somebody else's answer alike. + */ + def admitPayments(paymentIds: List[String], callContext: Option[CallContext]): Future[Unit] = + paymentIds.foldLeft(Future.successful(())) { (previous, paymentId) => + previous.flatMap(_ => admitPayment(paymentId, callContext)) + } + + private def admitPayment(paymentId: String, callContext: Option[CallContext]): Future[Unit] = + Http4sBGv13PIS.getOwnPaymentImpl(paymentId, callContext).transformWith { + case Success((payment, _)) => + booleanToFuture(SigningBasketMemberStatusInvalid, failCode = 409, cc = callContext) { + awaitingScaPaymentStatuses.contains(payment.status) + }.map(_ => ()) + case Failure(_) => + Future(unboxFullOrFail(Empty: Box[Unit], callContext, SigningBasketMemberNotFound, 400)) + } } diff --git a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala index d8bd342f55..33a2744dc7 100644 --- a/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala +++ b/obp-api/src/main/scala/code/signingbaskets/MappedSigningBasketProvider.scala @@ -32,6 +32,7 @@ import code.util.MappedUUID import com.openbankproject.commons.model.{SigningBasketConsentTrait, SigningBasketContent, SigningBasketPaymentTrait, SigningBasketTrait} import net.liftweb.common.Box import net.liftweb.common.Box.tryo +import net.liftweb.db.DB import net.liftweb.mapper._ object MappedSigningBasketProvider extends SigningBasketProvider { @@ -41,7 +42,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider { override def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] = { val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)) - val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId)).map(_.paymentId) match { + val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId), OrderBy(MappedSigningBasketPayment.id, Ascending)).map(_.paymentId) match { case Nil => None case head :: tail => Some(head :: tail) } @@ -53,7 +54,7 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } override def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] = { val basket: Box[MappedSigningBasket] = MappedSigningBasket.find(By(MappedSigningBasket.BasketId, entityId)).map(_.Status(status).saveMe) - val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId)).map(_.paymentId) match { + val payments = MappedSigningBasketPayment.findAll(By(MappedSigningBasketPayment.BasketId, entityId), OrderBy(MappedSigningBasketPayment.id, Ascending)).map(_.paymentId) match { case Nil => None case head :: tail => Some(head :: tail) } @@ -65,11 +66,13 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } override def createSigningBasket(paymentIds: Option[List[String]], - consentIds: Option[List[String]] + consentIds: Option[List[String]], + consumerId: String ): Box[SigningBasketTrait] = { tryo { val entity = MappedSigningBasket.create entity.Status(ConstantsBG.SigningBasketsStatus.RCVD.toString) + entity.ConsumerId(consumerId) if (entity.validate.isEmpty) { entity.saveMe() @@ -86,6 +89,14 @@ object MappedSigningBasketProvider extends SigningBasketProvider { } } + override def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] = + tryo { + DB.runUpdate( + s"UPDATE ${MappedSigningBasket.dbTableName} SET ${MappedSigningBasket.Status._dbColumnNameLC} = ? " + + s"WHERE ${MappedSigningBasket.BasketId._dbColumnNameLC} = ? AND ${MappedSigningBasket.Status._dbColumnNameLC} = ?", + List(to, basketId, from)) == 1 + } + override def deleteSigningBasket(id: String): Box[Boolean] = { MappedSigningBasket.find(By(MappedSigningBasket.BasketId, id)) map { _.Status(ConstantsBG.SigningBasketsStatus.CANC.toString).save @@ -98,11 +109,12 @@ class MappedSigningBasket extends SigningBasketTrait with LongKeyedMapper[Mapped override def getSingleton = MappedSigningBasket object BasketId extends MappedUUID(this) object Status extends MappedString(this, 50) - - + // The consumer (TPP) that created the basket. Empty, or null, on a basket created before this was recorded. + object ConsumerId extends MappedString(this, 255) override def basketId: String = BasketId.get override def status: String = Status.get + override def consumerId: Option[String] = Option(ConsumerId.get).map(_.trim).filter(_.nonEmpty) } diff --git a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala index 8400fc113a..dcf01994c3 100644 --- a/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala +++ b/obp-api/src/main/scala/code/signingbaskets/SigningBasket.scala @@ -45,10 +45,19 @@ trait SigningBasketProvider extends MdcLoggable { def getSigningBasketByBasketId(entityId: String): Box[SigningBasketContent] def saveSigningBasketStatus(entityId: String, status: String): Box[SigningBasketContent] + /** Creates the basket, owned by the consumer (TPP) that creates it. */ def createSigningBasket(paymentIds: Option[List[String]], consentIds: Option[List[String]], + consumerId: String ): Box[SigningBasketTrait] + /** + * Moves a basket from one status to another only if it still has the status the caller read. + * One conditional update, so two callers racing for the same transition have exactly one winner. + * Returns whether this call made the move. + */ + def transitionSigningBasketStatus(basketId: String, from: String, to: String): Box[Boolean] + def deleteSigningBasket(id: String): Box[Boolean] } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 3d8afa8922..244907d3ae 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -27,23 +27,37 @@ TESOBE (http://www.tesobe.com/) package code.api.berlin.group.v1_3 +import org.json4s._ import code.api.Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID import code.api.berlin.group.ConstantsBG -import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{AuthorisationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, PostSigningBasketJsonV13, ScaStatusJsonV13, SigningBasketGetResponseJson, SigningBasketResponseJson, StartPaymentAuthorisationJson} +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{AuthorisationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, PostSigningBasketJsonV13, SigningBasketGetResponseJson, SigningBasketResponseJson} import code.api.berlin.group.v1_3.model.TransactionStatus import code.api.berlin.group.v1_3.{Http4sBGv13SigningBaskets => APIMethods_SigningBasketsApi} import code.api.util.APIUtil.OAuth._ import code.api.util.ErrorMessages._ -import code.model.dataAccess.BankAccountRouting -import code.setup.{APIResponse, DefaultUsers} +import code.model.TokenType +import code.model.dataAccess.{BankAccountRouting, MappedBankAccount} +import code.setup.APIResponse +import com.openbankproject.commons.model.User +import code.signingbaskets.{MappedSigningBasket, MappedSigningBasketPayment, SigningBasketX} +import code.token.Tokens +import code.transactionChallenge.Challenges +import code.transactionrequests.MappedTransactionRequest import code.views.Views import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ViewId import com.openbankproject.commons.model.enums.{AccountRoutingScheme, PaymentServiceTypes, StrongCustomerAuthenticationStatus, TransactionRequestTypes} import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import net.liftweb.util.TimeHelpers.TimeSpan +import org.json4s.native.Serialization.write import org.scalatest.Tag -class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with DefaultUsers { +import java.util.UUID +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { object SBS extends Tag("Signing Baskets Service (SBS)") object createSigningBasket extends Tag(nameOf(APIMethods_SigningBasketsApi.createSigningBasket)) object getSigningBasket extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasket)) @@ -54,27 +68,143 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def object getSigningBasketAuthorisation extends Tag(nameOf(APIMethods_SigningBasketsApi.getSigningBasketAuthorisation)) object updateSigningBasketPsuData extends Tag(nameOf(APIMethods_SigningBasketsApi.updateSigningBasketPsuData)) - // Helper: create a real SEPA payment via BG PIS API and return its paymentId - private def createRealPaymentId(): String = { - val accountsRoutingIban = BankAccountRouting.findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) - val ibanFrom = accountsRoutingIban.head - val ibanTo = accountsRoutingIban.last + // ───────────────────────────── fixtures ───────────────────────────── + + // Spec references below are lines of psd2-api_v1.3.16-2025-11-27.openapi.yaml ("L1234") and sections + // of the Implementation Guidelines 1.3.16 ("IG §x"). Where the standard leaves a choice to the ASPSP the + // scenario says so and states the choice made. + + /** The tppMessage codes the standard allows for each status of a signing basket call (L11514-11749: MessageCode400_SBS L11514, 401 L11597, 403 L11648, 404 L11680, 409 L11744). */ + private val allowedTppCodes: Map[Int, Set[String]] = Map( + 400 -> Set("FORMAT_ERROR", "PARAMETER_NOT_CONSISTENT", "PARAMETER_NOT_SUPPORTED", "SERVICE_INVALID", "RESOURCE_UNKNOWN", + "RESOURCE_EXPIRED", "RESOURCE_BLOCKED", "TIMESTAMP_INVALID", "PERIOD_INVALID", "SCA_METHOD_UNKNOWN", "SCA_INVALID", + "CONSENT_UNKNOWN", "REFERENCE_MIX_INVALID"), + 401 -> Set("CERTIFICATE_INVALID", "ROLE_INVALID", "CERTIFICATE_EXPIRED", "CERTIFICATE_BLOCKED", "CERTIFICATE_REVOKE", + "CERTIFICATE_MISSING", "SIGNATURE_INVALID", "SIGNATURE_MISSING", "CORPORATE_ID_INVALID", "PSU_CREDENTIALS_INVALID", + "CONSENT_INVALID", "CONSENT_EXPIRED", "TOKEN_UNKNOWN", "TOKEN_INVALID", "TOKEN_EXPIRED"), + 403 -> Set("CONSENT_UNKNOWN", "SERVICE_BLOCKED", "RESOURCE_UNKNOWN", "RESOURCE_EXPIRED"), + 404 -> Set("RESOURCE_UNKNOWN"), + 409 -> Set("REFERENCE_STATUS_INVALID", "STATUS_INVALID") + ) + + private def ibanAccounts = BankAccountRouting + .findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)) + .filterNot(_.bankId.value == "DEFAULT_BANK_ID_NOT_SET") + + private def balanceOf(routing: BankAccountRouting) = MappedBankAccount.find( + By(MappedBankAccount.bank, routing.bankId.value), + By(MappedBankAccount.theAccountId, routing.accountId.value)) + .map(_.balance).openOrThrowException("Can not be empty here") + + private def basketsUrl = V1_3_BG / "signing-baskets" + private def basketUrl(basketId: String) = V1_3_BG / "signing-baskets" / basketId + private def authorisationsUrl(basketId: String) = V1_3_BG / "signing-baskets" / basketId / "authorisations" + private def authorisationUrl(basketId: String, authorisationId: String) = + V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId + + /** + * Lodges a SEPA payment as user1 and returns its id. The default amount is over the challenge + * threshold, so the payment sits at RCVD awaiting SCA, which is the only state a basket may take + * a payment in. A payment of 10 is booked on creation (ACCP) and can no longer be authorised by + * anything. + */ + private def lodgePayment(amount: String = "2001", as: Option[(Consumer, Token)] = user1, initiator: User = resourceUser1, creditorIban: Option[String] = None): String = { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last Views.views.vend.systemView(ViewId(SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)).foreach(view => - Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, resourceUser1) + Views.views.vend.grantAccessToSystemView(ibanFrom.bankId, ibanFrom.accountId, view, initiator) ) val initiatePaymentJson = s"""{ | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, - | "instructedAmount": { "currency": "EUR", "amount": "10" }, - | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "$amount" }, + | "creditorAccount": { "iban": "${creditorIban.getOrElse(ibanTo.accountRouting.address)}" }, | "creditorName": "TestCreditor" |}""".stripMargin - val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1) + val requestPost = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (as) val response: APIResponse = makePostRequest(requestPost, initiatePaymentJson) - response.code should equal(201) - val payment = response.body.extract[InitiatePaymentResponseJson] - payment.transactionStatus should be(TransactionStatus.ACCP.code) - payment.paymentId + withClue(s"lodging a payment of $amount: ") { response.code should equal(201) } + response.body.extract[InitiatePaymentResponseJson].paymentId + } + + private def createRealPaymentId(): String = lodgePayment() + + /** The stored status of a payment that awaits SCA, and of one that was booked on creation. */ + private val awaitingSca = "RCVD" + private val bookedOnCreation = "ACCP" + + private def idList(ids: List[String]): String = ids.map(id => s""""$id"""").mkString("[", ",", "]") + + private def postBasket(body: String, as: Option[(Consumer, Token)] = user1): APIResponse = + makePostRequest(basketsUrl.POST <@ (as), body) + + private def createBasket(paymentIds: List[String], as: Option[(Consumer, Token)] = user1): String = { + val response = postBasket(s"""{"paymentIds":${idList(paymentIds)}}""", as) + withClue(s"creating a basket of $paymentIds: ${response.body}: ") { response.code should equal(201) } + response.body.extract[SigningBasketResponseJson].basketId + } + + private def startAuthorisation(basketId: String, as: Option[(Consumer, Token)] = user1, body: String = "{}"): APIResponse = + makePostRequest(authorisationsUrl(basketId).POST <@ (as), body) + + private def answerAuthorisation(basketId: String, authorisationId: String, as: Option[(Consumer, Token)] = user1, + body: String = """{"scaAuthenticationData":"123"}"""): APIResponse = + makePutRequest(authorisationUrl(basketId, authorisationId).PUT <@ (as), body) + + /** Everything a basket needs for its SCA to be answered: a basket of real payments, and an authorisation on it. */ + private case class StartedBasket(basketId: String, paymentIds: List[String], authorisationId: String) + + private def startedBasket(paymentCount: Int = 1): StartedBasket = { + enableBasketAuthorisation() + val paymentIds = List.fill(paymentCount)(lodgePayment()) + val basketId = createBasket(paymentIds) + val started = startAuthorisation(basketId) + started.code should equal(201) + StartedBasket(basketId, paymentIds, (started.body \ "authorisationId").extract[String]) + } + + /** Tests that answer an SCA need a challenge whose answer is known, and an instance that lets baskets be authorised. */ + private def enableBasketAuthorisation(): Unit = { + setPropsValues("suggested_default_sca_method" -> "DUMMY", "signing_basket_authorisation_enabled" -> "true") + } + + // What the database says, as opposed to what an HTTP response claims. + private def storedBasketStatus(basketId: String): Option[String] = + SigningBasketX.signingBasketProvider.vend.getSigningBasketByBasketId(basketId).toOption.map(_.basket.status) + + private def storedPaymentStatus(paymentId: String): String = + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, paymentId)) + .map(_.mStatus.get).openOrThrowException(s"payment $paymentId must exist") + + private def storedChallengeCount(basketId: String): Int = + Challenges.ChallengeProvider.vend.getChallengesByBasketId(basketId).map(_.size).openOrThrowException("challenges must be readable") + + private def storedBasketCount(): Long = MappedSigningBasket.count() + + private def tppCode(response: APIResponse): String = response.body.extract[ErrorMessagesBG].tppMessages.head.code + + /** The status is as expected, the tppMessage code is the expected one, and that code is one the standard allows for that status. */ + private def expectRefusal(response: APIResponse, status: Int, code: String, what: String): Unit = + withClue(s"$what: ") { + response.code should equal(status) + tppCode(response) should equal(code) + allowedTppCodes(status) should contain(code) + } + + // resourceUser1's own token, issued under a second consumer: same person, different TPP. + private lazy val samePsuUnderSecondConsumer = { + val token = Tokens.tokens.vend.createToken( + TokenType.Access, + Some(testConsumer2.id.get), + Some(resourceUser1.id.get), + Some(randomString(40).toLowerCase), + Some(randomString(40).toLowerCase), + Some(tokenDuration), + Some(TimeSpan(tokenDuration + System.currentTimeMillis())), + Some(new java.util.Date(System.currentTimeMillis())), + None + ).openOrThrowException("test token creation failed") + Some(consumer2, Token(token.key.get, token.secret.get)) } feature(s"test the BG v1.3 - ${createSigningBasket.name}") { @@ -133,7 +263,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def response.code should equal(201) val createdBasket = response.body.extract[SigningBasketResponseJson] createdBasket.basketId should not be empty - createdBasket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + createdBasket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString) createdBasket._links.self.href should not be empty createdBasket._links.status.href should not be empty createdBasket._links.startAuthorisation.href should not be empty @@ -173,19 +303,19 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def Then("We should get a 200") responseGet.code should be(200) val basket = responseGet.body.extract[SigningBasketGetResponseJson] - basket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + basket.transactionStatus should be(ConstantsBG.SigningBasketsStatus.RCVD.toString) basket.payments.isDefined should be(true) basket.payments.get should contain(paymentId1) basket.payments.get should contain(paymentId2) - // Verify each paymentId in the basket has ACCP status - Then("Each payment in the basket should return ACCP status") + // Each payment still awaits SCA, which Berlin Group reports as RCVD (ACCP would mean it is already booked) + Then("Each payment in the basket should return RCVD status") basket.payments.get.foreach { pid => val requestPaymentStatus = (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / pid / "status").GET <@ (user1) val responsePaymentStatus = makeGetRequest(requestPaymentStatus) responsePaymentStatus.code should be(200) val txStatus = (responsePaymentStatus.body \ "transactionStatus").extract[String] - txStatus should be(TransactionStatus.ACCP.code) + txStatus should be(TransactionStatus.RCVD.code) } } } @@ -265,84 +395,538 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupServerSetupV1_3 with Def } - feature(s"BG v1.3 - $createSigningBasket, $getSigningBasket, $getSigningBasketStatus, $deleteSigningBasket, $startSigningBasketAuthorisation, $getSigningBasketAuthorisation, $updateSigningBasketPsuData") { - scenario("Authentication User, test succeed", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, startSigningBasketAuthorisation, getSigningBasketAuthorisation, updateSigningBasketPsuData) { - // Create Signing Basket - val postJson = - s"""{ - | "paymentIds": [ - | "123qwert456789", - | "12345qwert7899" - | ] - |}""".stripMargin - - val requestPost = (V1_3_BG / "signing-baskets").POST <@ (user1) - val response: APIResponse = makePostRequest(requestPost, postJson) - Then("We should get a 201 ") - response.code should equal(201) + // ───────────────────────── the happy path, with real payments ───────────────────────── - val basketId = response.body.extract[SigningBasketResponseJson].basketId + feature(s"BG v1.3 - $createSigningBasket, $getSigningBasket, $getSigningBasketStatus, $deleteSigningBasket, $startSigningBasketAuthorisation, $getSigningBasketAuthorisation, $getSigningBasketScaStatus, $updateSigningBasketPsuData") { + scenario("a basket of real payments is created, read, authorised and its authorisation listed", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus, startSigningBasketAuthorisation, getSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket(paymentCount = 2) - // Get Signing Basket Then(s"We test the $getSigningBasket") - val requestGet = (V1_3_BG / "signing-baskets" / basketId).GET <@ (user1) - val responseGet = makeGetRequest(requestGet) + val responseGet = makeGetRequest(basketUrl(started.basketId).GET <@ (user1)) responseGet.code should be(200) - responseGet.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) + responseGet.body.extract[SigningBasketGetResponseJson].transactionStatus should be("RCVD") // L4497-4518 - // Get Signing Basket Status Then(s"We test the $getSigningBasketStatus") - val requestGetStatus = (V1_3_BG / "signing-baskets" / basketId / "status").GET <@ (user1) - var responseGetStatus = makeGetRequest(requestGetStatus) - responseGetStatus.code should be(200) - responseGetStatus.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.RCVD.toString.toLowerCase()) - - // Delete Signing Basket - val requestDelete = (V1_3_BG / "signing-baskets" / basketId).DELETE <@ (user1) - val responseDelete = makeDeleteRequest(requestDelete) - responseDelete.code should be(204) - - responseGetStatus = makeGetRequest(requestGetStatus) - responseGetStatus.code should be(200) - responseGetStatus.body.extract[SigningBasketGetResponseJson].transactionStatus should - be(ConstantsBG.SigningBasketsStatus.CANC.toString.toLowerCase()) - - // Start Signing Basket Auth Flow - val postJsonAuth = s"""{}""".stripMargin - val requestAuth = (V1_3_BG / "signing-baskets" / basketId / "authorisations").POST <@ (user1) - val responseAuth = makePostRequest(requestAuth, postJsonAuth) - Then("We should get a 201 ") - responseAuth.code should equal(201) - responseAuth.body.extract[StartPaymentAuthorisationJson].scaStatus should - be(StrongCustomerAuthenticationStatus.received.toString) - val authorisationId = responseAuth.body.extract[StartPaymentAuthorisationJson].authorisationId - - // Get Signing Basket Auth Flow Status - val requestAuthStatus = (V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId).GET <@ (user1) - val responseAuthStatus = makeGetRequest(requestAuthStatus) - Then("We should get a 200 ") - responseAuthStatus.code should equal(200) - responseAuthStatus.body.extract[ScaStatusJsonV13].scaStatus should - be(responseAuth.body.extract[StartPaymentAuthorisationJson].scaStatus) - - // Get Signing Basket Authorisations - val requestGetAuths = (V1_3_BG / "signing-baskets" / "basketId" / "authorisations").GET <@ (user1) - val responseGetAuths = makeGetRequest(requestGetAuths) - Then("We should get a 200 ") - responseGetAuths.code should equal(200) - responseGetAuths.body.extract[AuthorisationJsonV13] - - // Failed due to unexisting paymentIds - val putJson = s"""{"scaAuthenticationData":"123"}""".stripMargin - val requestPut = (V1_3_BG / "signing-baskets" / basketId / "authorisations" / authorisationId).PUT <@ (user1) - val responsePut = makePutRequest(requestPut, putJson) - val error = s"$InvalidConnectorResponse" - And("error should be " + error) - responsePut.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith(error) + val responseStatus = makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user1)) + responseStatus.code should be(200) + (responseStatus.body \ "transactionStatus").extract[String] should be("RCVD") + + Then(s"We test the $getSigningBasketAuthorisation") + val responseAuths = makeGetRequest(authorisationsUrl(started.basketId).GET <@ (user1)) + responseAuths.code should be(200) + responseAuths.body.extract[AuthorisationJsonV13].authorisationIds should equal(List(started.authorisationId)) // L4827 + + Then(s"We test the $getSigningBasketScaStatus") + val responseAuthStatus = makeGetRequest(authorisationUrl(started.basketId, started.authorisationId).GET <@ (user1)) + responseAuthStatus.code should be(200) + (responseAuthStatus.body \ "scaStatus").extract[String] should be(StrongCustomerAuthenticationStatus.received.toString) + } + } + + // ───────────────────────── response shape: C1, C2, C3, C12 ───────────────────────── + + feature("BG v1.3 signing baskets - response shape follows the standard") { + scenario("C1: transactionStatus is upper case in every response that carries it (L4497-4518)", BerlinGroupV1_3, SBS, createSigningBasket, getSigningBasket, getSigningBasketStatus) { + val response = postBasket(s"""{"paymentIds":${idList(List(lodgePayment()))}}""") + response.code should equal(201) + (response.body \ "transactionStatus").extract[String] should equal("RCVD") + val basketId = response.body.extract[SigningBasketResponseJson].basketId + + (makeGetRequest(basketUrl(basketId).GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + (makeGetRequest((basketUrl(basketId) / "status").GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + } + + scenario("C2: _links.scaStatus of a started authorisation is a {href} object (L4801, L10752)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + val response = startAuthorisation(basketId) + response.code should equal(201) + val authorisationId = (response.body \ "authorisationId").extract[String] + (response.body \ "scaStatus").extract[String] should equal("received") + (response.body \ "_links" \ "scaStatus" \ "href").extract[String] should endWith(s"/signing-baskets/$basketId/authorisations/$authorisationId") + } + + scenario("C3: the answer to an authorisation links to the basket's authorisation, not to a payment (L8828, L15675)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + val response = answerAuthorisation(started.basketId, started.authorisationId) + response.code should equal(200) + (response.body \ "scaStatus").extract[String] should equal("finalised") + val href = (response.body \ "_links" \ "scaStatus" \ "href").extract[String] + href should endWith(s"/signing-baskets/${started.basketId}/authorisations/${started.authorisationId}") + href should not include "/payments/" + } + } + + // ───────────────────────── request validation: C5, C7 ───────────────────────── + + feature("BG v1.3 signing baskets - requests are validated against the schema") { + scenario("C5: an empty id list is refused, whichever list it is (L4325, L4365; body 'shall contain at least one entry' L4742)", BerlinGroupV1_3, SBS, createSigningBasket) { + val payment = lodgePayment() + val basketsBefore = storedBasketCount() + List( + """{"paymentIds":[]}""", + """{"consentIds":[]}""", + """{"paymentIds":[],"consentIds":[]}""", + s"""{"paymentIds":${idList(List(payment))},"consentIds":[]}""" + ).foreach { body => + expectRefusal(postBasket(body), 400, "FORMAT_ERROR", s"body $body") + } + withClue("a refused request leaves no basket behind: ") { storedBasketCount() should equal(basketsBefore) } + } + + scenario("C5: the same id twice in one list is refused (the standard sets no rule; refused as a format error)", BerlinGroupV1_3, SBS, createSigningBasket) { + val payment = lodgePayment() + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(payment, payment))}}"""), 400, "FORMAT_ERROR", "duplicate payment id") + } + + scenario("C7: POST authorisations refuses the body variants it does not support instead of discarding them (L3653)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + expectRefusal(startAuthorisation(basketId, body = """{"psuData":{"password":"secret"}}"""), 400, "SERVICE_INVALID", "updatePsuAuthentication") + expectRefusal(startAuthorisation(basketId, body = """{"authenticationMethodId":"sms"}"""), 400, "SERVICE_INVALID", "selectPsuAuthenticationMethod") + withClue("neither refused request minted a challenge: ") { storedChallengeCount(basketId) should equal(0) } + } + + scenario("C7: POST authorisations accepts the two variants it supports (L3653)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + val basketId = createBasket(List(lodgePayment())) + startAuthorisation(basketId, body = "{}").code should equal(201) + startAuthorisation(basketId, body = """{"scaAuthenticationData":"123"}""").code should equal(201) + } + + scenario("C7: PUT refuses the variants it does not support, and a body matching no variant is a format error (L3867, L8250-8300)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId, body = """{"confirmationCode":"123"}"""), 400, "SERVICE_INVALID", "authorisationConfirmation") + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId, body = """{"psuData":{"password":"x"}}"""), 400, "SERVICE_INVALID", "updatePsuAuthentication") + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId, body = """{"foo":"bar"}"""), 400, "FORMAT_ERROR", "matches no variant") + withClue("nothing was authorised: ") { + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + } + } + + // ───────────────────────── states and transitions: C6, C9, C10 ───────────────────────── + + feature("BG v1.3 signing baskets - a basket only moves along the transitions the standard and this ASPSP allow") { + scenario("C9: a deleted basket cannot be authorised, and nothing it held is touched (L3399-3403)", BerlinGroupV1_3, SBS, deleteSigningBasket, startSigningBasketAuthorisation, updateSigningBasketPsuData) { + val started = startedBasket() + makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)).code should equal(204) + storedBasketStatus(started.basketId) should equal(Some("CANC")) + + expectRefusal(startAuthorisation(started.basketId), 409, "STATUS_INVALID", "starting an authorisation on a CANC basket") + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 409, "STATUS_INVALID", "answering an authorisation on a CANC basket") + withClue("the basket stayed CANC and its payment was not touched: ") { + storedBasketStatus(started.basketId) should equal(Some("CANC")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + withClue("deleting a deleted basket is idempotent: ") { + makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)).code should equal(204) + } + } + + scenario("C6: a basket whose authorisation has been applied cannot be deleted or restarted (L3399-3403)", BerlinGroupV1_3, SBS, deleteSigningBasket, startSigningBasketAuthorisation, updateSigningBasketPsuData) { + val started = startedBasket() + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + + expectRefusal(makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)), 409, "STATUS_INVALID", "deleting an authorised basket") + expectRefusal(startAuthorisation(started.basketId), 409, "STATUS_INVALID", "starting another authorisation on an authorised basket") + withClue("the basket is still ACTC, not CANC: ") { storedBasketStatus(started.basketId) should equal(Some("ACTC")) } + } + + scenario("C6: a started but unanswered authorisation does not stop a delete (L3399-3403)", BerlinGroupV1_3, SBS, deleteSigningBasket) { + val started = startedBasket() + makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1)).code should equal(204) + storedBasketStatus(started.basketId) should equal(Some("CANC")) + } + + scenario("C9: answering the same authorisation twice is a conflict and does not repeat anything", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 409, "STATUS_INVALID", "the repeated answer") + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + } + + } + + // ───────────────────────── unknown resources: C4, C8, C11 ───────────────────────── + + feature("BG v1.3 signing baskets - unknown resources are refused with codes the standard allows") { + scenario("C8/D1: an unknown basket is answered 403 RESOURCE_UNKNOWN by every operation that names one (L11648, L11680)", BerlinGroupV1_3, SBS, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, getSigningBasketAuthorisation, startSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val unknown = UUID.randomUUID().toString + val unknownAuthorisation = UUID.randomUUID().toString + val challengesBefore = Challenges.ChallengeProvider.vend.getChallengesByBasketId(unknown).map(_.size).openOrThrowException("x") + List( + "GET basket" -> makeGetRequest(basketUrl(unknown).GET <@ (user1)), + "GET status" -> makeGetRequest((basketUrl(unknown) / "status").GET <@ (user1)), + "DELETE basket" -> makeDeleteRequest(basketUrl(unknown).DELETE <@ (user1)), + "GET authorisations" -> makeGetRequest(authorisationsUrl(unknown).GET <@ (user1)), + "POST authorisation" -> startAuthorisation(unknown), + "GET authorisation" -> makeGetRequest(authorisationUrl(unknown, unknownAuthorisation).GET <@ (user1)), + "PUT authorisation" -> answerAuthorisation(unknown, unknownAuthorisation) + ).foreach { case (what, response) => expectRefusal(response, 403, "RESOURCE_UNKNOWN", what) } + withClue("starting an authorisation on a basket that does not exist minted no challenge: ") { + Challenges.ChallengeProvider.vend.getChallengesByBasketId(unknown).map(_.size).openOrThrowException("x") should equal(challengesBefore) + } + } + + scenario("C4: an authorisation id the basket does not have is 404 RESOURCE_UNKNOWN, never a 200 with a made-up scaStatus (L4521, L11680)", BerlinGroupV1_3, SBS, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket() + val other = startedBasket() + expectRefusal(makeGetRequest(authorisationUrl(started.basketId, UUID.randomUUID().toString).GET <@ (user1)), 404, "RESOURCE_UNKNOWN", "an id nobody issued") + expectRefusal(makeGetRequest(authorisationUrl(started.basketId, other.authorisationId).GET <@ (user1)), 404, "RESOURCE_UNKNOWN", "an id issued for another basket") + expectRefusal(answerAuthorisation(started.basketId, other.authorisationId), 404, "RESOURCE_UNKNOWN", "answering an id issued for another basket") + } + + scenario("C11: a refused creation uses codes from the standard's lists (L11514, L11744, IG §14.11.5)", BerlinGroupV1_3, SBS, createSigningBasket) { + val invented = UUID.randomUUID().toString + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(invented))}}"""), 400, "RESOURCE_UNKNOWN", "invented payment id") + expectRefusal(postBasket("""{"wrongFieldName":["x"]}"""), 400, "FORMAT_ERROR", "unknown field") + } + } + + // ───────────────────────── ownership: S1 ───────────────────────── + + feature("BG v1.3 signing baskets - a basket belongs to the TPP that created it") { + scenario("S1: a second TPP is refused on every operation, and nothing about the basket changes (IG §4.11)", BerlinGroupV1_3, SBS, getSigningBasket, getSigningBasketStatus, deleteSigningBasket, getSigningBasketAuthorisation, startSigningBasketAuthorisation, getSigningBasketScaStatus, updateSigningBasketPsuData) { + val started = startedBasket() + val challengesBefore = storedChallengeCount(started.basketId) + + List( + "GET basket" -> makeGetRequest(basketUrl(started.basketId).GET <@ (user2)), + "GET status" -> makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user2)), + "DELETE basket" -> makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user2)), + "GET authorisations" -> makeGetRequest(authorisationsUrl(started.basketId).GET <@ (user2)), + "POST authorisation" -> startAuthorisation(started.basketId, as = user2), + "GET authorisation" -> makeGetRequest(authorisationUrl(started.basketId, started.authorisationId).GET <@ (user2)), + "PUT authorisation" -> answerAuthorisation(started.basketId, started.authorisationId, as = user2) + ).foreach { case (what, response) => expectRefusal(response, 403, "RESOURCE_UNKNOWN", s"user2 tried to $what") } + + withClue("the basket, its payments and its challenges are as they were: ") { + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + storedChallengeCount(started.basketId) should equal(challengesBefore) + } + And("the TPP that created it still can") + makeGetRequest((basketUrl(started.basketId) / "status").GET <@ (user1)).code should equal(200) + } + + scenario("S1: the same PSU acting through a second TPP is refused too (IG §4.11)", BerlinGroupV1_3, SBS, getSigningBasketStatus, deleteSigningBasket) { + val basketId = createBasket(List(lodgePayment())) + expectRefusal(makeGetRequest((basketUrl(basketId) / "status").GET <@ (samePsuUnderSecondConsumer)), 403, "RESOURCE_UNKNOWN", "status read") + expectRefusal(makeDeleteRequest(basketUrl(basketId).DELETE <@ (samePsuUnderSecondConsumer)), 403, "RESOURCE_UNKNOWN", "delete") + storedBasketStatus(basketId) should equal(Some("RCVD")) + } + + scenario("S1: a basket created before ownership was recorded is quarantined, for everybody", BerlinGroupV1_3, SBS, getSigningBasket, deleteSigningBasket, startSigningBasketAuthorisation) { + // The shape every basket had before ownership existed: a status, members, and no consumer or PSU. + val payment = lodgePayment() + val legacy = MappedSigningBasket.create.Status("RCVD").saveMe() + MappedSigningBasketPayment.create.BasketId(legacy.basketId).PaymentId(payment).saveMe() + + List( + "GET basket" -> makeGetRequest(basketUrl(legacy.basketId).GET <@ (user1)), + "DELETE basket" -> makeDeleteRequest(basketUrl(legacy.basketId).DELETE <@ (user1)), + "POST authorisation" -> startAuthorisation(legacy.basketId) + ).foreach { case (what, response) => expectRefusal(response, 403, "RESOURCE_UNKNOWN", s"the payment's own TPP tried to $what on a legacy basket") } + withClue("the legacy basket is kept as it was, for audit: ") { + storedBasketStatus(legacy.basketId) should equal(Some("RCVD")) + storedChallengeCount(legacy.basketId) should equal(0) + } + } + } + + feature("BG v1.3 signing baskets - a basket only takes members the caller may authorise") { + scenario("S5: consents cannot be put in a basket yet, and no basket is left behind", BerlinGroupV1_3, SBS, createSigningBasket) { + val basketsBefore = storedBasketCount() + expectRefusal(postBasket(s"""{"consentIds":${idList(List("123qwert456789"))}}"""), 400, "SERVICE_INVALID", "a basket of a consent") + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(lodgePayment()))},"consentIds":${idList(List("123qwert456789"))}}"""), 400, "SERVICE_INVALID", "a basket of a payment and a consent") + storedBasketCount() should equal(basketsBefore) } + + scenario("S5: an id that names no payment is refused, and no basket is left behind", BerlinGroupV1_3, SBS, createSigningBasket) { + val basketsBefore = storedBasketCount() + expectRefusal(postBasket(s"""{"paymentIds":${idList(List("123qwert456789", "12345qwert7899"))}}"""), 400, "RESOURCE_UNKNOWN", "invented payment ids") + storedBasketCount() should equal(basketsBefore) + } + + scenario("S5: a payment another TPP lodged looks exactly like one that does not exist", BerlinGroupV1_3, SBS, createSigningBasket) { + val payment = lodgePayment() // lodged by user1 + val basketsBefore = storedBasketCount() + val foreign = postBasket(s"""{"paymentIds":${idList(List(payment))}}""", as = user2) + val invented = postBasket(s"""{"paymentIds":${idList(List(UUID.randomUUID().toString))}}""", as = user2) + expectRefusal(foreign, 400, "RESOURCE_UNKNOWN", "another TPP's payment") + expectRefusal(invented, 400, "RESOURCE_UNKNOWN", "an invented payment") + storedBasketCount() should equal(basketsBefore) + } + + scenario("D8: a payment that is already booked cannot be put in a basket (IG §14.11.5, L11748)", BerlinGroupV1_3, SBS, createSigningBasket) { + val booked = lodgePayment(amount = "10") // under the threshold: booked on creation + storedPaymentStatus(booked) should equal(bookedOnCreation) + expectRefusal(postBasket(s"""{"paymentIds":${idList(List(booked))}}"""), 409, "REFERENCE_STATUS_INVALID", "a booked payment") + } + } + feature("BG v1.3 signing baskets - an authorisation can only be answered through the basket it was issued for") { + scenario("S3: a challenge that was finalised for one basket cannot be replayed to execute another (SB PUT order)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val first = startedBasket() + val second = startedBasket() + answerAuthorisation(first.basketId, first.authorisationId).code should equal(200) // finalises first's challenge + + // The challenge already answered is presented, with a wrong answer, against the other basket. + val replay = answerAuthorisation(second.basketId, first.authorisationId, body = """{"scaAuthenticationData":"wrong"}""") + replay.code should be >= 400 + withClue("the second basket and its payment are untouched, whatever the response said: ") { + storedBasketStatus(second.basketId) should equal(Some("RCVD")) + second.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + } + + scenario("S3: a wrong answer is the standard's incorrect-OTP refusal and changes nothing (IG §14.11 PSU_CREDENTIALS_INVALID, L11597)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val started = startedBasket() + expectRefusal( + answerAuthorisation(started.basketId, started.authorisationId, body = """{"scaAuthenticationData":"wrong"}"""), + 401, "PSU_CREDENTIALS_INVALID", "a wrong one-time password") + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + withClue("the authorisation can still be answered correctly: ") { + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + } + } + + scenario("S2: the same correct answer sent twice at once is accepted once and refused once (contract 3.7)", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 5).foreach { round => + val started = startedBasket() + val answers = (1 to 2).map(_ => Future(answerAuthorisation(started.basketId, started.authorisationId))) + val codes = Await.result(Future.sequence(answers), 60.seconds).map(_.code).sorted + withClue(s"round $round: ") { + codes should equal(List(200, 409)) + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + } + } + } -} \ No newline at end of file + scenario("D9: a basket cannot be authorised unless the instance enables it, and nothing changes while it is not", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") // signing_basket_authorisation_enabled is left at its default + val payment = lodgePayment() + val basketId = createBasket(List(payment)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + expectRefusal(answerAuthorisation(basketId, authorisationId), 403, "SERVICE_BLOCKED", "an instance that has not enabled it") + storedBasketStatus(basketId) should equal(Some("RCVD")) + storedPaymentStatus(payment) should equal(awaitingSca) + withClue("the answer was not consumed: ") { + Challenges.ChallengeProvider.vend.getChallenge(authorisationId).map(_.successful) should equal(net.liftweb.common.Full(false)) + } + setPropsValues("signing_basket_authorisation_enabled" -> "true") + answerAuthorisation(basketId, authorisationId).code should equal(200) + } + } + + // ───────────────────────── execution: the payments are booked, one after another ───────────────────────── + + /** + * A payment that is lodged normally and cannot be booked afterwards: its creditor account exists when + * the payment is created and its IBAN no longer resolves when the payment is executed. + */ + private def lodgePaymentThatCannotBeBooked(): String = { + ibanAccounts.size should be >= 3 + val creditor = ibanAccounts(1) + val payment = lodgePayment(creditorIban = Some(creditor.accountRouting.address)) + BankAccountRouting.findAll( + By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString), + By(BankAccountRouting.BankId, creditor.bankId.value), + By(BankAccountRouting.AccountId, creditor.accountId.value), + By(BankAccountRouting.AccountRoutingAddress, creditor.accountRouting.address)).foreach(_.delete_!) + payment + } + + private def storedBasketStatusRaw(basketId: String): String = + MappedSigningBasket.find(By(MappedSigningBasket.BasketId, basketId)).map(_.Status.get).openOrThrowException("basket") + + feature("BG v1.3 signing baskets - answering the authorisation books the payments, and only then is the basket ACTC") { + scenario("S4: both payments are booked once and the basket is ACTC", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val started = startedBasket(paymentCount = 2) + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + withClue("booked before the response, not eventually: ") { + balanceOf(ibanFrom) should equal(fromBefore - 2 * 2001) + balanceOf(ibanTo) should equal(toBefore + 2 * 2001) + } + started.paymentIds.foreach(storedPaymentStatus(_) should equal("COMPLETED")) + storedBasketStatus(started.basketId) should equal(Some("ACTC")) + } + + scenario("S4: a payment that cannot be booked leaves the basket RCVD, the earlier payment booked, and says so", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val good = lodgePayment() + val bad = lodgePaymentThatCannotBeBooked() + val basketId = createBasket(List(good, bad)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + + val response = answerAuthorisation(basketId, authorisationId) + response.code should equal(200) + (response.body \ "scaStatus").extract[String] should equal("finalised") + (response.body \ "psuMessage").extract[String] should include("not every payment") + + withClue("only the first payment moved money: ") { + balanceOf(ibanFrom) should equal(fromBefore - 2001) + balanceOf(ibanTo) should equal(toBefore + 2001) + } + storedPaymentStatus(good) should equal("COMPLETED") + storedPaymentStatus(bad) should equal(awaitingSca) + withClue("reported as RCVD although stored as authorising: ") { + storedBasketStatusRaw(basketId) should equal("AUTHORISING") + (makeGetRequest((basketUrl(basketId) / "status").GET <@ (user1)).body \ "transactionStatus").extract[String] should equal("RCVD") + } + withClue("the basket is no longer RCVD to be deleted: ") { + expectRefusal(makeDeleteRequest(basketUrl(basketId).DELETE <@ (user1)), 409, "STATUS_INVALID", "deleting an incompletely executed basket") + } + } + + scenario("S4: a payment stored INITIATED is admitted and then booked like one stored RCVD, and ends COMPLETED", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + val payment = lodgePayment() + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, payment)).openOrThrowException("payment") + .mStatus("INITIATED").saveMe() + val basketId = createBasket(List(payment)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + val (fromBefore, toBefore) = (balanceOf(ibanFrom), balanceOf(ibanTo)) + answerAuthorisation(basketId, authorisationId).code should equal(200) + balanceOf(ibanFrom) should equal(fromBefore - 2001) + balanceOf(ibanTo) should equal(toBefore + 2001) + storedPaymentStatus(payment) should equal("COMPLETED") + storedBasketStatus(basketId) should equal(Some("ACTC")) + } + + } + + feature("BG v1.3 signing baskets - a delete racing the final answer has exactly one winner") { + scenario("S2: PUT and DELETE at the same time never both succeed", BerlinGroupV1_3, SBS, deleteSigningBasket, updateSigningBasketPsuData) { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 5).foreach { round => + val started = startedBasket() + val put = Future(answerAuthorisation(started.basketId, started.authorisationId)) + val delete = Future(makeDeleteRequest(basketUrl(started.basketId).DELETE <@ (user1))) + val (putResponse, deleteResponse) = (Await.result(put, 60.seconds), Await.result(delete, 60.seconds)) + withClue(s"round $round (put ${putResponse.code}, delete ${deleteResponse.code}): ") { + (putResponse.code == 200 && deleteResponse.code == 204) should be(false) + storedBasketStatus(started.basketId) match { + case Some("CANC") => started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + case Some("ACTC") => deleteResponse.code should equal(409) + case other => fail(s"the basket ended in $other") + } + } + } + } + } + + feature("BG v1.3 signing baskets - what a review of the execution found") { + scenario("R3: a payment that is no longer waiting for SCA stops the answer before anything is booked", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + enableBasketAuthorisation() + val ibanFrom = ibanAccounts.head + List("REJECTED", "CANCELLED", "FAILED").foreach { status => + val first = lodgePayment() + val second = lodgePayment() + val basketId = createBasket(List(first, second)) + val authorisationId = (startAuthorisation(basketId).body \ "authorisationId").extract[String] + MappedTransactionRequest.find(By(MappedTransactionRequest.mTransactionRequestId, second)).openOrThrowException("payment") + .mStatus(status).saveMe() + val before = balanceOf(ibanFrom) + withClue(s"a payment that is $status: ") { + answerAuthorisation(basketId, authorisationId).code should equal(409) + balanceOf(ibanFrom) should equal(before) + storedPaymentStatus(first) should equal(awaitingSca) + storedBasketStatus(basketId) should equal(Some("RCVD")) + } + } + } + + } + + /** + * Runs `body` with the connector replaced by one that answers the named methods itself and hands every other + * call to the connector that was in use. Not the mapped connector and not the star connector, which is what + * a deployment with a single configured remote connector looks like to the code that asks which one it has. + */ + private def withConnector[A](overrides: PartialFunction[String, Array[AnyRef] => AnyRef])(body: => A): A = { + val original = code.bankconnectors.Connector.connector.vend + val handler = new java.lang.reflect.InvocationHandler { + override def invoke(proxy: AnyRef, method: java.lang.reflect.Method, args: Array[AnyRef]): AnyRef = { + val arguments = Option(args).getOrElse(Array.empty[AnyRef]) + overrides.lift(method.getName) match { + case Some(answer) => answer(arguments) + case None => + try method.invoke(original, arguments: _*) + catch { case e: java.lang.reflect.InvocationTargetException => throw e.getCause } + } + } + } + val replacement = java.lang.reflect.Proxy + .newProxyInstance(classOf[code.bankconnectors.Connector].getClassLoader, Array(classOf[code.bankconnectors.Connector]), handler) + .asInstanceOf[code.bankconnectors.Connector] + code.bankconnectors.Connector.connector.default.set(replacement) + try body finally code.bankconnectors.Connector.connector.default.set(original) + } + + /** The challenge the connector was asked about, reporting the given SCA status instead of its own. */ + private def challengeReporting(challengeId: String, status: StrongCustomerAuthenticationStatus.SCAStatus): com.openbankproject.commons.model.ChallengeTrait = { + val real = Challenges.ChallengeProvider.vend.getChallenge(challengeId).openOrThrowException("the challenge must exist") + java.lang.reflect.Proxy.newProxyInstance( + classOf[com.openbankproject.commons.model.ChallengeTrait].getClassLoader, + Array(classOf[com.openbankproject.commons.model.ChallengeTrait]), + new java.lang.reflect.InvocationHandler { + override def invoke(proxy: AnyRef, method: java.lang.reflect.Method, args: Array[AnyRef]): AnyRef = + if (method.getName == "scaStatus") Some(status) + else try method.invoke(real, Option(args).getOrElse(Array.empty[AnyRef]): _*) + catch { case e: java.lang.reflect.InvocationTargetException => throw e.getCause } + }).asInstanceOf[com.openbankproject.commons.model.ChallengeTrait] + } + + feature("BG v1.3 signing baskets - what a connector other than the mapped one can answer") { + // The connector answers a one-time password it did not accept by handing back the challenge itself, with + // a status that says so, instead of failing. + def connectorAnswering(status: StrongCustomerAuthenticationStatus.SCAStatus): PartialFunction[String, Array[AnyRef] => AnyRef] = { + case "validateChallengeAnswerC5" => arguments => + Future.successful((net.liftweb.common.Full(challengeReporting(arguments(3).asInstanceOf[String], status)), arguments(6))) + } + + scenario("X1: a challenge the connector hands back as failed is a refusal, and authorises nothing", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val started = startedBasket(paymentCount = 2) + val before = balanceOf(ibanFrom) + withConnector(connectorAnswering(StrongCustomerAuthenticationStatus.failed)) { + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 401, "PSU_CREDENTIALS_INVALID", "an answer the connector reports as failed") + } + withClue("nothing was booked, and the basket is as it was: ") { + balanceOf(ibanFrom) should equal(before) + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + } + } + + scenario("X2: a challenge the connector hands back without finalising it authorises nothing either", BerlinGroupV1_3, SBS, updateSigningBasketPsuData) { + val ibanFrom = ibanAccounts.head + val started = startedBasket() + val before = balanceOf(ibanFrom) + withConnector(connectorAnswering(StrongCustomerAuthenticationStatus.received)) { + expectRefusal(answerAuthorisation(started.basketId, started.authorisationId), 401, "PSU_CREDENTIALS_INVALID", "an answer the connector has not finalised") + } + balanceOf(ibanFrom) should equal(before) + storedBasketStatus(started.basketId) should equal(Some("RCVD")) + started.paymentIds.foreach(storedPaymentStatus(_) should equal(awaitingSca)) + withClue("and the PSU can still answer it properly: ") { + answerAuthorisation(started.basketId, started.authorisationId).code should equal(200) + balanceOf(ibanFrom) should equal(before - 2001) + } + } + + } +} diff --git a/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala new file mode 100644 index 0000000000..60353ad9e5 --- /dev/null +++ b/obp-api/src/test/scala/code/signingbaskets/MappedSigningBasketProviderTest.scala @@ -0,0 +1,84 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + + +package code.signingbaskets + +import code.setup.ServerSetup + +import scala.concurrent.duration._ +import scala.concurrent.{Await, Future} + +class MappedSigningBasketProviderTest extends ServerSetup { + + private val provider = MappedSigningBasketProvider + + private def uuid() = java.util.UUID.randomUUID().toString + + private def newBasket(consumerId: String = "consumer-1") = + provider.createSigningBasket(Some(List(uuid(), uuid())), None, consumerId).openOrThrowException("the basket must be created") + + feature("a signing basket records who created it") { + scenario("the creating consumer and the payments are stored, and a basket without a consumer reads as unowned") { + val payments = List(uuid(), uuid()) + val basket = provider.createSigningBasket(Some(payments), None, "consumer-a").openOrThrowException("created") + val stored = provider.getSigningBasketByBasketId(basket.basketId).openOrThrowException("stored") + stored.basket.status should equal("RCVD") + stored.basket.consumerId should equal(Some("consumer-a")) + stored.payments should equal(Some(payments)) + + val legacy = MappedSigningBasket.create.Status("RCVD").saveMe() + provider.getSigningBasketByBasketId(legacy.basketId).openOrThrowException("stored").basket.consumerId should equal(None) + } + } + + feature("a basket moves between statuses with one conditional update") { + scenario("the first caller moves the basket and the next finds it already moved") { + val basket = newBasket() + provider.transitionSigningBasketStatus(basket.basketId, "RCVD", "AUTHORISING").openOrThrowException("moved") should be(true) + provider.transitionSigningBasketStatus(basket.basketId, "RCVD", "AUTHORISING").openOrThrowException("moved") should be(false) + provider.getSigningBasketByBasketId(basket.basketId).openOrThrowException("stored").basket.status should equal("AUTHORISING") + } + + scenario("a transition from the wrong status changes nothing") { + val basket = newBasket() + provider.transitionSigningBasketStatus(basket.basketId, "AUTHORISING", "ACTC").openOrThrowException("moved") should be(false) + provider.getSigningBasketByBasketId(basket.basketId).openOrThrowException("stored").basket.status should equal("RCVD") + } + + scenario("callers racing for different transitions out of RCVD have exactly one winner") { + import scala.concurrent.ExecutionContext.Implicits.global + (1 to 10).foreach { round => + val basket = newBasket() + val moves = List("AUTHORISING", "CANC", "AUTHORISING", "CANC").map(to => + Future(provider.transitionSigningBasketStatus(basket.basketId, "RCVD", to).openOr(false))) + val won = Await.result(Future.sequence(moves), 60.seconds) + withClue(s"round $round: ") { won.count(identity) should equal(1) } + } + } + } +} diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala index 73e58c53b8..dcb5457489 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/CommonModelTrait.scala @@ -99,6 +99,8 @@ trait AccountApplication { trait SigningBasketTrait { def basketId: String def status: String + /** The consumer (TPP) that created the basket. None on a basket created before ownership was recorded. */ + def consumerId: Option[String] = None } case class SigningBasketContent( basket: SigningBasketTrait,