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,