diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 2500954cbf..442e67b4b3 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -282,6 +282,12 @@ object ApiRole extends MdcLoggable{ case class CanGetCurrentConsumer(requiresBankId: Boolean = false) extends ApiRole lazy val canGetCurrentConsumer = CanGetCurrentConsumer() + case class CanVerifyOidcClient(requiresBankId: Boolean = false) extends ApiRole + lazy val canVerifyOidcClient = CanVerifyOidcClient() + + case class CanGetOidcClient(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetOidcClient = CanGetOidcClient() + case class CanCreateTransactionType(requiresBankId: Boolean = true) extends ApiRole lazy val canCreateTransactionType = CanCreateTransactionType() diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 7b88948f37..35f98d6968 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -23,20 +23,22 @@ import code.api.v3_0_0.JSONFactory300 import code.api.v3_0_0.JSONFactory300.createAggregateMetricJson import code.api.v2_0_0.JSONFactory200 import code.api.v3_1_0.{JSONFactory310, PostCustomerNumberJsonV310} -import code.api.v4_0_0.CallLimitPostJsonV400 +import code.api.v1_2_1.{AccountHolderJSON, BankRoutingJsonV121, TransactionDetailsJSON} +import code.api.v4_0_0.{BankAttributeBankResponseJsonV400, CallLimitPostJsonV400} import code.api.v4_0_0.JSONFactory400.createCallsLimitJson import code.api.v5_0_0.JSONFactory500 import code.api.v5_0_0.{ViewJsonV500, ViewsJsonV500} import code.api.v5_1_0.{JSONFactory510, PostCustomerLegalNameJsonV510} import code.api.dynamic.entity.helper.{DynamicEntityHelper, DynamicEntityInfo} import code.api.v6_0_0.JSONFactory600.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson} -import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, DynamicEntityLinksJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, PostVerifyUserCredentialsJsonV600, RedisCacheStatusJsonV600, RelatedLinkJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CreateDynamicEntityRequestJsonV600, CurrentConsumerJsonV600, DynamicEntityDefinitionJsonV600, DynamicEntityDefinitionWithCountJsonV600, DynamicEntitiesWithCountJsonV600, DynamicEntityLinksJsonV600, ExecuteAbacRuleJsonV600, GetOidcClientResponseJsonV600, InMemoryCacheStatusJsonV600, MyDynamicEntitiesJsonV600, PostVerifyUserCredentialsJsonV600, RedisCacheStatusJsonV600, RelatedLinkJsonV600, UpdateAbacRuleJsonV600, UpdateDynamicEntityRequestJsonV600, VerifyOidcClientRequestJsonV600, VerifyOidcClientResponseJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics import code.bankconnectors.{Connector, LocalMappedConnectorInternal} import code.bankconnectors.storedprocedure.StoredProcedureUtils import code.bankconnectors.LocalMappedConnectorInternal._ +import code.consumer.Consumers import code.entitlement.Entitlement import code.loginattempts.LoginAttempt import code.model._ @@ -917,6 +919,174 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + getBanks, + implementedInApiVersion, + nameOf(getBanks), + "GET", + "/banks", + "Get Banks", + """Get banks on this API instance + |Returns a list of banks supported on this server: + | + |- bank_id used as parameter in URLs + |- Short and full name of bank + |- Logo URL + |- Website + | + |User Authentication is Optional. The User need not be logged in. + |""", + EmptyBody, + BanksJsonV600(List(BankJsonV600( + bank_id = "gh.29.uk", + bank_code = "bank_code", + full_name = "full_name", + logo = "logo", + website = "www.openbankproject.com", + bank_routings = List(BankRoutingJsonV121("OBP", "gh.29.uk")), + attributes = Some(List(BankAttributeBankResponseJsonV400("OVERDRAFT_LIMIT", "1000"))) + ))), + List(UnknownError), + apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil + ) + + lazy val getBanks: OBPEndpoint = { + case "banks" :: Nil JsonGet _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (banks, callContext) <- NewStyle.function.getBanks(cc.callContext) + } yield { + (JSONFactory600.createBanksJsonV600(banks), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getBank, + implementedInApiVersion, + nameOf(getBank), + "GET", + "/banks/BANK_ID", + "Get Bank", + """Get the bank specified by BANK_ID + |Returns information about a single bank specified by BANK_ID including: + | + |- bank_id: The unique identifier of this bank + |- Short and full name of bank + |- Logo URL + |- Website + |""", + EmptyBody, + BankJsonV600( + bank_id = "gh.29.uk", + bank_code = "bank_code", + full_name = "full_name", + logo = "logo", + website = "www.openbankproject.com", + bank_routings = List(BankRoutingJsonV121("OBP", "gh.29.uk")), + attributes = Some(List(BankAttributeBankResponseJsonV400("OVERDRAFT_LIMIT", "1000"))) + ), + List(UnknownError, BankNotFound), + apiTagBank :: apiTagPSD2AIS :: apiTagPsd2 :: Nil + ) + + lazy val getBank: OBPEndpoint = { + case "banks" :: BankId(bankId) :: Nil JsonGet _ => { cc => + implicit val ec = EndpointContext(Some(cc)) + for { + (bank, callContext) <- NewStyle.function.getBank(bankId, cc.callContext) + (attributes, callContext) <- NewStyle.function.getBankAttributesByBank(bankId, callContext) + } yield { + (JSONFactory600.createBankJsonV600(bank, attributes), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getTransactionsForBankAccount, + implementedInApiVersion, + nameOf(getTransactionsForBankAccount), + "GET", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transactions", + "Get Transactions for Account (Full)", + s"""Returns transactions list of the account specified by ACCOUNT_ID and [moderated](#1_2_1-getViewsForBankAccount) by the view (VIEW_ID). + | + |${userAuthenticationMessage(false)} + | + |Authentication is required if the view is not public. + | + |${urlParametersDocument(true, true)} + | + |**Note:** This v6.0.0 endpoint returns `bank_id` directly in both `this_account` and `other_account` objects, + |making it easier to identify which bank each account belongs to without parsing the `bank_routing` object. + | + |""", + EmptyBody, + TransactionsJsonV600(List(TransactionJsonV600( + transaction_id = "123", + this_account = ThisAccountJsonV600( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", + bank_routing = BankRoutingJsonV121("OBP", "gh.29.uk"), + account_routings = List(AccountRoutingJsonV121("OBP", "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0")), + holders = List(AccountHolderJSON("John Doe", false)) + ), + other_account = OtherAccountJsonV600( + bank_id = "other.bank.uk", + account_id = "counterparty-123", + holder = AccountHolderJSON("Jane Smith", false), + bank_routing = BankRoutingJsonV121("OBP", "other.bank.uk"), + account_routings = List(AccountRoutingJsonV121("OBP", "counterparty-123")), + metadata = null + ), + details = TransactionDetailsJSON( + `type` = "SEPA", + description = "Payment for services", + posted = new java.util.Date(), + completed = new java.util.Date(), + new_balance = AmountOfMoneyJsonV121("EUR", "1000.00"), + value = AmountOfMoneyJsonV121("EUR", "100.00") + ), + metadata = null, + transaction_attributes = Nil + ))), + List( + FilterSortDirectionError, + FilterOffersetError, + FilterLimitError, + FilterDateFormatError, + AuthenticatedUserIsRequired, + BankAccountNotFound, + ViewNotFound, + UnknownError + ), + List(apiTagTransaction, apiTagAccount) + ) + + lazy val getTransactionsForBankAccount: OBPEndpoint = { + case "banks" :: BankId(bankId) :: "accounts" :: AccountId(accountId) :: ViewId(viewId) :: "transactions" :: Nil JsonGet req => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (user, callContext) <- authenticatedAccess(cc) + (bank, callContext) <- NewStyle.function.getBank(bankId, callContext) + (bankAccount, callContext) <- NewStyle.function.checkBankAccountExists(bankId, accountId, callContext) + view <- ViewNewStyle.checkViewAccessAndReturnView(viewId, BankIdAccountId(bankAccount.bankId, bankAccount.accountId), user, callContext) + (params, callContext) <- createQueriesByHttpParamsFuture(callContext.get.requestHeaders, callContext) + (transactions, callContext) <- bankAccount.getModeratedTransactionsFuture(bank, user, view, callContext, params) map { + connectorEmptyResponse(_, callContext) + } + moderatedTransactionsWithAttributes <- Future.sequence(transactions.map(transaction => + NewStyle.function.getTransactionAttributes( + bankId, + transaction.id, + cc.callContext: Option[CallContext]).map(attributes => code.api.v3_0_0.ModeratedTransactionWithAttributes(transaction, attributes._1)) + )) + } yield { + (JSONFactory600.createTransactionsJsonV600(moderatedTransactionsWithAttributes), HttpCode.`200`(callContext)) + } + } + } + lazy val getCurrentConsumer: OBPEndpoint = { case "consumers" :: "current" :: Nil JsonGet _ => { cc => { @@ -1644,8 +1814,9 @@ trait APIMethods600 { json.extract[PostBankJson600] } + // TODO: Improve this error message to not hardcode "16" - should reference the max length from checkOptionalShortString function checkShortStringValue = APIUtil.checkOptionalShortString(postJson.bank_id) - _ <- Helper.booleanToFuture(failMsg = s"$checkShortStringValue.", cc = cc.callContext) { + _ <- Helper.booleanToFuture(failMsg = s"$InvalidJsonFormat BANK_ID: $checkShortStringValue BANK_ID must contain only characters A-Z, a-z, 0-9, -, _, . and be max 16 characters.", cc = cc.callContext) { checkShortStringValue == SILENCE_IS_GOLDEN } @@ -7221,6 +7392,135 @@ trait APIMethods600 { } } + staticResourceDocs += ResourceDoc( + verifyOidcClient, + implementedInApiVersion, + nameOf(verifyOidcClient), + "POST", + "/oidc/clients/verify", + "Verify OIDC Client", + s"""Verifies an OIDC/OAuth2 client's credentials. + | + |Returns `valid: true` if the client_id and client_secret match an active consumer. + |Also returns the consumer_id and redirect_uris for use by the OIDC provider. + | + |${userAuthenticationMessage(true)} + |""", + VerifyOidcClientRequestJsonV600( + client_id = "abc123def456", + client_secret = "supersecret123" + ), + VerifyOidcClientResponseJsonV600( + valid = true, + client_id = Some("abc123def456"), + consumer_id = Some("7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh"), + redirect_uris = Some(List("https://app.example.com/callback")) + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagOIDC, apiTagConsumer, apiTagOAuth), + Some(List(canVerifyOidcClient)) + ) + + lazy val verifyOidcClient: OBPEndpoint = { + case "oidc" :: "clients" :: "verify" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) + else NewStyle.function.hasEntitlement("", u.userId, canVerifyOidcClient, callContext) + postedData <- NewStyle.function.tryons(s"$InvalidJsonFormat The Json body should be the VerifyOidcClientRequestJsonV600", 400, callContext) { + json.extract[VerifyOidcClientRequestJsonV600] + } + consumerBox <- Future { + Consumers.consumers.vend.getConsumerByConsumerKey(postedData.client_id) + } + } yield { + consumerBox match { + case Full(consumer) if consumer.isActive.get && consumer.secret.get == postedData.client_secret => + val redirectUris = Option(consumer.redirectURL.get) + .filter(_.nonEmpty) + .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList) + (VerifyOidcClientResponseJsonV600( + valid = true, + client_id = Some(postedData.client_id), + consumer_id = Some(consumer.consumerId.get), + redirect_uris = redirectUris + ), HttpCode.`200`(callContext)) + case _ => + (VerifyOidcClientResponseJsonV600(valid = false), HttpCode.`200`(callContext)) + } + } + } + } + + staticResourceDocs += ResourceDoc( + getOidcClient, + implementedInApiVersion, + nameOf(getOidcClient), + "GET", + "/oidc/clients/CLIENT_ID", + "Get OIDC Client", + s"""Gets an OIDC/OAuth2 client's metadata by client_id. + | + |Returns client information including name, consumer_id, redirect_uris, and enabled status. + |This endpoint does not verify the client secret - use POST /oidc/clients/verify for authentication. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, + GetOidcClientResponseJsonV600( + client_id = "abc123def456", + client_name = "My Application", + consumer_id = "7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", + redirect_uris = List("https://app.example.com/callback"), + enabled = true + ), + List( + $AuthenticatedUserIsRequired, + UserHasMissingRoles, + UnknownError + ), + List(apiTagOIDC, apiTagConsumer, apiTagOAuth), + Some(List(canGetOidcClient)) + ) + + lazy val getOidcClient: OBPEndpoint = { + case "oidc" :: "clients" :: clientId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(u), callContext) <- authenticatedAccess(cc) + _ <- if(isSuperAdmin(u.userId)) Future.successful(Full(Unit)) + else NewStyle.function.hasEntitlement("", u.userId, canGetOidcClient, callContext) + consumerBox <- Future { + Consumers.consumers.vend.getConsumerByConsumerKey(clientId) + } + consumer <- NewStyle.function.tryons(s"OBP-OIDC-003: Client not found: $clientId", 404, callContext) { + consumerBox match { + case Full(c) => c + case _ => throw new RuntimeException("Client not found") + } + } + } yield { + val redirectUris = Option(consumer.redirectURL.get) + .filter(_.nonEmpty) + .map(_.split("[,\\s]+").map(_.trim).filter(_.nonEmpty).toList) + .getOrElse(List.empty) + (GetOidcClientResponseJsonV600( + client_id = clientId, + client_name = consumer.name.get, + consumer_id = consumer.consumerId.get, + redirect_uris = redirectUris, + enabled = consumer.isActive.get + ), HttpCode.`200`(callContext)) + } + } + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala index 015873293e..8b721a83f9 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala @@ -16,17 +16,19 @@ package code.api.v6_0_0 import code.api.util.APIUtil.stringOrNull import code.api.util.RateLimitingPeriod.LimitCallPeriod import code.api.util._ -import code.api.v1_2_1.BankRoutingJsonV121 +import code.api.v1_2_1.{AccountHolderJSON, BankRoutingJsonV121, OtherAccountMetadataJSON, TransactionDetailsJSON, TransactionMetadataJSON} import code.api.v1_4_0.JSONFactory1_4_0.CustomerFaceImageJson import code.api.v2_0_0.{EntitlementJSONs, JSONFactory200} import code.api.v2_1_0.CustomerCreditRatingJSON import code.api.v3_0_0.{ CustomerAttributeResponseJsonV300, + ModeratedTransactionWithAttributes, UserJsonV300, ViewJSON300, ViewsJSON300 } -import code.api.v3_1_0.{RateLimit, RedisCallLimitJson} +import code.api.v3_1_0.{AccountAttributeResponseJson, RateLimit, RedisCallLimitJson} +import code.api.v4_0_0.TransactionAttributeResponseJson import code.api.v4_0_0.{BankAttributeBankResponseJsonV400, UserAgreementJson} import code.entitlement.Entitlement import code.loginattempts.LoginAttempt @@ -76,6 +78,28 @@ case class CurrentConsumerJsonV600( call_counters: RedisCallCountersJsonV600 ) +// OIDC Client Verification models (V600) +case class VerifyOidcClientRequestJsonV600( + client_id: String, + client_secret: String +) + +case class VerifyOidcClientResponseJsonV600( + valid: Boolean, + client_id: Option[String] = None, + consumer_id: Option[String] = None, + redirect_uris: Option[List[String]] = None +) + +// OIDC Client Get (metadata lookup without secret verification) +case class GetOidcClientResponseJsonV600( + client_id: String, + client_name: String, + consumer_id: String, + redirect_uris: List[String], + enabled: Boolean +) + case class CallLimitPostJsonV600( from_date: java.util.Date, to_date: java.util.Date, @@ -334,6 +358,18 @@ case class StoredProcedureConnectorHealthJsonV600( error_message: Option[String] ) +case class BankJsonV600( + bank_id: String, + bank_code: String, + full_name: String, + logo: String, + website: String, + bank_routings: List[BankRoutingJsonV121], + attributes: Option[List[BankAttributeBankResponseJsonV400]] +) + +case class BanksJsonV600(banks: List[BankJsonV600]) + case class PostCustomerJsonV600( legal_name: String, customer_number: Option[String] = None, @@ -515,6 +551,37 @@ case class AbacPoliciesJsonV600( policies: List[AbacPolicyJsonV600] ) +// Transaction JSON structures for v6.0.0 - with bank_id included directly +case class ThisAccountJsonV600( + bank_id: String, + account_id: String, + bank_routing: BankRoutingJsonV121, + account_routings: List[AccountRoutingJsonV121], + holders: List[AccountHolderJSON] +) + +case class OtherAccountJsonV600( + bank_id: String, + account_id: String, + holder: AccountHolderJSON, + bank_routing: BankRoutingJsonV121, + account_routings: List[AccountRoutingJsonV121], + metadata: OtherAccountMetadataJSON +) + +case class TransactionJsonV600( + transaction_id: String, + this_account: ThisAccountJsonV600, + other_account: OtherAccountJsonV600, + details: TransactionDetailsJSON, + metadata: TransactionMetadataJSON, + transaction_attributes: List[TransactionAttributeResponseJson] +) + +case class TransactionsJsonV600( + transactions: List[TransactionJsonV600] +) + // HATEOAS-style links for dynamic entity discoverability case class RelatedLinkJsonV600(rel: String, href: String, method: String) case class DynamicEntityLinksJsonV600( @@ -1397,6 +1464,34 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + def createBankJsonV600(bank: Bank, attributes: List[BankAttributeTrait] = Nil): BankJsonV600 = { + val obp = BankRoutingJsonV121("OBP", bank.bankId.value) + val bic = BankRoutingJsonV121("BIC", bank.swiftBic) + val routings = bank.bankRoutingScheme match { + case "OBP" => bic :: BankRoutingJsonV121(bank.bankRoutingScheme, bank.bankRoutingAddress) :: Nil + case "BIC" => obp :: BankRoutingJsonV121(bank.bankRoutingScheme, bank.bankRoutingAddress) :: Nil + case _ => obp :: bic :: BankRoutingJsonV121(bank.bankRoutingScheme, bank.bankRoutingAddress) :: Nil + } + BankJsonV600( + bank_id = stringOrNull(bank.bankId.value), + bank_code = stringOrNull(bank.shortName), + full_name = stringOrNull(bank.fullName), + logo = stringOrNull(bank.logoUrl), + website = stringOrNull(bank.websiteUrl), + bank_routings = routings.filter(a => stringOrNull(a.address) != null), + attributes = Option( + attributes.filter(_.isActive == Some(true)).map(a => BankAttributeBankResponseJsonV400( + name = a.name, + value = a.value) + ) + ) + ) + } + + def createBanksJsonV600(banks: List[Bank]): BanksJsonV600 = { + BanksJsonV600(banks.map(bank => createBankJsonV600(bank, Nil))) + } + /** * Create v6.0.0 response for GET /my/dynamic-entities * @@ -1557,4 +1652,75 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable { ) } + // Transaction v6.0.0 factory methods + + import code.api.util.APIUtil.stringOptionOrNull + import code.api.v1_2_1.JSONFactory.{createAmountOfMoneyJSON, createTransactionCommentJSON, createTransactionTagJSON, createTransactionImageJSON, createLocationJSON, createAccountHolderJSON} + import code.api.v3_0_0.JSONFactory300.createOtherAccountMetaDataJSON + import code.api.v4_0_0.JSONFactory400.createTransactionAttributeJson + import code.model.{ModeratedBankAccount, ModeratedOtherBankAccount, ModeratedTransaction, ModeratedTransactionMetadata} + + def createTransactionsJsonV600(moderatedTransactionsWithAttributes: List[ModeratedTransactionWithAttributes]): TransactionsJsonV600 = { + TransactionsJsonV600(moderatedTransactionsWithAttributes.map(t => createTransactionJsonV600(t.transaction, t.transactionAttributes))) + } + + def createTransactionJsonV600(transaction: ModeratedTransaction, transactionAttributes: List[TransactionAttribute]): TransactionJsonV600 = { + TransactionJsonV600( + transaction_id = transaction.id.value, + this_account = transaction.bankAccount.map(createThisAccountJsonV600).getOrElse(null), + other_account = transaction.otherBankAccount.map(createOtherAccountJsonV600).getOrElse(null), + details = createTransactionDetailsJsonV600(transaction), + metadata = transaction.metadata.map(createTransactionMetadataJsonV600).getOrElse(null), + transaction_attributes = transactionAttributes.map(createTransactionAttributeJson) + ) + } + + def createThisAccountJsonV600(bankAccount: ModeratedBankAccount): ThisAccountJsonV600 = { + ThisAccountJsonV600( + bank_id = bankAccount.bankId.value, + account_id = bankAccount.accountId.value, + bank_routing = BankRoutingJsonV121(stringOptionOrNull(bankAccount.bankRoutingScheme), stringOptionOrNull(bankAccount.bankRoutingAddress)), + account_routings = List(AccountRoutingJsonV121(stringOptionOrNull(bankAccount.accountRoutingScheme), stringOptionOrNull(bankAccount.accountRoutingAddress))), + holders = bankAccount.owners.map(x => x.toList.map(holder => AccountHolderJSON(name = holder.name, is_alias = false))).getOrElse(null) + ) + } + + def createOtherAccountJsonV600(bankAccount: ModeratedOtherBankAccount): OtherAccountJsonV600 = { + // Extract bank_id from bank_routing when scheme is "OBP", otherwise use the address as best effort + val bankId = bankAccount.bankRoutingScheme match { + case Some("OBP") => stringOptionOrNull(bankAccount.bankRoutingAddress) + case _ => stringOptionOrNull(bankAccount.bankRoutingAddress) // Best effort - use address + } + + OtherAccountJsonV600( + bank_id = bankId, + account_id = bankAccount.id, + holder = createAccountHolderJSON(bankAccount.label.display, bankAccount.isAlias), + bank_routing = BankRoutingJsonV121(stringOptionOrNull(bankAccount.bankRoutingScheme), stringOptionOrNull(bankAccount.bankRoutingAddress)), + account_routings = List(AccountRoutingJsonV121(stringOptionOrNull(bankAccount.accountRoutingScheme), stringOptionOrNull(bankAccount.accountRoutingAddress))), + metadata = bankAccount.metadata.map(createOtherAccountMetaDataJSON).getOrElse(null) + ) + } + + def createTransactionDetailsJsonV600(transaction: ModeratedTransaction): TransactionDetailsJSON = { + TransactionDetailsJSON( + `type` = stringOptionOrNull(transaction.transactionType), + description = stringOptionOrNull(transaction.description), + posted = transaction.startDate.getOrElse(null), + completed = transaction.finishDate.getOrElse(null), + new_balance = createAmountOfMoneyJSON(transaction.currency, transaction.balance), + value = createAmountOfMoneyJSON(transaction.currency, transaction.amount.map(_.toString)) + ) + } + + def createTransactionMetadataJsonV600(metadata: ModeratedTransactionMetadata): TransactionMetadataJSON = { + TransactionMetadataJSON( + narrative = stringOptionOrNull(metadata.ownerComment), + comments = metadata.comments.map(_.map(createTransactionCommentJSON)).getOrElse(null), + tags = metadata.tags.map(_.map(createTransactionTagJSON)).getOrElse(null), + images = metadata.images.map(_.map(createTransactionImageJSON)).getOrElse(null), + where = metadata.whereTag.map(createLocationJSON).getOrElse(null) + ) + } + } diff --git a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index de19890654..5b5ef21cc4 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -207,16 +207,30 @@ class AuthUser extends MegaProtoUser[AuthUser] with CreatedUpdated with MdcLogga */ def valUniqueExternally(msg: => String)(uniqueUsername: String): List[FieldError] ={ if (APIUtil.getPropsAsBoolValue("connector.user.authentication", false)) { - Connector.connector.vend.checkExternalUserExists(uniqueUsername, None).map(_.sub) match { + logger.info(s"valUniqueExternally: calling checkExternalUserExists for username: $uniqueUsername") + val connectorResult = Connector.connector.vend.checkExternalUserExists(uniqueUsername, None) + logger.info(s"valUniqueExternally: checkExternalUserExists returned: ${connectorResult.getClass.getSimpleName}") + connectorResult.map(_.sub) match { case Full(returnedUsername) => // Get the username via connector + logger.info(s"valUniqueExternally: checkExternalUserExists returned username: $returnedUsername") if(uniqueUsername == returnedUsername) { // Username is NOT unique + logger.info(s"valUniqueExternally: username $uniqueUsername already exists externally") List(FieldError(this, Text(msg))) // provide the error message - } else { + } else { + logger.info(s"valUniqueExternally: username $uniqueUsername is unique (returned different: $returnedUsername)") Nil // All good. Allow username creation } case ParamFailure(message,_,_,APIFailure(errorMessage, errorCode)) if errorMessage.contains("NO DATA") => // Cannot get the username via connector + logger.info(s"valUniqueExternally: checkExternalUserExists returned NO DATA for username: $uniqueUsername - allowing creation") Nil // All good. Allow username creation + case Failure(failureMsg, exception, chain) => + logger.warn(s"valUniqueExternally: checkExternalUserExists failed for username: $uniqueUsername, message: $failureMsg, exception: ${exception.map(_.getMessage)}, chain: $chain") + List(FieldError(this, Text(msg))) + case Empty => + logger.warn(s"valUniqueExternally: checkExternalUserExists returned Empty for username: $uniqueUsername") + List(FieldError(this, Text(msg))) case _ => // Any other case we provide error message + logger.warn(s"valUniqueExternally: checkExternalUserExists returned unexpected result for username: $uniqueUsername") List(FieldError(this, Text(msg))) } } else { @@ -932,8 +946,12 @@ import net.liftweb.util.Helpers._ * @return Return the authUser */ def checkExternalUserViaConnector(username: String, password: String):Box[AuthUser] = { - Connector.connector.vend.checkExternalUserCredentials(username, password, None) match { + logger.info(s"checkExternalUserViaConnector: calling checkExternalUserCredentials for username: $username") + val connectorResult = Connector.connector.vend.checkExternalUserCredentials(username, password, None) + logger.info(s"checkExternalUserViaConnector: checkExternalUserCredentials returned: ${connectorResult.getClass.getSimpleName}") + connectorResult match { case Full(InboundExternalUser(aud, exp, iat, iss, sub, azp, email, emailVerified, name, userAuthContexts)) => + logger.info(s"checkExternalUserViaConnector: successful response for sub: $sub, iss: $iss, email: $email") val user = findAuthUserByUsernameAndProvider(sub, iss) match { // Check if the external user is already created locally case Full(user) if user.validated_? => // Return existing user if found logger.debug("external user already exists locally, using that one") @@ -969,7 +987,14 @@ import net.liftweb.util.Helpers._ case None => // Do nothing } Full(user) + case Failure(msg, exception, chain) => + logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials failed for username: $username, message: $msg, exception: ${exception.map(_.getMessage)}, chain: $chain") + Empty + case Empty => + logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials returned Empty for username: $username") + Empty case _ => + logger.warn(s"checkExternalUserViaConnector: checkExternalUserCredentials returned unexpected result for username: $username") Empty } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala b/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala index 80b5aeaab9..6e54fab1a0 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/BankTests.scala @@ -6,11 +6,13 @@ import code.api.util.ApiRole.CanCreateBank import code.api.util.ErrorMessages import code.api.util.ErrorMessages.UserHasMissingRoles import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement import code.setup.DefaultUsers import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage import com.openbankproject.commons.util.ApiVersion import net.liftweb.json.Serialization.write +import net.liftweb.util.Helpers.randomString import org.scalatest.Tag class BankTests extends V600ServerSetup with DefaultUsers { @@ -54,6 +56,70 @@ class BankTests extends V600ServerSetup with DefaultUsers { response.code should equal(403) response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanCreateBank) } + + scenario("Successfully create a bank with a 16-character bank_id (max length)", ApiEndpoint1, VersionOfApi) { + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateBank.toString) + + // Generate a 16-character bank_id (maximum allowed by checkOptionalShortString validation) + val longBankId = "bank." + randomString(11).toLowerCase // 5 + 11 = 16 characters + + When("We create a bank with a 16-character bank_id") + val postJson = PostBankJson600( + bank_id = longBankId, + bank_code = "test_code", + full_name = Some("Test Bank with Long ID"), + logo = Some("https://example.com/logo.png"), + website = Some("https://example.com"), + bank_routings = None + ) + val request = (v6_0_0_Request / "banks").POST <@ (user1) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + Then("We should get a 201") + response.code should equal(201) + + And("The response should contain the bank with the 16-character bank_id") + val responseJson = response.body + (responseJson \ "bank_id").extract[String] should equal(longBankId) + (responseJson \ "bank_id").extract[String].length should equal(16) + } + + scenario("Fail to create a bank with bank_id exceeding 16 characters", ApiEndpoint1, VersionOfApi) { + // Add the required entitlement + val addedEntitlement = Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateBank.toString) + + // Generate a 17-character bank_id (exceeds maximum of 16) + val tooLongBankId = "bank." + randomString(12).toLowerCase // 5 + 12 = 17 characters + + When("We try to create a bank with a 17-character bank_id") + val postJson = PostBankJson600( + bank_id = tooLongBankId, + bank_code = "test_code", + full_name = Some("Test Bank with Too Long ID"), + logo = Some("https://example.com/logo.png"), + website = Some("https://example.com"), + bank_routings = None + ) + val request = (v6_0_0_Request / "banks").POST <@ (user1) + val response = try { + makePostRequest(request, write(postJson)) + } finally { + // Clean up entitlement + Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement) + } + + Then("We should get a 400") + response.code should equal(400) + + And("The error message should indicate BANK_ID validation failed") + response.body.extract[ErrorMessage].message should include("BANK_ID") + } } } \ No newline at end of file