From b648f7dd9f3442c131cb4b1ea2a697e58a2506c1 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 14 Jul 2026 13:02:36 +0200 Subject: [PATCH] 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)