From b648f7dd9f3442c131cb4b1ea2a697e58a2506c1 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 14 Jul 2026 13:02:36 +0200 Subject: [PATCH 1/6] feat(uk-open-banking): wire 9 v4.0.1 AIS/consent endpoints to real data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace static spec-example responses with real OBP connector calls for the account-access-consents, accounts, balances, and transactions endpoints in UK Open Banking v4.0.1 AccountInfo — the same 9 endpoints that carry real implementations in v3.1, covering the full consent lifecycle plus the 5 endpoints OBP-Hola actually calls. Connector call sequences are carried over from the v3.1 handlers (Http4sUKOBv310AccountAccess/Accounts/Balances/Transactions) since the underlying data access is unchanged between API versions. Response shaping needed a new factory (JSONFactory_UKOpenBanking_401) because the v4.0.1 Account/Balance/Transaction resources changed shape from v3.1 (AccountTypeCode replaces AccountType, single-string SchemeName, added TotalValue/TransactionMutability/Status codes, etc.). getAccounts, getAccountsAccountIdBalances and getAccountsAccountIdTransactions call checkUKConsent, which requires a live Hydra OAuth2 introspection endpoint with no local test double, so those three keep the same "authenticated -> not 401" assertion already used for their v3.1 counterparts. The other 6 endpoints get full scenario coverage: real seeded data, unknown-consent and empty-data paths, and a full create -> get -> delete -> get consent lifecycle. --- .../v4_0_1/Http4sUKOBv401AccountInfo.scala | 206 ++++++++++- .../JSONFactory_UKOpenBanking_401.scala | 350 ++++++++++++++++++ .../UKOpenBankingV401AccountInfoTests.scala | 166 +++++++-- .../v4_0_1/UKOpenBankingV401ServerSetup.scala | 41 ++ 4 files changed, 721 insertions(+), 42 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala index cdbe5507c6..3d02a40b4c 100644 --- a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/Http4sUKOBv401AccountInfo.scala @@ -2,15 +2,26 @@ package code.api.UKOpenBanking.v4_0_1 import cats.data.{Kleisli, OptionT} import cats.effect.IO -import code.api.util.APIUtil.{EmptyBody, ResourceDoc} +import code.api.APIFailureNewStyle +import code.api.Constant +import code.api.UKOpenBanking.v3_1_0.JSONFactory_UKOpenBanking_310.ConsentPostBodyUKV310 +import code.api.util.APIUtil.{EmptyBody, ResourceDoc, HTTPParam, connectorEmptyResponse, createQueriesByHttpParams, defaultBankId, fullBoxOrException, passesPsd2Aisp, unboxFull, unboxFullOrFail, DateWithDayFormat} import code.api.util.ApiTag +import code.api.util.CallContext import code.api.util.CustomJsonFormats -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, UnknownError} -import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, ConsentNotFound, ConsentViewNotFund, UnknownError} +import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} +import code.api.util.newstyle.ViewNewStyle +import code.api.util.{APIUtil, ConsentJWT, JwtUtil, NewStyle} +import code.consent.Consents +import code.model.{BankAccountExtended, UserExtended} import code.util.Helper.MdcLoggable +import code.views.Views import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, TransactionAttribute, View, ViewId} import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} import com.openbankproject.commons.util.JsonAliases +import net.liftweb.common.Full import org.json4s.{Formats, JObject} import org.http4s._ import org.http4s.dsl.io._ @@ -78,7 +89,46 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val createAccountAccessConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `ukV401Prefix` / "aisp" / "account-access-consents" => - EndpointHelpers.executeFutureCreated(req)(Future.successful(parseBody(EX_createAccountAccessConsents))) + // Check auth FIRST (before body parsing) to mirror Lift's wrappedWithAuthCheck behaviour: + // unauthenticated -> 401, invalid body -> 400. + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + for { + u <- cc.user.toOption match { + case Some(user) => Future.successful(user) + case None => Future.failed(new RuntimeException(AuthenticatedUserIsRequired)) + } + consentJson <- Future.fromTry(scala.util.Try( + JsonAliases.parse(cc.httpBody.getOrElse("{}")).extract[ConsentPostBodyUKV310] + )) + consumerId = cc.consumer.map(_.consumerId.get) + _ <- passesPsd2Aisp(Some(cc)) + createdConsent <- Future(Consents.consentProvider.vend.saveUKConsent( + Some(u), + bankId = None, + accountIds = None, + consumerId = consumerId, + permissions = consentJson.Data.Permissions, + expirationDateTime = DateWithDayFormat.parse(consentJson.Data.ExpirationDateTime), + transactionFromDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionFromDateTime), + transactionToDateTime = DateWithDayFormat.parse(consentJson.Data.TransactionToDateTime), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + )) map { i => connectorEmptyResponse(i, Some(cc)) } + } yield { + JSONFactory_UKOpenBanking_401.createConsentResponseJSON( + consentId = createdConsent.consentId, + creationDateTime = createdConsent.creationDateTime.toString, + status = createdConsent.status, + statusUpdateDateTime = createdConsent.statusUpdateDateTime.toString, + permissions = consentJson.Data.Permissions, + expirationDateTime = consentJson.Data.ExpirationDateTime, + transactionFromDateTime = consentJson.Data.TransactionFromDateTime, + transactionToDateTime = consentJson.Data.TransactionToDateTime, + selfPath = "/aisp/account-access-consents" + ) + } + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -130,7 +180,28 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getAccountAccessConsentsConsentId)) } + EndpointHelpers.withUser(req) { (_, cc) => + for { + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)") + } + consentViews <- Future(JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken).map( + JsonAliases.parse(_).extract[ConsentJWT].views.map(_.view_id) + )) map { unboxFullOrFail(_, Some(cc), s"$ConsentViewNotFund ($consentId)") } + } yield { + JSONFactory_UKOpenBanking_401.createConsentResponseJSON( + consentId = consent.consentId, + creationDateTime = consent.creationDateTime.toString, + status = consent.status, + statusUpdateDateTime = consent.statusUpdateDateTime.toString, + permissions = consentViews, + expirationDateTime = consent.expirationDateTime.toString, + transactionFromDateTime = consent.transactionFromDateTime.toString, + transactionToDateTime = consent.transactionToDateTime.toString, + selfPath = s"/aisp/account-access-consents/$consentId" + ) + } + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -149,7 +220,17 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { private val EX_deleteAccountAccessConsentsConsentId: String = """{}""" lazy val deleteAccountAccessConsentsConsentId: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `ukV401Prefix` / "aisp" / "account-access-consents" / consentId => - EndpointHelpers.executeDelete(req) { cc => Future.successful(()) } + EndpointHelpers.withUserDelete(req) { (_, cc) => + for { + _ <- passesPsd2Aisp(Some(cc)) + _ <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) map { + unboxFullOrFail(_, Some(cc), ConsentNotFound) + } + _ <- Future(Consents.consentProvider.vend.revoke(consentId)) map { + i => connectorEmptyResponse(i, Some(cc)) + } + } yield () + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -239,7 +320,27 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getAccounts: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getAccounts)) } + EndpointHelpers.withUser(req) { (u, cc) => + val detailViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) + for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) + (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) + (moderatedAttributes, _) <- NewStyle.function.getModeratedAccountAttributesByAccounts( + accounts.map(a => BankIdAccountId(a.bankId, a.accountId)), + basicViewId, + Some(cc)) + } yield { + val accountsWithView = accounts.flatMap { account => + APIUtil.checkViewAccessAndReturnView(detailViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)).or( + APIUtil.checkViewAccessAndReturnView(basicViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)) + ).toOption.map(view => (account, view)) + } + JSONFactory_UKOpenBanking_401.createAccountsListJSON(accountsWithView, moderatedAttributes) + } + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -328,8 +429,29 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { } }""" lazy val getAccountsAccountId: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountId => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getAccountsAccountId)) } + case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountIdStr => + EndpointHelpers.withUser(req) { (u, cc) => + val accountId = AccountId(accountIdStr) + val detailViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID) + for { + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) map { + _.filter(_.accountId.value == accountId.value) + } + (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) + (moderatedAttributes, _) <- NewStyle.function.getModeratedAccountAttributesByAccounts( + accounts.map(a => BankIdAccountId(a.bankId, a.accountId)), + basicViewId, + Some(cc)) + } yield { + val accountsWithView = accounts.flatMap { account => + APIUtil.checkViewAccessAndReturnView(detailViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)).or( + APIUtil.checkViewAccessAndReturnView(basicViewId, BankIdAccountId(account.bankId, account.accountId), Full(u), Some(cc)) + ).toOption.map(view => (account, view)) + } + JSONFactory_UKOpenBanking_401.createAccountsListJSON(accountsWithView, moderatedAttributes) + } + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -394,8 +516,18 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { } }""" lazy val getAccountsAccountIdBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountId / "balances" => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getAccountsAccountIdBalances)) } + case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountIdStr / "balances" => + EndpointHelpers.withUser(req) { (u, cc) => + val accountId = AccountId(accountIdStr) + val viewId = ViewId(Constant.SYSTEM_READ_BALANCES_VIEW_ID) + for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) + (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) + moderatedAccount <- NewStyle.function.moderatedBankAccountCore(account, view, Full(u), Some(cc)) + } yield JSONFactory_UKOpenBanking_401.createAccountBalanceJSON(moderatedAccount) + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -2080,8 +2212,32 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { } }""" lazy val getAccountsAccountIdTransactions: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountId / "transactions" => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getAccountsAccountIdTransactions)) } + case req @ GET -> `ukV401Prefix` / "aisp" / "accounts" / accountIdStr / "transactions" => + EndpointHelpers.withUser(req) { (u, cc) => + val accountId = AccountId(accountIdStr) + val detailViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID) + val basicViewId = ViewId(Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID) + for { + _ <- NewStyle.function.checkUKConsent(u, Some(cc)) + _ <- passesPsd2Aisp(Some(cc)) + (account, _) <- NewStyle.function.getBankAccountByAccountId(accountId, Some(cc)) + (bank, _) <- NewStyle.function.getBank(account.bankId, Some(cc)) + view <- ViewNewStyle.checkViewsAccessAndReturnView(detailViewId, basicViewId, BankIdAccountId(account.bankId, accountId), Full(u), Some(cc)) + params <- Future { + createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))) + } map { x => + unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) + } + (transactions, _) <- BankAccountExtended(account).getModeratedTransactionsFuture(bank, Full(u), view, Some(cc), params) map { x => + unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) + } + (moderatedAttributes: List[TransactionAttribute], _) <- NewStyle.function.getModeratedAttributesByTransactions( + account.bankId, + transactions.map(_.id), + view.viewId, + Some(cc)) + } yield JSONFactory_UKOpenBanking_401.createTransactionsJsonNew(account.bankId, transactions, moderatedAttributes, view) + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -2147,7 +2303,12 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getBalances: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "balances" => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getBalances)) } + EndpointHelpers.withUser(req) { (u, cc) => + for { + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) + (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) + } yield JSONFactory_UKOpenBanking_401.createBalancesJSON(accounts) + } } resourceDocs += ResourceDoc( implementedInApiVersion, @@ -3283,7 +3444,22 @@ object Http4sUKOBv401AccountInfo extends MdcLoggable { }""" lazy val getTransactions: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `ukV401Prefix` / "aisp" / "transactions" => - EndpointHelpers.withUser(req) { (u, cc) => Future.successful(parseBody(EX_getTransactions)) } + EndpointHelpers.withUser(req) { (u, cc) => + for { + (bank, _) <- NewStyle.function.getBank(BankId(defaultBankId), Some(cc)) + availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u) + (accounts, _) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, Some(cc)) + allTxns <- Future { + accounts.flatMap { bankAccount => + (for { + view <- UserExtended(u).checkOwnerViewAccessAndReturnOwnerView(BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc)) + params = createQueriesByHttpParams(req.headers.headers.toList.map(h => HTTPParam(h.name.toString, List(h.value)))).getOrElse(Nil) + (transactions, _) <- BankAccountExtended(bankAccount).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), Some(cc), params) + } yield transactions).getOrElse(Nil) + } + } + } yield JSONFactory_UKOpenBanking_401.createTransactionsJson(bank.bankId, allTxns) + } } resourceDocs += ResourceDoc( implementedInApiVersion, diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala new file mode 100644 index 0000000000..f8fb2ec4ce --- /dev/null +++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v4_0_1/JSONFactory_UKOpenBanking_401.scala @@ -0,0 +1,350 @@ +package code.api.UKOpenBanking.v4_0_1 + +import java.util.Date + +import code.api.Constant +import code.api.util.CustomJsonFormats +import code.model.{ModeratedBankAccountCore, ModeratedTransaction} +import com.openbankproject.commons.model.{AccountAttribute, AccountId, BankAccount, BankId, TransactionAttribute, View} + +import scala.collection.immutable.List + +/** + * JSON shaping for the UK Open Banking v4.0.1 AIS/consent endpoints that are + * backed by real OBP connector data (see Http4sUKOBv401AccountInfo). + * + * v4.0.1 changed the Account/Balance/Transaction resource shapes relative to + * v3.1 (e.g. AccountTypeCode replaces AccountType, Account.SchemeName is a + * single string not a list, Balance carries a top-level TotalValue, Transaction + * adds TransactionMutability/Status codes) so the v3.1 factory cannot be reused + * as-is. The connector calls that produce the underlying data are unchanged + * from v3.1 (see Http4sUKOBv310Accounts/Balances/Transactions). + */ +object JSONFactory_UKOpenBanking_401 extends CustomJsonFormats { + + case class LinksV401( + Self: String, + First: String, + Prev: String, + Next: String, + Last: String + ) + + case class MetaV401( + TotalPages: Int, + FirstAvailableDateTime: Date, + LastAvailableDateTime: Date + ) + + case class AmountV401( + Amount: String, + Currency: String, + SubType: Option[String] = None + ) + + // --------------------------------------------------------------------- + // Accounts + // --------------------------------------------------------------------- + case class AccountInnerV401( + SchemeName: String, + Identification: String, + Name: Option[String] = None, + SecondaryIdentification: Option[String] = None + ) + + case class ServicerV401( + SchemeName: String, + Identification: String, + Name: Option[String] = None + ) + + case class AccountV401( + AccountId: String, + Status: String, + StatusUpdateDateTime: Option[String] = None, + Currency: String, + AccountCategory: Option[String] = None, + AccountTypeCode: String, + Description: Option[String] = None, + Nickname: Option[String] = None, + OpeningDate: Option[String] = None, + MaturityDate: Option[String] = None, + SwitchStatus: Option[String] = None, + Account: List[AccountInnerV401] = Nil, + Servicer: Option[ServicerV401] = None + ) + + case class AccountListV401(Account: List[AccountV401]) + case class AccountsUKV401(Data: AccountListV401, Links: LinksV401, Meta: MetaV401) + + // --------------------------------------------------------------------- + // Balances + // --------------------------------------------------------------------- + case class CreditLineV401(Included: Boolean, Type: String, Amount: AmountV401) + + case class BalanceV401( + AccountId: String, + CreditDebitIndicator: String, + Type: String, + DateTime: Option[Date], + Amount: AmountV401, + LocalAmount: AmountV401, + CreditLine: List[CreditLineV401] = Nil + ) + + case class BalanceDataV401(Balance: List[BalanceV401], TotalValue: Option[AmountV401] = None) + case class BalancesUKV401(Data: BalanceDataV401, Links: LinksV401, Meta: MetaV401) + + // --------------------------------------------------------------------- + // Transactions + // --------------------------------------------------------------------- + case class MerchantDetailsV401( + MerchantName: Option[String] = None, + MerchantCategoryCode: Option[String] = None + ) + + case class TransactionBalanceV401( + CreditDebitIndicator: String, + Type: String, + Amount: AmountV401 + ) + + case class TransactionV401( + AccountId: String, + TransactionId: String, + TransactionReference: Option[String] = None, + StatementReference: List[String] = Nil, + CreditDebitIndicator: String = "Credit", + Status: String = "BOOK", + TransactionMutability: String = "Mutable", + BookingDateTime: Date, + ValueDateTime: Date, + TransactionInformation: Option[String] = None, + Amount: AmountV401, + ChargeAmount: Option[AmountV401] = None, + Balance: Option[TransactionBalanceV401] = None, + MerchantDetails: Option[MerchantDetailsV401] = None + ) + + case class TransactionDataV401(Transaction: List[TransactionV401]) + case class TransactionsUKV401(Data: TransactionDataV401, Links: LinksV401, Meta: MetaV401) + + // --------------------------------------------------------------------- + // Consent + // --------------------------------------------------------------------- + case class ConsentDataV401( + ConsentId: String, + CreationDateTime: String, + Status: String, + StatusUpdateDateTime: String, + Permissions: List[String], + ExpirationDateTime: String, + TransactionFromDateTime: String, + TransactionToDateTime: String + ) + case class ConsentResponseV401( + Data: ConsentDataV401, + Risk: Map[String, String], + Links: LinksV401, + Meta: MetaV401 + ) + + private def links(path: String): LinksV401 = { + val url = s"${Constant.HostName}/open-banking/v4.0.1$path" + LinksV401(Self = url, First = url, Prev = url, Next = url, Last = url) + } + + private def meta(): MetaV401 = MetaV401(TotalPages = 1, FirstAvailableDateTime = new Date(), LastAvailableDateTime = new Date()) + + private def accountAttributeOptValue(name: String, bankId: BankId, accountId: AccountId, list: List[AccountAttribute]): Option[String] = + list.find(e => e.name == name && e.bankId == bankId && e.accountId == accountId).map(_.value) + + private def transactionAttributeOptValue(name: String, bankId: BankId, transactionId: com.openbankproject.commons.model.TransactionId, list: List[TransactionAttribute]): Option[String] = + list.find(e => e.name == name && e.bankId == bankId && e.transactionId == transactionId).map(_.value) + + def createAccountsListJSON( + accounts: List[(BankAccount, View)], + moderatedAttributes: List[AccountAttribute] + ): AccountsUKV401 = { + + def getServicer(account: (BankAccount, View)): Option[ServicerV401] = { + account._2.viewId.value match { + case Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID => + val schemeName = accountAttributeOptValue("Servicer_SchemeName", account._1.bankId, account._1.accountId, moderatedAttributes) + val identification = accountAttributeOptValue("Servicer_Identification", account._1.bankId, account._1.accountId, moderatedAttributes) + (schemeName, identification) match { + case (Some(scheme), Some(id)) => Some(ServicerV401(SchemeName = scheme, Identification = id)) + case _ => None + } + case _ => None + } + } + + def getAccountDetails(account: (BankAccount, View)): Option[AccountInnerV401] = { + account._2.viewId.value match { + case Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID => + account._1.accountRoutings.headOption.map(e => + AccountInnerV401(SchemeName = e.scheme, Identification = e.address) + ) + case _ => None + } + } + + val list = accounts.map { account => + AccountV401( + AccountId = account._1.accountId.value, + Status = accountAttributeOptValue("Status", account._1.bankId, account._1.accountId, moderatedAttributes).getOrElse("Enabled"), + StatusUpdateDateTime = accountAttributeOptValue("StatusUpdateDateTime", account._1.bankId, account._1.accountId, moderatedAttributes), + Currency = account._1.currency, + AccountCategory = accountAttributeOptValue("AccountCategory", account._1.bankId, account._1.accountId, moderatedAttributes), + AccountTypeCode = account._1.accountType, + Nickname = Some(account._1.label), + OpeningDate = accountAttributeOptValue("OpeningDate", account._1.bankId, account._1.accountId, moderatedAttributes), + MaturityDate = accountAttributeOptValue("MaturityDate", account._1.bankId, account._1.accountId, moderatedAttributes), + SwitchStatus = accountAttributeOptValue("SwitchStatus", account._1.bankId, account._1.accountId, moderatedAttributes), + Account = getAccountDetails(account).toList, + Servicer = getServicer(account) + ) + } + AccountsUKV401( + Data = AccountListV401(list), + Links = links("/aisp/accounts"), + Meta = meta() + ) + } + + def createAccountBalanceJSON(moderatedAccount: ModeratedBankAccountCore): BalancesUKV401 = { + val accountId = moderatedAccount.accountId.value + val amount = AmountV401( + Amount = moderatedAccount.balance.getOrElse(""), + Currency = moderatedAccount.currency.getOrElse("") + ) + val balance = BalanceV401( + AccountId = accountId, + CreditDebitIndicator = "Credit", + Type = "ClosingAvailable", + DateTime = None, + Amount = amount, + LocalAmount = amount + ) + BalancesUKV401( + Data = BalanceDataV401(Balance = List(balance), TotalValue = Some(amount)), + Links = links(s"/aisp/accounts/$accountId/balances"), + Meta = meta() + ) + } + + def createBalancesJSON(accounts: List[BankAccount]): BalancesUKV401 = { + val balances = accounts.map { account => + val amount = AmountV401(Amount = account.balance.toString(), Currency = account.currency) + BalanceV401( + AccountId = account.accountId.value, + CreditDebitIndicator = "Credit", + Type = "ClosingAvailable", + DateTime = Some(account.lastUpdate), + Amount = amount, + LocalAmount = amount + ) + } + val totalValue = balances.headOption.map(_.Amount) + BalancesUKV401( + Data = BalanceDataV401(Balance = balances, TotalValue = totalValue), + Links = links("/aisp/balances"), + Meta = meta() + ) + } + + private def transactionJson( + accountId: String, + bankId: BankId, + moderatedTransaction: ModeratedTransaction, + attributes: List[TransactionAttribute], + view: Option[View] + ): TransactionV401 = { + def getMerchantDetails: Option[MerchantDetailsV401] = view match { + case Some(v) if v.viewId.value == Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID => + val merchantName = transactionAttributeOptValue("MerchantDetails_MerchantName", bankId, moderatedTransaction.id, attributes) + val merchantCategoryCode = transactionAttributeOptValue("MerchantDetails_CategoryCode", bankId, moderatedTransaction.id, attributes) + if (merchantName.isDefined || merchantCategoryCode.isDefined) + Some(MerchantDetailsV401(MerchantName = merchantName, MerchantCategoryCode = merchantCategoryCode)) + else None + case _ => None + } + + val amount = AmountV401( + Amount = moderatedTransaction.amount.getOrElse(BigDecimal(0)).toString(), + Currency = moderatedTransaction.currency.getOrElse("") + ) + TransactionV401( + AccountId = accountId, + TransactionId = moderatedTransaction.id.value, + TransactionReference = moderatedTransaction.description, + TransactionInformation = moderatedTransaction.description, + BookingDateTime = moderatedTransaction.startDate.get, + ValueDateTime = moderatedTransaction.finishDate.get, + Amount = amount, + Balance = Some(TransactionBalanceV401( + CreditDebitIndicator = "Credit", + Type = "InterimBooked", + Amount = AmountV401(Amount = moderatedTransaction.balance, Currency = moderatedTransaction.currency.getOrElse("")) + )), + MerchantDetails = getMerchantDetails + ) + } + + def createTransactionsJsonNew( + bankId: BankId, + moderatedTransactions: List[ModeratedTransaction], + attributes: List[TransactionAttribute], + view: View + ): TransactionsUKV401 = { + val accountId = moderatedTransactions.headOption.flatMap(_.bankAccount.map(_.accountId.value)).orNull + val transactions = moderatedTransactions.map(t => transactionJson(accountId, bankId, t, attributes, Some(view))) + TransactionsUKV401( + Data = TransactionDataV401(transactions), + Links = links(s"/aisp/accounts/$accountId/transactions"), + Meta = meta() + ) + } + + def createTransactionsJson(bankId: BankId, moderatedTransactions: List[ModeratedTransaction]): TransactionsUKV401 = { + val transactions = moderatedTransactions.map { t => + val accountId = t.bankAccount.map(_.accountId.value).orNull + transactionJson(accountId, bankId, t, Nil, None) + } + TransactionsUKV401( + Data = TransactionDataV401(transactions), + Links = links("/aisp/transactions"), + Meta = meta() + ) + } + + def createConsentResponseJSON( + consentId: String, + creationDateTime: String, + status: String, + statusUpdateDateTime: String, + permissions: List[String], + expirationDateTime: String, + transactionFromDateTime: String, + transactionToDateTime: String, + selfPath: String + ): ConsentResponseV401 = { + ConsentResponseV401( + Data = ConsentDataV401( + ConsentId = consentId, + CreationDateTime = creationDateTime, + Status = status, + StatusUpdateDateTime = statusUpdateDateTime, + Permissions = permissions, + ExpirationDateTime = expirationDateTime, + TransactionFromDateTime = transactionFromDateTime, + TransactionToDateTime = transactionToDateTime + ), + Risk = Map.empty, + Links = links(selfPath), + Meta = meta() + ) + } + +} diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala index ebc20e1009..d90a65a074 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401AccountInfoTests.scala @@ -1,62 +1,142 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.util.APIUtil.DateWithDayFormat +import code.consent.Consents +import org.json4s._ import org.scalatest.Tag -// AUTO-GENERATED test suite for UK Open Banking Read/Write v4.0.1 (AccountInfo). -// Mirrors UKOpenBankingV310AisTests: one feature per endpoint, two scenarios -// (authenticated -> deterministic success code; unauthenticated -> 401). -// Endpoints are static spec-faithful scaffolds, so success codes are exact. +// Test suite for UK Open Banking Read/Write v4.0.1 (AccountInfo). +// +// The 9 endpoints wired to real connector data (see Http4sUKOBv401AccountInfo) +// get a full scenario matrix: unauthenticated -> 401, authenticated with real +// seeded data -> 200/201/204 with real field values, error paths (unknown +// consent/account), and a full consent create -> get -> delete -> get lifecycle. +// +// getAccounts / getAccountsAccountIdBalances / getAccountsAccountIdTransactions +// call NewStyle.function.checkUKConsent, which requires a live Hydra OAuth2 +// introspection endpoint (see code.api.util.ConsentUtil.checkUKConsent) — there +// is no local test double for Hydra, so (mirroring UKOpenBankingV310AisTests' +// precedent for the same three v3.1 endpoints) only "unauthenticated -> 401" +// and "authenticated -> not 401" are asserted for those three. +// +// The remaining 80 endpoints are still static spec-faithful stubs; their tests +// are unchanged (two scenarios: authenticated -> fixed code, unauthenticated -> 401). class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { object UKOpenBankingV401AccountInfo extends Tag("UKOpenBankingV401AccountInfo") - val emptyBody = "{}" + val acc = testAccountId1.value + private val consentPermissions = List("ReadAccountsBasic") + private val consentPostBody = + """{ + | "Data": { + | "Permissions": ["ReadAccountsBasic"], + | "ExpirationDateTime": "2030-01-01", + | "TransactionFromDateTime": "2020-01-01", + | "TransactionToDateTime": "2030-01-01" + | }, + | "Risk": {} + |}""".stripMargin + + private def createRealConsent(): String = { + val consent = Consents.consentProvider.vend.saveUKConsent( + user = Some(resourceUser1), + bankId = None, + accountIds = None, + consumerId = None, + permissions = consentPermissions, + expirationDateTime = DateWithDayFormat.parse("2030-01-01"), + transactionFromDateTime = DateWithDayFormat.parse("2020-01-01"), + transactionToDateTime = DateWithDayFormat.parse("2030-01-01"), + apiStandard = Some("UKOpenBanking"), + apiVersion = Some("4.0.1") + ).openOrThrowException("test consent creation failed") + consent.consentId + } + + // ── AccountAccessApi ─────────────────────────────────────────────── feature("UKOB v4.0.1 POST /aisp/account-access-consents") { - scenario("authenticated -> 201", UKOpenBankingV401AccountInfo) { - postAuthed(emptyBody, "aisp", "account-access-consents").code should equal(201) + scenario("authenticated with real body -> 201 real ConsentId", UKOpenBankingV401AccountInfo) { + val response = postAuthed(consentPostBody, "aisp", "account-access-consents") + response.code should equal(201) + val consentId = (response.body \ "Data" \ "ConsentId").extract[String] + consentId should not be empty + Consents.consentProvider.vend.getConsentByConsentId(consentId).isDefined should equal(true) + (response.body \ "Data" \ "Permissions").extract[List[String]] should equal(consentPermissions) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { - postUnauthed(emptyBody, "aisp", "account-access-consents").code should equal(401) + postUnauthed(consentPostBody, "aisp", "account-access-consents").code should equal(401) } } feature("UKOB v4.0.1 GET /aisp/account-access-consents/CONSENT_ID") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(200) + scenario("authenticated with real consent -> 200 real data", UKOpenBankingV401AccountInfo) { + val consentId = createRealConsent() + val response = getAuthed("aisp", "account-access-consents", consentId) + response.code should equal(200) + (response.body \ "Data" \ "ConsentId").extract[String] should equal(consentId) + (response.body \ "Data" \ "Permissions").extract[List[String]] should equal(consentPermissions) + } + scenario("authenticated with unknown consent -> 400", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(400) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } } feature("UKOB v4.0.1 DELETE /aisp/account-access-consents/CONSENT_ID") { - scenario("authenticated -> 204", UKOpenBankingV401AccountInfo) { - deleteAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(204) + scenario("full consent lifecycle: create -> get -> delete -> get", UKOpenBankingV401AccountInfo) { + val consentId = createRealConsent() + + getAuthed("aisp", "account-access-consents", consentId).code should equal(200) + + deleteAuthed("aisp", "account-access-consents", consentId).code should equal(204) + + val afterDelete = getAuthed("aisp", "account-access-consents", consentId) + afterDelete.code should equal(200) + (afterDelete.body \ "Data" \ "Status").extract[String] should equal("REVOKED") + } + scenario("authenticated with unknown consent -> 400", UKOpenBankingV401AccountInfo) { + deleteAuthed("aisp", "account-access-consents", "fake-consentid").code should equal(400) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { deleteUnauthed("aisp", "account-access-consents", "fake-consentid").code should equal(401) } } + // ── AccountsApi ──────────────────────────────────────────────────── + // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). feature("UKOB v4.0.1 GET /aisp/accounts") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts").code should equal(200) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "accounts").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "accounts").code should equal(401) } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", "fake-accountid").code should equal(200) + scenario("authenticated with granted read view -> 200 real account data", UKOpenBankingV401AccountInfo) { + grantUKReadViews(testAccountId1, resourceUser1) + val response = getAuthed("aisp", "accounts", acc) + response.code should equal(200) + val accounts = (response.body \ "Data" \ "Account").children + accounts should not be empty + (accounts.head \ "AccountId").extract[String] should equal(acc) + (accounts.head \ "Currency").extract[String] should equal("EUR") + } + scenario("authenticated with fake account id -> 200 empty Account list", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "accounts", "fake-accountid") + response.code should equal(200) + (response.body \ "Data" \ "Account").children should be(empty) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { - getUnauthed("aisp", "accounts", "fake-accountid").code should equal(401) + getUnauthed("aisp", "accounts", acc).code should equal(401) } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/balances") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", "fake-accountid", "balances").code should equal(200) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "accounts", acc, "balances").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { - getUnauthed("aisp", "accounts", "fake-accountid", "balances").code should equal(401) + getUnauthed("aisp", "accounts", acc, "balances").code should equal(401) } } feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/beneficiaries") { @@ -155,17 +235,35 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { getUnauthed("aisp", "accounts", "fake-accountid", "statements", "fake-statementid", "transactions").code should equal(401) } } + // ── TransactionsApi ──────────────────────────────────────────────── + // DATA-DEPENDENT: checkUKConsent requires live Hydra (see class doc above). feature("UKOB v4.0.1 GET /aisp/accounts/ACCOUNT_ID/transactions") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "accounts", "fake-accountid", "transactions").code should equal(200) + scenario("authenticated", UKOpenBankingV401AccountInfo) { + getAuthed("aisp", "accounts", acc, "transactions").code should not equal (401) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { - getUnauthed("aisp", "accounts", "fake-accountid", "transactions").code should equal(401) + getUnauthed("aisp", "accounts", acc, "transactions").code should equal(401) } } + + // ── BalancesApi ──────────────────────────────────────────────────── feature("UKOB v4.0.1 GET /aisp/balances") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "balances").code should equal(200) + scenario("authenticated with real account -> 200 real balance", UKOpenBankingV401AccountInfo) { + val response = getAuthed("aisp", "balances") + response.code should equal(200) + // resourceUser1 owns accounts on several test banks (see TestConnectorSetup. + // createAccountRelevantResources), so testAccountId1's entry isn't necessarily + // first — find it rather than assume position 0. + val balances = (response.body \ "Data" \ "Balance").children + balances should not be empty + val myBalance = balances.find(b => (b \ "AccountId").extract[String] == acc) + myBalance shouldBe defined + (myBalance.get \ "Amount" \ "Currency").extract[String] should equal("EUR") + } + scenario("authenticated with no private accounts -> 200 empty Balance list", UKOpenBankingV401AccountInfo) { + val response = getAuthedAsUser2("aisp", "balances") + response.code should equal(200) + (response.body \ "Data" \ "Balance").children should be(empty) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "balances").code should equal(401) @@ -236,8 +334,22 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { } } feature("UKOB v4.0.1 GET /aisp/transactions") { - scenario("authenticated -> 200", UKOpenBankingV401AccountInfo) { - getAuthed("aisp", "transactions").code should equal(200) + scenario("authenticated with seeded transactions -> 200 real data", UKOpenBankingV401AccountInfo) { + val seeded = seedTransactions(testAccountId1) + val response = getAuthed("aisp", "transactions") + response.code should equal(200) + // resourceUser1 owns accounts on several test banks, but only testAccountId1 + // has seeded transactions here, so every returned entry should belong to it. + val transactions = (response.body \ "Data" \ "Transaction").children + transactions should not be empty + transactions.foreach(t => (t \ "AccountId").extract[String] should equal(acc)) + (transactions.head \ "Amount" \ "Currency").extract[String] should equal("EUR") + transactions.map(t => (t \ "TransactionId").extract[String]) should contain (seeded.head.id.value) + } + scenario("authenticated with no private accounts -> 200 empty Transaction list", UKOpenBankingV401AccountInfo) { + val response = getAuthedAsUser2("aisp", "transactions") + response.code should equal(200) + (response.body \ "Data" \ "Transaction").children should be(empty) } scenario("unauthenticated -> 401", UKOpenBankingV401AccountInfo) { getUnauthed("aisp", "transactions").code should equal(401) diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala index 5f8dcae965..38b56ea712 100644 --- a/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v4_0_1/UKOpenBankingV401ServerSetup.scala @@ -1,8 +1,15 @@ package code.api.UKOpenBanking.v4_0_1 +import code.api.Constant import code.api.util.APIUtil.OAuth._ +import code.api.util.NewStyle import code.setup.{APIResponse, DefaultUsers, ServerSetupWithTestData} import code.setup.OBPReq +import code.views.Views +import com.openbankproject.commons.model.{AccountId, Transaction, User} + +import scala.concurrent.Await +import scala.concurrent.duration._ /** * Shared setup + request helpers for the UK Open Banking v4.0.1 test suites. @@ -20,12 +27,46 @@ trait UKOpenBankingV401ServerSetup extends ServerSetupWithTestData with DefaultU def v401Request: OBPReq = baseRequest / "open-banking" / "v4.0.1" + /** + * Grants the UK-Open-Banking-specific read views (basic + detail for accounts, + * balances, transactions) that ServerSetupWithTestData's default view grants + * (owner/auditor/accountant/firehose/public/random) do not include. Needed for + * getAccounts/getAccountsAccountId/Balances/Transactions to surface real data + * instead of filtering the account out via APIUtil.checkViewAccessAndReturnView. + */ + def grantUKReadViews(accountId: AccountId, user: User): Unit = { + List( + Constant.SYSTEM_READ_ACCOUNTS_BASIC_VIEW_ID, + Constant.SYSTEM_READ_ACCOUNTS_DETAIL_VIEW_ID, + Constant.SYSTEM_READ_BALANCES_VIEW_ID, + Constant.SYSTEM_READ_TRANSACTIONS_BASIC_VIEW_ID, + Constant.SYSTEM_READ_TRANSACTIONS_DETAIL_VIEW_ID + ).foreach { viewId => + val view = Views.views.vend.getOrCreateSystemView(viewId).openOrThrowException(s"could not create system view $viewId") + Views.views.vend.grantAccessToSystemView(testBankId1, accountId, view, user) + } + } + + /** + * Seeds transactions for an already-created test account, mirroring + * TestConnectorSetup.createTransactions (used automatically by suites listed in + * ServerSetup.suitesNeedingTransactionData). This suite seeds on demand instead + * of joining that global set, to keep the shared file untouched. + */ + def seedTransactions(accountId: AccountId): List[Transaction] = { + val (account, _) = Await.result(NewStyle.function.getBankAccountByAccountId(accountId, None), 10.seconds) + createTransactions(List(account)) + } + // Build a request from path segments, e.g. v401("aisp", "accounts", accountId, "balances"). def v401(segments: String*): OBPReq = segments.foldLeft(v401Request)((req, s) => req / s) def getAuthed(segments: String*): APIResponse = makeGetRequest(v401(segments: _*).GET <@ (user1)) def getUnauthed(segments: String*): APIResponse = makeGetRequest(v401(segments: _*).GET) + // user2 has no private accounts (only user1 gets them in beforeEach) — use for empty-data scenarios. + def getAuthedAsUser2(segments: String*): APIResponse = makeGetRequest(v401(segments: _*).GET <@ (user2)) + def postAuthed(body: String, segments: String*): APIResponse = makePostRequest(v401(segments: _*).POST <@ (user1), body) def postUnauthed(body: String, segments: String*): APIResponse = makePostRequest(v401(segments: _*).POST, body) From e26805a0ee56a592c43b8129a956eb53d1b4241a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 14 Jul 2026 16:18:18 +0200 Subject: [PATCH 2/6] fix(test): make PasswordResetTest SMTP-failure scenario override OBP_MAIL_TEST_MODE run_tests_parallel.sh unconditionally exports OBP_MAIL_TEST_MODE=true for every shard, and APIUtil.getPropsValue always checks sys.env before Props/setPropsValues overrides. That made PasswordResetTest's setPropsValues("mail.test.mode" -> "false", ...) a no-op locally, so the "SMTP failure must surface as a 500" scenario always got a 201 sent response instead (CI was unaffected since it only sets a props-file default, no env var). Add EnvVarOverride, a test-only trait that mutates the process environment for the scope of a block and restores it afterward, and use it to force OBP_MAIL_TEST_MODE=false for that one scenario. On modern JDKs the Unix ProcessEnvironment backing map is keyed by internal Variable/Value wrapper types rather than plain Strings, so the override goes through their valueOf(String) factories instead of assuming a Map[String, String]. --- .../code/api/v6_0_0/PasswordResetTest.scala | 23 +++-- .../scala/code/setup/EnvVarOverride.scala | 92 +++++++++++++++++++ run_tests_parallel.sh | 4 + 3 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 obp-api/src/test/scala/code/setup/EnvVarOverride.scala diff --git a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala index 2205bf2748..2cfcd9ebef 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/PasswordResetTest.scala @@ -52,7 +52,7 @@ import org.scalatest.Tag * - Anonymous request: POST /obp/v6.0.0/users/password-reset-url * - Anonymous complete: POST /obp/v6.0.0/users/password */ -class PasswordResetTest extends V600ServerSetup { +class PasswordResetTest extends V600ServerSetup with code.setup.EnvVarOverride { override def beforeEach() = { wipeTestData() @@ -151,14 +151,21 @@ class PasswordResetTest extends V600ServerSetup { "mail.smtp.host" -> "localhost", "mail.smtp.port" -> "1" // reserved port, connection refused immediately ) - When("We make a request v6.0.0") - val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) - val response600 = makePostRequest(request600, write(postJson.copy(user_id = resourceUser.map(_.userId).getOrElse("")))) - Then("We should get a 500 that says the email could not be sent") - withClue(s"Response body: ${response600.body} ") { - response600.code should equal(500) + // run_tests_parallel.sh (local runner) exports OBP_MAIL_TEST_MODE=true for + // every shard so other tests don't open a real SMTP socket. APIUtil.getPropsValue + // checks that env var before the setPropsValues override above, so without this, + // "mail.test.mode" -> "false" is silently ignored locally (CI has no such env var, + // only a props-file default, so it isn't affected either way). + withEnvOverride("OBP_MAIL_TEST_MODE" -> "false") { + When("We make a request v6.0.0") + val request600 = (v6_0_0_Request / "management" / "user" / "reset-password-url").POST <@(user1) + val response600 = makePostRequest(request600, write(postJson.copy(user_id = resourceUser.map(_.userId).getOrElse("")))) + Then("We should get a 500 that says the email could not be sent") + withClue(s"Response body: ${response600.body} ") { + response600.code should equal(500) + } + response600.body.extract[ErrorMessage].message should include("Failed to send password reset email") } - response600.body.extract[ErrorMessage].message should include("Failed to send password reset email") // beforeEach restores mail.test.mode=true for the remaining scenarios } diff --git a/obp-api/src/test/scala/code/setup/EnvVarOverride.scala b/obp-api/src/test/scala/code/setup/EnvVarOverride.scala new file mode 100644 index 0000000000..441e9fd6f4 --- /dev/null +++ b/obp-api/src/test/scala/code/setup/EnvVarOverride.scala @@ -0,0 +1,92 @@ +package code.setup + +import java.util.{Map => JMap} + +/** + * Test-only override for OS-level environment variables. + * + * Why this exists: `APIUtil.getPropsValue` always checks `sys.env` before the + * Props file / `setPropsValues` overrides (env vars are meant to win — e.g. + * `run_tests_parallel.sh` injects `OBP_MAIL_TEST_MODE=true` for every shard so + * that local runs don't open a real SMTP socket, mirroring CI's `mail.test.mode` + * props-file default). A scenario that needs to flip one of those props off + * (e.g. to test the real-SMTP-failure path) cannot do it with `setPropsValues` + * alone when the corresponding `OBP_*` env var is set in the JVM's process + * environment — the env var always wins. This trait mutates the process + * environment for the scope of one block so such a scenario can force the + * env var out of the way, then restores it. + * + * Uses the same reflection trick as `PropsReset`/`PropsProgrammatically` + * (declared-field access); relies on `--add-opens java.base/java.lang=ALL-UNNAMED`, + * already granted to the test JVM by `obp-api/pom.xml`'s `scalatest-maven-plugin` + * argLine. On modern JDKs (Unix `ProcessEnvironment`), the backing map is keyed + * by internal `Variable`/`Value` wrapper types, not plain `String` — this goes + * through their `valueOf(String)` factories rather than assuming a `Map[String,String]`. + */ +trait EnvVarOverride { + + def withEnvOverride[T](keyValues: (String, String)*)(block: => T): T = { + val backend = EnvVarOverride.backend() + val originals = keyValues.map { case (k, _) => k -> Option(System.getenv(k)) } + try { + keyValues.foreach { case (k, v) => backend.put(k, v) } + block + } finally { + originals.foreach { + case (k, Some(v)) => backend.put(k, v) + case (k, None) => backend.remove(k) + } + } + } +} + +object EnvVarOverride { + + private trait Backend { + def put(key: String, value: String): Unit + def remove(key: String): Unit + } + + private def declaredField(cls: Class[_], name: String) = { + val f = cls.getDeclaredField(name) + f.setAccessible(true) + f + } + + private def backend(): Backend = { + try unixBackend() + catch { case _: NoSuchFieldException => windowsBackend() } + } + + // Unix `ProcessEnvironment.theEnvironment` is `HashMap` (byte-array-backed + // wrapper types), not `Map` — must go through their `valueOf(String)` factories. + private def unixBackend(): Backend = { + val processEnvironmentClass = Class.forName("java.lang.ProcessEnvironment") + val theEnvironment = declaredField(processEnvironmentClass, "theEnvironment") + .get(null).asInstanceOf[JMap[AnyRef, AnyRef]] + val variableClass = Class.forName("java.lang.ProcessEnvironment$Variable") + val valueClass = Class.forName("java.lang.ProcessEnvironment$Value") + val variableValueOf = variableClass.getMethod("valueOf", classOf[String]) + variableValueOf.setAccessible(true) + val valueValueOf = valueClass.getMethod("valueOf", classOf[String]) + valueValueOf.setAccessible(true) + new Backend { + def put(key: String, value: String): Unit = + theEnvironment.put(variableValueOf.invoke(null, key), valueValueOf.invoke(null, value)) + def remove(key: String): Unit = + theEnvironment.remove(variableValueOf.invoke(null, key)) + } + } + + // Windows fallback: System.getenv() is a Collections.unmodifiableMap wrapping a + // plain Map[String, String] in a field named "m". + private def windowsBackend(): Backend = { + val env = System.getenv() + val unmodifiableMapClass = Class.forName("java.util.Collections$UnmodifiableMap") + val underlying = declaredField(unmodifiableMapClass, "m").get(env).asInstanceOf[JMap[String, String]] + new Backend { + def put(key: String, value: String): Unit = underlying.put(key, value) + def remove(key: String): Unit = underlying.remove(key) + } + } +} diff --git a/run_tests_parallel.sh b/run_tests_parallel.sh index 82c72d486c..56c8d5107f 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -275,6 +275,10 @@ run_shard() { # mail.test.mode (CI has it); without it, flows like consent actually open an # SMTP socket -> 500 (CI green, local red). We inject OBP_MAIL_TEST_MODE # instead of editing props so we don't clobber the user's local DB settings. + # This env var always outranks a test's setPropsValues("mail.test.mode" -> "false", ...) + # (see APIUtil.getPropsValue precedence above), so PasswordResetTest's real-SMTP-failure + # scenario mutates this specific env var for its own scope via code.setup.EnvVarOverride + # rather than relying on setPropsValues alone. # OBP_DYNAMIC_CODE_SANDBOX_PERMISSIONS mirrors CI's dynamic_code_sandbox_permissions # props line: without it the dynamic-code sandbox denies reflection/getenv and # DynamicResourceDocTest's native-execution scenarios fail locally (CI green, local red). From f9da889ca32c9a4b4784d941d6fadbf9d16b9b76 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 14 Jul 2026 18:22:59 +0200 Subject: [PATCH 3/6] fix(test): make DynamicCodeKillSwitchTest OFF scenarios override the sandbox env var Same root cause as the PasswordResetTest fix: run_tests_parallel.sh exports OBP_ALLOW_USER_GENERATED_SCALA_CODE=true for every shard, and that env var always wins over setPropsValues("allow_user_generated_scala_code" -> "false") per APIUtil.getPropsValue. The five OFF-state scenarios could never actually observe the kill-switch disabled locally, so they failed deterministically under run_tests_parallel.sh (CI was unaffected, since it only sets a props-file default, no env var). Reuses the EnvVarOverride trait added for the PasswordResetTest fix to force the env var out of the way for the scope of each OFF scenario. --- .../v4_0_0/DynamicCodeKillSwitchTest.scala | 104 ++++++++++-------- 1 file changed, 59 insertions(+), 45 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala index 2baa9aa41e..735617e6b8 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DynamicCodeKillSwitchTest.scala @@ -32,7 +32,7 @@ import code.api.util.{ApiRole, DynamicUtil} import code.connectormethod.{ConnectorMethodProvider, JsonConnectorMethod} import code.dynamicResourceDoc.JsonDynamicResourceDoc import code.entitlement.Entitlement -import code.setup.OBPReq +import code.setup.{EnvVarOverride, OBPReq} import com.openbankproject.commons.model.ErrorMessage import com.openbankproject.commons.util.ApiVersion import net.liftweb.common.{Failure, Full} @@ -46,7 +46,7 @@ import org.scalatest.Tag * dynamic resource docs, ABAC rules), plus a regression check that Dynamic Entities * (which never execute user code) are unaffected when the switch is off. */ -class DynamicCodeKillSwitchTest extends V400ServerSetup { +class DynamicCodeKillSwitchTest extends V400ServerSetup with EnvVarOverride { def v6_0_0_Request: OBPReq = baseRequest / "obp" / "v6.0.0" @@ -77,20 +77,28 @@ class DynamicCodeKillSwitchTest extends V400ServerSetup { result should be(Full(42)) } + // run_tests_parallel.sh exports OBP_ALLOW_USER_GENERATED_SCALA_CODE=true for every shard + // (mirroring CI's allow_user_generated_scala_code=true default), and that env var always + // wins over setPropsValues (see APIUtil.getPropsValue). withEnvOverride forces the env var + // out of the way for the scope of this scenario so the "false" prop actually takes effect. scenario("Explicit prop=false disables compilation regardless of run mode", VersionOfApi) { - setPropsValues("allow_user_generated_scala_code" -> "false") + withEnvOverride("OBP_ALLOW_USER_GENERATED_SCALA_CODE" -> "false") { + setPropsValues("allow_user_generated_scala_code" -> "false") - Then("the predicate should be false") - DynamicUtil.dynamicCodeExecutionEnabled should be(false) + Then("the predicate should be false") + DynamicUtil.dynamicCodeExecutionEnabled should be(false) - And("compileScalaCode should refuse to compile/execute and return the kill-switch Failure") - val result = DynamicUtil.compileScalaCode[Int]("41 + 1") - result should be(Failure(DynamicCodeExecutionDisabled)) + And("compileScalaCode should refuse to compile/execute and return the kill-switch Failure") + val result = DynamicUtil.compileScalaCode[Int]("41 + 1") + result should be(Failure(DynamicCodeExecutionDisabled)) + } } scenario("A later explicit prop=true re-enables after being forced off", VersionOfApi) { - setPropsValues("allow_user_generated_scala_code" -> "false") - DynamicUtil.dynamicCodeExecutionEnabled should be(false) + withEnvOverride("OBP_ALLOW_USER_GENERATED_SCALA_CODE" -> "false") { + setPropsValues("allow_user_generated_scala_code" -> "false") + DynamicUtil.dynamicCodeExecutionEnabled should be(false) + } setPropsValues("allow_user_generated_scala_code" -> "true") @@ -102,22 +110,24 @@ class DynamicCodeKillSwitchTest extends V400ServerSetup { feature("Connector Methods endpoint respects the kill-switch") { scenario("OFF: create connector method returns 400 with the kill-switch error, nothing persisted", VersionOfApi) { - setPropsValues("allow_user_generated_scala_code" -> "false") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateConnectorMethod.toString) + withEnvOverride("OBP_ALLOW_USER_GENERATED_SCALA_CODE" -> "false") { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateConnectorMethod.toString) - val countBefore = ConnectorMethodProvider.provider.vend.getAll().size + val countBefore = ConnectorMethodProvider.provider.vend.getAll().size - val request = (v4_0_0_Request / "management" / "connector-methods").POST <@ (user1) - lazy val postConnectorMethod = SwaggerDefinitionsJSON.jsonScalaConnectorMethod + val request = (v4_0_0_Request / "management" / "connector-methods").POST <@ (user1) + lazy val postConnectorMethod = SwaggerDefinitionsJSON.jsonScalaConnectorMethod - val response = makePostRequest(request, write(postConnectorMethod)) + val response = makePostRequest(request, write(postConnectorMethod)) - Then("We should get a 400, not a 500 and not a 201") - response.code should equal(400) - response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + Then("We should get a 400, not a 500 and not a 201") + response.code should equal(400) + response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) - And("nothing should have been persisted") - ConnectorMethodProvider.provider.vend.getAll().size should equal(countBefore) + And("nothing should have been persisted") + ConnectorMethodProvider.provider.vend.getAll().size should equal(countBefore) + } } scenario("ON: create connector method returns 201 and is persisted", VersionOfApi) { @@ -144,17 +154,19 @@ class DynamicCodeKillSwitchTest extends V400ServerSetup { feature("Dynamic Resource Doc endpoint respects the kill-switch") { scenario("OFF: create dynamic resource doc returns 400 with the kill-switch error", VersionOfApi) { - setPropsValues("allow_user_generated_scala_code" -> "false") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) + withEnvOverride("OBP_ALLOW_USER_GENERATED_SCALA_CODE" -> "false") { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.canCreateDynamicResourceDoc.toString) - val request = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) - lazy val postDynamicResourceDoc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(dynamicResourceDocId = None) + val request = (v4_0_0_Request / "management" / "dynamic-resource-docs").POST <@ (user1) + lazy val postDynamicResourceDoc = SwaggerDefinitionsJSON.jsonDynamicResourceDoc.copy(dynamicResourceDocId = None) - val response = makePostRequest(request, write(postDynamicResourceDoc)) + val response = makePostRequest(request, write(postDynamicResourceDoc)) - Then("We should get a 400, not a 500 and not a 201") - response.code should equal(400) - response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + Then("We should get a 400, not a 500 and not a 201") + response.code should equal(400) + response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + } } scenario("ON: create dynamic resource doc returns 201", VersionOfApi) { @@ -176,22 +188,24 @@ class DynamicCodeKillSwitchTest extends V400ServerSetup { feature("ABAC Rule endpoint respects the kill-switch") { scenario("OFF: create ABAC rule returns 400 with the kill-switch error", VersionOfApi) { - setPropsValues("allow_user_generated_scala_code" -> "false") - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) - - val createJson = code.api.v6_0_0.CreateAbacRuleJsonV600( - rule_name = "kill-switch-off-test", - rule_code = "true", - description = "should not compile", - policy = "account-access", - is_active = true - ) - val request = (v6_0_0_Request / "management" / "abac-rules").POST <@ (user1) - val response = makePostRequest(request, write(createJson)) - - Then("We should get a 400, not a 500 and not a 201") - response.code should equal(400) - response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + withEnvOverride("OBP_ALLOW_USER_GENERATED_SCALA_CODE" -> "false") { + setPropsValues("allow_user_generated_scala_code" -> "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) + + val createJson = code.api.v6_0_0.CreateAbacRuleJsonV600( + rule_name = "kill-switch-off-test", + rule_code = "true", + description = "should not compile", + policy = "account-access", + is_active = true + ) + val request = (v6_0_0_Request / "management" / "abac-rules").POST <@ (user1) + val response = makePostRequest(request, write(createJson)) + + Then("We should get a 400, not a 500 and not a 201") + response.code should equal(400) + response.body.extract[ErrorMessage].message should equal(DynamicCodeExecutionDisabled) + } } scenario("ON: create ABAC rule returns 201", VersionOfApi) { From 26c0c424523cbd5f03f00086827d06188b5d1145 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 02:18:12 +0200 Subject: [PATCH 4/6] feat: add UK Open Banking consent authorise endpoint Add POST /obp/v5.1.0/banks/BANK_ID/consents/CONSENT_ID/authorise, a user-scoped (role-free) endpoint that authorises a UK Open Banking account-access consent as the current PSU. UK consents are lodged by the TPP via client_credentials and start in AWAITINGAUTHORISATION with no bound user. After the PSU authenticates, this endpoint transitions the consent to AUTHORISED, binds it to the PSU (mUserId) and stamps the PSU into the consent JWT's createdByUserId, supplying the previously missing authorisation-binding step so that subsequent UK data calls whose token carries the consent_id claim pass checkUKConsent. The consent JWT's permission views are preserved. --- .../scala/code/api/v5_1_0/Http4s510.scala | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index fd9fdb55ea..133802587b 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4262,6 +4262,76 @@ object Http4s510 { http4sPartialFunction = Some(updateConsentUserIdByConsentId) ) + // Authorise a UK Open Banking account-access consent as the current PSU. + // + // UK consents are lodged by the TPP via client_credentials (no user, status + // AWAITINGAUTHORISATION). After the PSU authenticates at the identity provider, + // Portal calls this endpoint with the PSU's own access token to bind the consent + // to that user and flip it to AUTHORISED — the missing "authorisation binding" + // step of the UK flow. It is user-authenticated but role-free (the account holder + // is approving their own consent), mirroring the SCA challenge endpoint. + val authoriseUKConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "consents" / consentId / "authorise" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) + // Only a consent still awaiting authorisation can be authorised. This status + // is UK-specific (Berlin Group uses "received", OBP uses INITIATED), so the + // guard also effectively scopes this endpoint to the UK flow. + _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to be authorised (current: ${consent.status}).", 400, Some(cc)) { + consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString + } + // Bind the PSU as the consent's user in the DB (mUserId). + consentAfterBind <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, user)) + .map(i => connectorEmptyResponse(i, Some(cc))) + // Also stamp the PSU into the consent JWT's createdByUserId, so consent-scoped + // identity resolution (e.g. GET /users/current) reports the authorising user + // rather than the client_credentials pseudo-user the consent was lodged under. + // Despite its name, updateUserIdOfBerlinGroupConsentJWT only rewrites + // createdByUserId (via ConsentJWT.copy) — the UK permission views are preserved. + updatedJwt <- Future(Consent.updateUserIdOfBerlinGroupConsentJWT(user.userId, consentAfterBind, Some(cc))) + .map(i => connectorEmptyResponse(i, Some(cc))) + _ <- Future(Consents.consentProvider.vend.setJsonWebToken(consentId, updatedJwt)) + .map(i => connectorEmptyResponse(i, Some(cc))) + updatedConsent <- Future(Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED)) + .map(i => connectorEmptyResponse(i, Some(cc))) + } yield ConsentJsonV310(updatedConsent.consentId, updatedConsent.jsonWebToken, updatedConsent.status) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(authoriseUKConsent), + "POST", + "/banks/BANK_ID/consents/CONSENT_ID/authorise", + "Authorise UK Consent", + s""" + | + |Authorise a UK Open Banking account-access consent as the current (PSU) user. + | + |The TPP first lodges the intent via `POST /account-access-consents`; the consent is + |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user. After the + |PSU authenticates, this endpoint binds the consent to the PSU and transitions it to + |${ConsentStatus.AUTHORISED}, so subsequent UK data calls whose access token carries the + |`consent_id` claim pass the consent check. + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + ConsentJsonV310( + "9d429899-24f5-42c8-8565-943ffa6a7945", + "eyJhbGciOiJIUzI1NiJ9.eyJ2aWV3cyI6W119.signature", + "AUTHORISED" + ), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, + None, + http4sPartialFunction = Some(authoriseUKConsent) + ) + val getMyConsents: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "my" / "consents" => EndpointHelpers.executeFuture(req) { @@ -5060,6 +5130,7 @@ object Http4s510 { .orElse(updateConsentStatusByConsent(req)) .orElse(updateConsentAccountAccessByConsentId(req)) .orElse(updateConsentUserIdByConsentId(req)) + .orElse(authoriseUKConsent(req)) .orElse(getMyConsents(req)) .orElse(getConsentsAtBank(req)) .orElse(getConsents(req)) From 86166ec24be3929c7a383d427961babec3d39ca2 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 02:18:12 +0200 Subject: [PATCH 5/6] fix: skip deleted accounts in getBankAccounts instead of failing the batch A user can hold a view/grant on an account that has since been deleted (a dangling BankIdAccountId). LocalMappedConnector.getBankAccounts mapped each id through openOrThrowException, so one orphaned grant would 500 an entire "list my accounts" call. Skip accounts that no longer resolve; callers needing a specific account use getBankAccount(single). --- .../scala/code/bankconnectors/LocalMappedConnector.scala | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 12992ceb84..25635f6776 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -977,14 +977,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { override def getBankAccounts(bankIdAccountIds: List[BankIdAccountId], callContext: Option[CallContext]): OBPReturnType[Box[List[BankAccount]]] = { Future { + // Tolerate stale account access: a user can hold a view/grant on an account that has + // since been deleted (a dangling BankIdAccountId). Skip such accounts instead of + // throwing, which would otherwise 500 an entire "list my accounts" call because of one + // orphaned grant. Callers that need a specific account use getBankAccount(single). (Full( - bankIdAccountIds.map( + bankIdAccountIds.flatMap( bankIdAccountId => getBankAccountLegacy( bankIdAccountId.bankId, bankIdAccountId.accountId, callContext - ).map(_._1).openOrThrowException(s"${ErrorMessages.BankAccountNotFound} current BANK_ID(${bankIdAccountId.bankId}) and ACCOUNT_ID(${bankIdAccountId.accountId})")) + ).map(_._1).toList) ), callContext) } } From 69195c001fdd9ed428100dfe4927ba764e392bcb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 15 Jul 2026 09:45:26 +0200 Subject: [PATCH 6/6] feat: add SCA challenge to UK Open Banking consent authorisation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /obp/v5.1.0/banks/BANK_ID/consents/CONSENT_ID/authorise/challenge to start Strong Customer Authentication for a UK consent, and require the challenge answer on the authorise endpoint before flipping the consent to AUTHORISED. Uses the shared OBP challenge engine (createChallengesC2 / validateChallengeAnswerC4) with ChallengeType.OBP_CONSENT_CHALLENGE — the same engine Berlin Group uses, which is status-agnostic and so works on an AWAITINGAUTHORISATION consent (unlike the INITIATED-only checkAnswer path). The OTP is delivered per suggested_default_sca_method (DUMMY answer "123" in dev, EMAIL/SMS in prod). A wrong answer leaves the consent unauthorised. --- .../scala/code/api/v5_1_0/Http4s510.scala | 113 +++++++++++++++--- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 133802587b..1f172a3e95 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -62,7 +62,7 @@ import com.openbankproject.commons.model.{ BranchRoutingJsonV141, CounterpartyId, CustomerId, ListResult, ProductCode, RegulatedEntityId, TransactionRequestId, User, View, ViewId } -import com.openbankproject.commons.model.enums.{AtmAttributeType, ConsentType, RegulatedEntityAttributeType, StrongCustomerAuthentication, TransactionRequestStatus, UserAttributeType} +import com.openbankproject.commons.model.enums.{AtmAttributeType, ChallengeType, ConsentType, RegulatedEntityAttributeType, StrongCustomerAuthentication, StrongCustomerAuthenticationStatus, SuppliedAnswerType, TransactionRequestStatus, UserAttributeType} import com.openbankproject.commons.util.{ApiVersion, ApiVersionStatus, ScannedApiVersion} import net.liftweb.common.{Box, Empty, Full} import com.openbankproject.commons.util.json @@ -84,6 +84,11 @@ import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future import scala.language.{higherKinds, implicitConversions} +// UK Open Banking consent SCA (see authoriseUKConsentChallenge / authoriseUKConsent): +// the challenge-start endpoint returns this; the authorise endpoint consumes the answer. +case class UKConsentScaChallengeJsonV510(challenge_id: String, sca_status: String, sca_method: String) +case class PostUKConsentAuthoriseJsonV510(challenge_id: String, answer: String) + object Http4s510 { type HttpF[A] = OptionT[IO, A] @@ -4262,14 +4267,75 @@ object Http4s510 { http4sPartialFunction = Some(updateConsentUserIdByConsentId) ) - // Authorise a UK Open Banking account-access consent as the current PSU. + // Start SCA for a UK Open Banking consent: issue a one-time challenge (OTP) to the PSU. + // Uses the shared OBP challenge engine (createChallengesC2) — the same one Berlin Group + // uses — with ChallengeType.OBP_CONSENT_CHALLENGE, which is status-agnostic and so works + // on an AWAITINGAUTHORISATION consent. The OTP is delivered per the configured SCA method + // (props suggested_default_sca_method: DUMMY answer "123" for dev, EMAIL/SMS for prod). + val authoriseUKConsentChallenge: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "consents" / consentId / "authorise" / "challenge" => + EndpointHelpers.executeFuture(req) { + implicit val cc: code.api.util.CallContext = req.callContext + val scaMethod = getSuggestedDefaultScaMethod() + for { + user <- Future.successful(cc.user.openOrThrowException(AuthenticatedUserIsRequired)) + consent <- Future(Consents.consentProvider.vend.getConsentByConsentId(consentId)) + .map(unboxFullOrFail(_, Some(cc), s"$ConsentNotFound ($consentId)", 404)) + _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to start SCA (current: ${consent.status}).", 400, Some(cc)) { + consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString + } + (challenges, _) <- NewStyle.function.createChallengesC2( + List(user.userId), + ChallengeType.OBP_CONSENT_CHALLENGE, + None, + scaMethod, + Some(StrongCustomerAuthenticationStatus.received), + Some(consentId), + None, + Some(cc)) + challenge <- NewStyle.function.tryons(s"$InvalidConnectorResponseForCreateChallenge", 400, Some(cc)) { + challenges.head + } + } yield UKConsentScaChallengeJsonV510( + challenge.challengeId, + challenge.scaStatus.map(_.toString).getOrElse(StrongCustomerAuthenticationStatus.received.toString), + scaMethod.map(_.toString).getOrElse("") + ) + } + } + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(authoriseUKConsentChallenge), + "POST", + "/banks/BANK_ID/consents/CONSENT_ID/authorise/challenge", + "Start UK Consent SCA Challenge", + s""" + | + |Start Strong Customer Authentication for a UK Open Banking account-access consent: issue a + |one-time challenge (OTP) to the current (PSU) user, delivered per the configured SCA method. + | + |Call this before `POST /banks/BANK_ID/consents/CONSENT_ID/authorise`, then submit the + |returned `challenge_id` together with the OTP answer to that endpoint to complete authorisation. + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + UKConsentScaChallengeJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "received", "SMS"), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidConnectorResponse, UnknownError), + apiTagConsent :: apiTagPSD2AIS :: Nil, + None, + http4sPartialFunction = Some(authoriseUKConsentChallenge) + ) + + // Authorise a UK Open Banking account-access consent as the current PSU, after SCA. // // UK consents are lodged by the TPP via client_credentials (no user, status - // AWAITINGAUTHORISATION). After the PSU authenticates at the identity provider, - // Portal calls this endpoint with the PSU's own access token to bind the consent - // to that user and flip it to AUTHORISED — the missing "authorisation binding" - // step of the UK flow. It is user-authenticated but role-free (the account holder - // is approving their own consent), mirroring the SCA challenge endpoint. + // AWAITINGAUTHORISATION). After the PSU authenticates and answers the SCA challenge + // (started via .../authorise/challenge), Portal calls this endpoint with the PSU's own + // access token + the challenge answer to verify SCA, bind the consent to that user, and + // flip it to AUTHORISED — the missing "authorisation binding" step of the UK flow. + // User-authenticated but role-free (the account holder is approving their own consent). val authoriseUKConsent: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / _ / "consents" / consentId / "authorise" => EndpointHelpers.executeFuture(req) { @@ -4284,6 +4350,23 @@ object Http4s510 { _ <- Helper.booleanToFuture(s"$ConsentStatusIssue${ConsentStatus.AWAITINGAUTHORISATION.toString} to be authorised (current: ${consent.status}).", 400, Some(cc)) { consent.status.toUpperCase == ConsentStatus.AWAITINGAUTHORISATION.toString } + // Verify the SCA challenge answer before authorising (dynamic linking to this consent). + // The challenge must have been started via POST .../authorise/challenge. + authJson <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the $PostUKConsentAuthoriseJsonV510 ", 400, Some(cc)) { + com.openbankproject.commons.util.JsonAliases.parse(cc.httpBody.getOrElse("")).extract[PostUKConsentAuthoriseJsonV510] + } + (_, _) <- NewStyle.function.getChallenge(authJson.challenge_id, Some(cc)) + (challenge, _) <- NewStyle.function.validateChallengeAnswerC4( + ChallengeType.OBP_CONSENT_CHALLENGE, + None, + Some(consentId), + authJson.challenge_id, + authJson.answer, + SuppliedAnswerType.PLAIN_TEXT_VALUE, + Some(cc)) + _ <- Helper.booleanToFuture(s"$InvalidChallengeAnswer", 403, Some(cc)) { + challenge.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised) + } // Bind the PSU as the consent's user in the DB (mUserId). consentAfterBind <- Future(Consents.consentProvider.vend.updateConsentUser(consentId, user)) .map(i => connectorEmptyResponse(i, Some(cc))) @@ -4309,24 +4392,25 @@ object Http4s510 { "Authorise UK Consent", s""" | - |Authorise a UK Open Banking account-access consent as the current (PSU) user. + |Authorise a UK Open Banking account-access consent as the current (PSU) user, after SCA. | |The TPP first lodges the intent via `POST /account-access-consents`; the consent is - |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user. After the - |PSU authenticates, this endpoint binds the consent to the PSU and transitions it to - |${ConsentStatus.AUTHORISED}, so subsequent UK data calls whose access token carries the - |`consent_id` claim pass the consent check. + |created in ${ConsentStatus.AWAITINGAUTHORISATION} state with no bound user. The PSU then + |starts SCA via `POST .../authorise/challenge` and submits the resulting `challenge_id` + |plus the OTP `answer` here. On a valid answer this binds the consent to the PSU and + |transitions it to ${ConsentStatus.AUTHORISED}, so subsequent UK data calls whose access + |token carries the `consent_id` claim pass the consent check. | |${userAuthenticationMessage(true)} | |""", - EmptyBody, + PostUKConsentAuthoriseJsonV510("74a8ebda-9e5a-4c3f-9b0b-1a2b3c4d5e6f", "123"), ConsentJsonV310( "9d429899-24f5-42c8-8565-943ffa6a7945", "eyJhbGciOiJIUzI1NiJ9.eyJ2aWV3cyI6W119.signature", "AUTHORISED" ), - List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidConnectorResponse, UnknownError), + List($AuthenticatedUserIsRequired, ConsentNotFound, ConsentStatusIssue, InvalidJsonFormat, InvalidChallengeAnswer, InvalidConnectorResponse, UnknownError), apiTagConsent :: apiTagPSD2AIS :: Nil, None, http4sPartialFunction = Some(authoriseUKConsent) @@ -5130,6 +5214,7 @@ object Http4s510 { .orElse(updateConsentStatusByConsent(req)) .orElse(updateConsentAccountAccessByConsentId(req)) .orElse(updateConsentUserIdByConsentId(req)) + .orElse(authoriseUKConsentChallenge(req)) .orElse(authoriseUKConsent(req)) .orElse(getMyConsents(req)) .orElse(getConsentsAtBank(req))