diff --git a/CLAUDE.md b/CLAUDE.md index 6ebe0882cb..8e3da6a443 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,7 @@ The goal is a full http4s migration — replace Lift Web across all version file **Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). -**v7.0.0 native endpoints** (46 ResourceDocs): root, corePrivateAccountsAllBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestUtility, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. +**v7.0.0 native endpoints** (48 ResourceDocs): root, corePrivateAccountsAllBanks, createMyBank, getMyBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestUtility, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. **Tests**: `Http4s700RoutesTest` (91 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. ## Migrating a Lift Endpoint to http4s diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index c5ac75d4c4..9375673cc0 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -459,6 +459,13 @@ allow_sandbox_data_import=true # Secret key that allows access to the "data import" api. You should change this to your own secret key sandbox_data_import_secret=change_me +## Self-service bank creation (POST /my/banks): the maximum number of banks each registered +## User may create without holding the role CanCreateBank. The bank identity (bank_id, short +## name, full name) is auto-generated by the server — the caller cannot choose it. +## 0 disables the endpoint (default). Users holding CanCreateBank are unaffected by this +## limit — they use POST /banks as usual. +# self_service_bank_creation.limit=0 + ### API features diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 470ff8c66c..926c5c3b52 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -153,6 +153,37 @@ case class CallContext( // for endpoint body convenient get userId def userId: String = user.map(_.userId).openOrThrowException(AuthenticatedUserIsRequired) + + /** + * The human User this request is really about. + * + * The authenticated `user` may be the human themselves, or an agent user minted by a + * Consent the human granted (e.g. Opey / MCP acting under a consent). Resolution order: + * 1. `onBehalfOfUser` or `consenter`, when a middleware populated them (free); + * 2. otherwise resolve via the delegation registry: the caller's ResourceUser row's + * CreatedByConsentId names the Consent that minted it, and that Consent's userId + * names the granting human; + * 3. otherwise the caller IS the human. + * + * IMPORTANT: this reads only the authenticated user and server-written columns + * (ResourceUser.CreatedByConsentId, MappedConsent.mUserId). It deliberately takes no + * parameters so nothing caller-asserted (body/header/query values) can ever influence + * the resolution — identity-sensitive queries (e.g. /my/banks) depend on that. + */ + def effectiveHumanUserId: String = { + val delegatedHumanUserId = onBehalfOfUser.or(consenter).map(_.userId).filter(_.nonEmpty) + delegatedHumanUserId.openOr { + val authenticatedUserId = user.map(_.userId).openOr("") + val grantingHumanUserId = for { + callerResourceUser <- code.model.dataAccess.ResourceUser.find( + net.liftweb.mapper.By(code.model.dataAccess.ResourceUser.userId_, authenticatedUserId)) + consentId <- net.liftweb.common.Full(callerResourceUser.CreatedByConsentId.get) + .filter(id => id != null && id.nonEmpty) + consent <- code.consent.Consents.consentProvider.vend.getConsentByConsentId(consentId) + } yield consent.userId + grantingHumanUserId.filter(_.nonEmpty).openOr(authenticatedUserId) + } + } def userPrimaryKey: UserPrimaryKey = user.map(_.userPrimaryKey).openOrThrowException(AuthenticatedUserIsRequired) def loggedInUser: User = user.openOrThrowException(AuthenticatedUserIsRequired) // for endpoint body convenient get cc.callContext diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index b11cf0fbb6..3747454e6d 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -637,6 +637,8 @@ object ErrorMessages { val UpdateCustomerAccountLinkError = "OBP-30227: Could not update the customer account link." val DeleteCustomerAccountLinkError = "OBP-30228: Could not delete the customer account link." val GetConsentImplicitSCAError = "OBP-30229: Could not get the implicit SCA consent." + val SelfServiceBankCreationDisabled = "OBP-30230: Self-service bank creation is disabled on this instance." + val SelfServiceBankLimitReached = "OBP-30231: Self-service bank creation limit reached. To create more banks you require the role CanCreateBank." val CreateSystemViewError = "OBP-30250: Could not create the system view" val DeleteSystemViewError = "OBP-30251: Could not delete the system view" diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 08ff035c4f..a56894473b 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -10,7 +10,8 @@ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, Glossary, NewStyle} import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.CommonsEmailWrapper -import code.model.dataAccess.AuthUser +import code.model.dataAccess.{AuthUser, MappedBank, ResourceUser} +import code.consent.Consents import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, IdempotencyMiddleware, RequestScopeConnection, ResourceDocMiddleware, ResourceDocMatcher} @@ -20,7 +21,7 @@ import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v2_0_0.AccountsHelper.accountTypeFilterText import code.api.v2_0_0.{BasicViewJson, CreateEntitlementJSON, JSONFactory200} import code.api.v4_0_0.JSONFactory400 -import code.api.v6_0_0.{BasicAccountJsonV600, BasicAccountsJsonV600, BankJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheNamespaceJsonV600, CacheNamespacesJsonV600, ConnectorInfoJsonV600, ConnectorsJsonV600, DatabasePoolInfoJsonV600, FeaturesJsonV600, InMemoryCacheStatusJsonV600, JSONFactory600, RedisCacheStatusJsonV600, StoredProcedureConnectorHealthJsonV600, UserV600} +import code.api.v6_0_0.{BasicAccountJsonV600, BasicAccountsJsonV600, BankJsonV600, BanksJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheNamespaceJsonV600, CacheNamespacesJsonV600, ConnectorInfoJsonV600, ConnectorsJsonV600, DatabasePoolInfoJsonV600, FeaturesJsonV600, InMemoryCacheStatusJsonV600, JSONFactory600, RedisCacheStatusJsonV600, StoredProcedureConnectorHealthJsonV600, UserV600} import code.api.v6_0_0.JSONFactory600.ViewJsonV600 import code.api.cache.Redis import code.bankconnectors.storedprocedure.StoredProcedureUtils @@ -49,7 +50,7 @@ import code.users.UserAgreementProvider import net.liftweb.common.Full import com.openbankproject.commons.util.JsonAliases.prettyRender import org.json4s.{Extraction, Formats} -import net.liftweb.mapper.{By, Descending, MaxRows, OrderBy} +import net.liftweb.mapper.{By, ByList, Descending, MaxRows, OrderBy} import org.http4s._ import org.http4s.dsl.io._ import org.typelevel.ci.CIString @@ -58,7 +59,7 @@ import scala.collection.JavaConverters._ import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future import scala.language.{higherKinds, implicitConversions} -import code.util.Helper +import code.util.{BankNameGenerator, Helper} object Http4s700 { @@ -71,54 +72,54 @@ object Http4s700 { val versionStatus = ApiVersionStatus.BLEEDING_EDGE.toString val resourceDocs = ArrayBuffer[ResourceDoc]() - /* + /* * IMPORTANT: Endpoint Exclusion Pattern - * + * * excludeEndpoints is used to filter out old endpoints when v7.0.0 has a DIFFERENT URL pattern. - * + * * WHEN TO EXCLUDE: * - Old and new endpoints have DIFFERENT URLs (e.g., v4.0.0: /users/:username vs v7.0.0: /providers/:provider/users/:username) * - The old endpoint should not be accessible via v7.0.0 at all - * + * * WHEN NOT TO EXCLUDE: * - Old and new endpoints have the SAME URL and HTTP method (e.g., GET /api/versions) * - In this case, collectResourceDocs() automatically deduplicates by (URL, method) and keeps newest version * - Excluding by function name would remove BOTH versions since they share the same name! - * + * * Why? The routing works as follows: * 1. allResourceDocs = collectResourceDocs() deduplicates docs by (URL, method), keeps newest * 2. excludeEndpoints filters ResourceDocs by partialFunctionName (removes by name, not by version) * 3. The filtered docs determine which endpoints are available - * + * * Pattern: Add nameOf(Implementations{version}.endpointName) :: with a comment explaining why - * + * * NOTE: Currently empty - no v7-specific exclusions have been identified yet. * As v7.0.0 introduces endpoints with different URL patterns than previous versions, * add those old endpoint names here with explanatory comments. */ - lazy val excludeEndpoints: List[String] = + lazy val excludeEndpoints: List[String] = // Add exclusions here when v7.0.0 replaces old endpoints with different URLs // Example: nameOf(Implementations3_0_0.getUserByUsername) :: // v7.0.0 uses /providers/:provider/users/:username Nil /** * Aggregated resource docs from all API versions (v7.0.0 + v6.0.0 + v5.1.0 + ... + v1.3.0) - * + * * This method implements the resource docs aggregation pattern for v7.0.0: * 1. Takes OBPAPI6_0_0.allResourceDocs (which already contains v6.0.0 + v5.1.0 + ... + v1.3.0) * 2. Adds v7.0.0's own resourceDocs * 3. Deduplicates by (requestUrl, requestVerb), keeping the newest version * 4. Filters out explicitly excluded old endpoints - * + * * Note: We cannot extend OBPRestHelper (Lift framework) in Http4s700 (Http4s framework) * due to type incompatibilities. Instead, we implement the collectResourceDocs logic inline. - * + * * The deduplication algorithm: * - Sort all docs by API version (descending: v7.0.0, v6.0.0, v5.1.0, ...) * - For each doc, check if (requestUrl, requestVerb) has been seen * - If not seen, add to result (this keeps the newest version) * - If seen, skip (this omits older versions of the same endpoint) - * + * * Performance: Computed once and cached (lazy val) to avoid recomputation on every request. */ lazy val allResourceDocs: ArrayBuffer[ResourceDoc] = { @@ -130,19 +131,19 @@ object Http4s700 { // v6.0.0's aggregated docs (v6.0.0 + v5.1.0 + ... + v1.2.1), sourced Lift-free. // Combine with v7.0.0's docs. val allDocs = code.api.util.http4s.Http4sResourceDocAggregation.v600 ++ resourceDocs - + // Deduplicate by (requestUrl, requestVerb), keeping newest version // Sort by API version (descending) so newer versions come first implicit val ordering = new Ordering[ScannedApiVersion] { - override def compare(x: ScannedApiVersion, y: ScannedApiVersion): Int = + override def compare(x: ScannedApiVersion, y: ScannedApiVersion): Int = y.toString().compareTo(x.toString()) } - + val sortedDocs = allDocs.sortBy(_.implementedInApiVersion) - + val result = ArrayBuffer[ResourceDoc]() val urlAndMethods = scala.collection.mutable.Set[(String, String)]() - + for (doc <- sortedDocs) { val urlAndMethod = (doc.requestUrl, doc.requestVerb) if (!urlAndMethods.contains(urlAndMethod)) { @@ -150,7 +151,7 @@ object Http4s700 { result += doc } } - + // Filter out explicitly excluded old endpoints if (excludeEndpoints.isEmpty) { result @@ -278,6 +279,180 @@ object Http4s700 { } } + // ─── Self-service bank creation — /my/banks ────────────────────────────── + // One bank per registered user, gated by props self_service_bank_creation.limit + // (default 0 = disabled). Every public-facing string (bank_id, short name, full + // name) is generated by BankNameGenerator, so no user-supplied text can reach the + // anonymous GET /banks listing — which is why the POST takes an empty body. + // Response shapes reuse the v6 bank JSON (BankJson600 / BanksJsonV600). + + // ─── Delegation fan-down for /my/banks ─────────────────────────────────── + // Resolving UP (agent caller → the granting human) is cc.effectiveHumanUserId. + // This is the fan DOWN: the human plus every agent user minted from any Consent the + // human granted — i.e. all user ids whose creations belong to that human. Match the + // result against CreatedByUserId. Reads only server-written columns + // (MappedConsent.mUserId, ResourceUser.CreatedByConsentId); the input must be an + // already-resolved human id (cc.effectiveHumanUserId), never a raw caller value. + + private def humanAndAgentUserIds(humanUserId: String): List[String] = { + val consentIds = Consents.consentProvider.vend.getConsentsByUser(humanUserId) + .map(_.consentId).filter(_.nonEmpty) + val agentUserIds = + if (consentIds.isEmpty) Nil + else ResourceUser.findAll(ByList(ResourceUser.CreatedByConsentId, consentIds)).map(_.userId) + (humanUserId :: agentUserIds).filter(_.nonEmpty).distinct + } + + // Baked into the ResourceDoc description at boot so API consumers see the effective + // value on this instance (props changes require a restart anyway). The handler reads + // the prop per-request, so tests overriding props are unaffected. + private val selfServiceBankCreationConfiguredLimit = + APIUtil.getPropsAsIntValue("self_service_bank_creation.limit", 0) + private val selfServiceBankCreationStatusText = + if (selfServiceBankCreationConfiguredLimit > 0) + s"On this instance, each User may create up to $selfServiceBankCreationConfiguredLimit bank(s) via this endpoint." + else + "On this instance, self-service bank creation is currently disabled." + + val createMyBank: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "my" / "banks" => + EndpointHelpers.executeFutureCreated(req) { + implicit val cc: CallContext = req.callContext + val selfServiceBankLimit = APIUtil.getPropsAsIntValue("self_service_bank_creation.limit", 0) + for { + _ <- Helper.booleanToFuture(SelfServiceBankCreationDisabled, cc = Some(cc)) { + selfServiceBankLimit > 0 + } + _ <- Helper.booleanToFuture( + s"$InvalidJsonFormat The request body must be empty or an empty JSON object {} — the bank identity is generated by the server.", + cc = Some(cc)) { + // API Explorer and similar clients send {} for POSTs — treat it as empty. + cc.httpBody.forall(requestBody => { + val withoutWhitespace = requestBody.replaceAll("\\s", "") + withoutWhitespace.isEmpty || withoutWhitespace == "{}" + }) + } + banksCreatedByUser <- Future { + // Quota binds to the human: banks created by the human directly or by any + // of their consent-agents count toward the same limit — otherwise every + // new consent would arrive with a fresh quota. + val creatorUserIds = humanAndAgentUserIds(cc.effectiveHumanUserId) + MappedBank.count(ByList(MappedBank.CreatedByUserId, creatorUserIds)) + } + _ <- Helper.booleanToFuture(SelfServiceBankLimitReached, failCode = 403, cc = Some(cc)) { + banksCreatedByUser < selfServiceBankLimit + } + generatedName <- Future { + unboxFullOrFail( + BankNameGenerator.generateUnique(candidateBankId => + MappedBank.findByBankId(BankId(candidateBankId)).isDefined), + Some(cc), UnknownError, 500 + ) + } + (bank, _) <- NewStyle.function.createOrUpdateBank( + generatedName.bankId, + generatedName.fullName, + generatedName.shortName, + "", "", "", "", "", "", + Some(cc) + ) + _ <- Future(Entitlement.entitlement.vend.addEntitlement( + generatedName.bankId, cc.userId, canCreateEntitlementAtOneBank.toString())) + } yield JSONFactory600.createBankJSON600(bank) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createMyBank), + "POST", + "/my/banks", + "Create My Bank (Self-Service)", + s"""Create a bank for the current User without requiring the role CanCreateBank. + | + |This is the self-service flavour of Create Bank, intended for + |sandbox instances. + | + |$selfServiceBankCreationStatusText + | + |When the limit is reached, further banks require the role CanCreateBank + |(see POST /banks). The limit binds to the human User: banks created by the User + |directly and by any agent acting for the User under a Consent count toward the + |same limit. + | + |The request body must be empty (an empty JSON object `{}` is also accepted): + |the bank_id, short name and full name are + |auto-generated by the server (e.g. bank_id `granite-astra-falcon-4f2a`, full name + |`Granite Astra Falcon Bank`) and are permanent. Users holding the role CanCreateBank can instead use POST /banks to create + |banks with chosen names and branding. + | + |The user creating the bank is automatically assigned the Role + |CanCreateEntitlementAtOneBank at the new bank, and can therefore manage the bank + |and assign Roles to other Users. + | + |The settlement accounts are automatically created by the system when the bank is + |created, as for POST /banks. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, + bankJson600, + List( + $AuthenticatedUserIsRequired, + SelfServiceBankCreationDisabled, + SelfServiceBankLimitReached, + InvalidJsonFormat, + UnknownError + ), + apiTagBank :: Nil, + None, + http4sPartialFunction = Some(createMyBank) + ) + + val getMyBanks: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "my" / "banks" => + EndpointHelpers.withUser(req) { (user, cc) => + for { + banksCreatedByUser <- Future { + val creatorUserIds = humanAndAgentUserIds(cc.effectiveHumanUserId) + MappedBank.findAll(ByList(MappedBank.CreatedByUserId, creatorUserIds)) + } + } yield JSONFactory600.createBanksJsonV600(banksCreatedByUser) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(getMyBanks), + "GET", + "/my/banks", + "Get My Banks", + s"""Returns the banks belonging to the current User — created directly by the User, + |or created by an agent acting for the User under a Consent. + | + |Delegation is resolved server-side from the Consent records: when the caller is a + |consent-based agent, the list shows the granting User's banks; when the caller is + |the User, the list includes banks created by any of their consent-based agents. + |Nothing is accepted from the caller to influence this resolution. + | + |${userAuthenticationMessage(true)} + |""", + EmptyBody, + BanksJsonV600(List(BankJsonV600( + bank_id = "granite-astra-falcon-4f2a", + bank_code = "", + full_name = "Granite Astra Falcon Bank", + logo = "", + website = "", + bank_routings = Nil, + attributes = None + ))), + List($AuthenticatedUserIsRequired, UnknownError), + apiTagBank :: Nil, + None, + http4sPartialFunction = Some(getMyBanks) + ) + // Category: withUserDelete (user auth, 204 No Content) val deleteEntitlement: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "entitlements" / entitlementId => @@ -642,7 +817,7 @@ object Http4s700 { ) // ── Trading Endpoints ────────────────────────────────────────────────── - + // Route: POST /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers val createTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ POST -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" => @@ -684,7 +859,7 @@ object Http4s700 { } yield JSONFactory700.createTradingOfferJson(offer) } } - + resourceDocs += ResourceDoc( implementedInApiVersion, nameOf(createTradingOffer), @@ -735,7 +910,7 @@ object Http4s700 { apiTagTrading :: apiTagTrade :: Nil, http4sPartialFunction = Some(createTradingOffer) ) - + // Route: GET /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID val getTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ GET -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => @@ -795,7 +970,7 @@ object Http4s700 { // Extract query parameters val status = req.uri.query.params.get("status") val offerType = req.uri.query.params.get("offer_type") - + for { // Invoke connector (offers, callContext) <- NewStyle.function.getTradingOffers( @@ -863,7 +1038,7 @@ object Http4s700 { apiTagTrading :: apiTagTrade :: Nil, http4sPartialFunction = Some(getTradingOffers) ) - + // Route: DELETE /obp/v7.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/views/VIEW_ID/trading/offers/OFFER_ID val cancelTradingOffer: HttpRoutes[IO] = HttpRoutes.of[IO] { case req @ DELETE -> `prefixPath` / "banks" / bankId / "accounts" / accountId / "views" / viewId / "trading" / "offers" / offerId => @@ -1405,14 +1580,14 @@ object Http4s700 { // for { // // Validate bank and account // (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) -// +// // // Validate amount // _ <- Helper.booleanToFuture( // failMsg = InvalidTradingAmount, // failCode = 400, // cc = callContext // )(createAuthJson.amount_fiat > 0) -// +// // // Invoke connector to create payment authorization (PREAUTH state) // (auth, callContext2) <- NewStyle.function.createPaymentAuth( // BankId(bankId), @@ -1482,7 +1657,7 @@ object Http4s700 { // for { // // Validate bank and account // (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) -// +// // // Invoke connector to capture payment (PREAUTH → CAPTURED) // (auth, callContext2) <- NewStyle.function.capturePaymentAuth( // BankId(bankId), @@ -1539,7 +1714,7 @@ object Http4s700 { // for { // // Validate bank and account // (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) -// +// // // Invoke connector to release payment (PREAUTH/CAPTURED → RELEASED) // (auth, callContext2) <- NewStyle.function.releasePaymentAuth( // BankId(bankId), @@ -1596,7 +1771,7 @@ object Http4s700 { // for { // // Validate bank and account // (_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc)) -// +// // // Invoke connector to get payment authorization // (auth, callContext2) <- NewStyle.function.getPaymentAuth( // BankId(bankId), diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 5fcc5adfcb..4d97456300 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -3378,6 +3378,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { .national_identifier(national_identifier) .mBankRoutingScheme(bankRoutingScheme) .mBankRoutingAddress(bankRoutingAddress) + .CreatedByUserId(callContext.map(_.user).flatMap(_.toOption).map(_.userId).getOrElse("")) .saveMe() } ?~! ErrorMessages.UpdateBankError } diff --git a/obp-api/src/main/scala/code/model/dataAccess/MappedBank.scala b/obp-api/src/main/scala/code/model/dataAccess/MappedBank.scala index 0809177f21..b6888c5cce 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/MappedBank.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/MappedBank.scala @@ -15,7 +15,12 @@ class MappedBank extends Bank with LongKeyedMapper[MappedBank] with IdPK with Cr object national_identifier extends MappedString(this, 255) object mBankRoutingScheme extends MappedString(this, 255) object mBankRoutingAddress extends MappedString(this, 255) - + // user_id of the User that created this bank (empty for banks created before this + // column existed or via paths with no authenticated user, e.g. sandbox data import). + // Never serialized into any API response — used for the self-service bank quota + // (POST /my/banks) and the GET /my/banks listing. + object CreatedByUserId extends MappedString(this, 255) + override def bankId: BankId = BankId(permalink.get) // This is the bank id used in URLs override def fullName: String = fullBankName.get @@ -32,7 +37,7 @@ object MappedBank extends MappedBank with LongKeyedMetaMapper[MappedBank] { // permalink should be unique // TODO should have UniqueIndex on permalink but need to modify tests see createBank // TODO Other Models should be able to foreign key to this but would need to expose IdPK then? - override def dbIndexes = Index(permalink) :: super.dbIndexes + override def dbIndexes = Index(permalink) :: Index(CreatedByUserId) :: super.dbIndexes def findByBankId(bankId : BankId) = MappedBank.find(By(MappedBank.permalink, bankId.value)) diff --git a/obp-api/src/main/scala/code/users/LiftUsers.scala b/obp-api/src/main/scala/code/users/LiftUsers.scala index f6073e587c..f117f9f110 100644 --- a/obp-api/src/main/scala/code/users/LiftUsers.scala +++ b/obp-api/src/main/scala/code/users/LiftUsers.scala @@ -314,7 +314,7 @@ object LiftUsers extends Users with MdcLoggable{ } createdByUserInvitationId match { case Some(invitationId) => ru.CreatedByUserInvitationId(invitationId) - case None => ru.CreatedByConsentId(null) + case None => ru.CreatedByUserInvitationId(null) } name match { case Some(v) => ru.name_(v) diff --git a/obp-api/src/main/scala/code/util/BankNameGenerator.scala b/obp-api/src/main/scala/code/util/BankNameGenerator.scala new file mode 100644 index 0000000000..6910a12eb9 --- /dev/null +++ b/obp-api/src/main/scala/code/util/BankNameGenerator.scala @@ -0,0 +1,107 @@ +package code.util + +import net.liftweb.common.{Box, Failure, Full} + +import scala.util.Random + +/** + * Generates bank identities for self-service bank creation (POST /my/banks). + * + * Every public-facing string (bank_id, short name, full name) is produced here from + * three curated word lists plus a 4-hex-char suffix, so no user-supplied text can + * ever reach the anonymous GET /banks listing. Example output: + * + * bankId = "granite-astra-falcon-4f2a" + * shortName = "Granite Astra Falcon" + * fullName = "Granite Astra Falcon Bank" + * + * Combination space: 100 x 100 x 100 x 16^4 = ~65 billion bank ids. + * + * Curation rules for the word lists (keep these when editing): + * - lowercase ASCII single words only (they become URL path segments) + * - no colour words that double as skin/ethnicity descriptors (black, white, brown, + * yellow, red) — the third word list contains nouns and the combinations would + * read as descriptions of people + * - no names of real financial institutions (e.g. fortis) + * - screen the full cross-product for rude substrings in the major sandbox-audience + * languages (en, de, fr, es, it) before adding words + */ +object BankNameGenerator { + + case class GeneratedBankName(bankId: String, shortName: String, fullName: String) + + // Colours, minerals, textures and qualities. + val adjectives: List[String] = List( + "amber", "coral", "indigo", "crimson", "teal", "cobalt", "jade", "slate", "ochre", "granite", + "marble", "copper", "bronze", "silver", "golden", "pearl", "ivory", "onyx", "quartz", "topaz", + "garnet", "opal", "amethyst", "sapphire", "emerald", "ruby", "turquoise", "magenta", "violet", "lavender", + "lilac", "mauve", "maroon", "scarlet", "vermilion", "azure", "cerulean", "navy", "ultramarine", "aquamarine", + "mint", "olive", "sage", "fern", "moss", "pine", "cedar", "oak", "maple", "birch", + "walnut", "hazel", "chestnut", "sienna", "umber", "sepia", "taupe", "sandy", "dune", "clay", + "terracotta", "brick", "flint", "basalt", "obsidian", "pumice", "shale", "cobble", "pebble", "crystal", + "glacier", "arctic", "alpine", "polar", "boreal", "solar", "lunar", "stellar", "cosmic", "meridian", + "zenith", "prime", "noble", "regal", "royal", "grand", "bright", "clear", "calm", "swift", + "steady", "solid", "sturdy", "brisk", "bold", "keen", "deft", "agile", "gentle", "quiet" + ) + + // Latin and celestial nouns — loanword-grade / brand-familiar only. + val celestials: List[String] = List( + "nova", "terra", "luna", "astra", "stella", "aurora", "aqua", "vita", "lux", "pax", + "vera", "alta", "magna", "prima", "ultra", "aura", "iris", "flora", "silva", "unda", + "arbor", "avis", "apis", "ursa", "aquila", "corvus", "cygnus", "lyra", "vega", "altair", + "sirius", "rigel", "polaris", "atlas", "titan", "helios", "selene", "gaia", "juno", "vesta", + "ceres", "minerva", "aurelia", "cassia", "livia", "octavia", "nimbus", "cirrus", "stratus", "cumulus", + "zephyr", "aether", "aurum", "argentum", "ferrum", "platina", "crux", "draco", "phoenix", "pegasus", + "andromeda", "perseus", "calypso", "triton", "oberon", "titania", "miranda", "ariel", "callisto", "europa", + "themis", "rhea", "dione", "janus", "orion", "castor", "pollux", "capella", "antares", "deneb", + "mira", "electra", "maia", "celeste", "solis", "montis", "fontis", "pontis", "portus", "hortus", + "domus", "virtus", "veritas", "concordia", "fortuna", "victoria", "gloria", "aurea", "argenta", "stellaris" + ) + + // Animals — birds, mammals and fish. + val animals: List[String] = List( + "falcon", "heron", "otter", "lynx", "ibex", "osprey", "kestrel", "merlin", "condor", "eagle", + "hawk", "owl", "raven", "wren", "robin", "finch", "sparrow", "swallow", "starling", "crane", + "stork", "pelican", "puffin", "gannet", "petrel", "albatross", "tern", "plover", "curlew", "avocet", + "egret", "ibis", "flamingo", "toucan", "macaw", "kingfisher", "hoopoe", "lark", "nightingale", "oriole", + "tanager", "warbler", "thrush", "magpie", "jay", "fox", "wolf", "bear", "elk", "moose", + "stag", "hart", "bison", "buffalo", "yak", "chamois", "marmot", "beaver", "badger", "marten", + "ermine", "hare", "rabbit", "squirrel", "hedgehog", "seal", "walrus", "dolphin", "porpoise", "whale", + "orca", "narwhal", "manatee", "tortoise", "turtle", "gecko", "salmon", "trout", "pike", "perch", + "carp", "sturgeon", "marlin", "tuna", "panther", "leopard", "cheetah", "jaguar", "puma", "cougar", + "ocelot", "serval", "caracal", "lion", "tiger", "gazelle", "antelope", "oryx", "zebra", "okapi" + ) + + require(adjectives.distinct.size == adjectives.size, "duplicate word in adjectives list") + require(celestials.distinct.size == celestials.size, "duplicate word in celestials list") + require(animals.distinct.size == animals.size, "duplicate word in animals list") + + private val suffixAlphabet = "0123456789abcdef" + private val suffixLength = 4 + + private def pickRandomWord(words: List[String]): String = words(Random.nextInt(words.size)) + + def generate(): GeneratedBankName = { + val words = List(pickRandomWord(adjectives), pickRandomWord(celestials), pickRandomWord(animals)) + val suffix = List.fill(suffixLength)(suffixAlphabet(Random.nextInt(suffixAlphabet.length))).mkString + val displayName = words.map(_.capitalize).mkString(" ") + GeneratedBankName( + bankId = (words :+ suffix).mkString("-"), + shortName = displayName, + fullName = s"$displayName Bank" + ) + } + + /** + * Generate a bank name whose bankId is not already taken, retrying up to maxAttempts + * times. The caller supplies the taken-check (a lookup against the bank table); the + * unique index on the bank_id column remains the correctness backstop for races. + */ + def generateUnique(isBankIdTaken: String => Boolean, maxAttempts: Int = 10): Box[GeneratedBankName] = { + val candidates = Iterator.continually(generate()).take(maxAttempts) + candidates.find(candidate => !isBankIdTaken(candidate.bankId)) match { + case Some(candidate) => Full(candidate) + case None => Failure(s"Could not generate an unused bank id after $maxAttempts attempts") + } + } +} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 7bd3cf9ddb..c4d0f72b34 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -8,7 +8,7 @@ import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canDeleteSchedulerJobLock, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canGetSchedulerJobLocks, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidJsonFormat, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SelfServiceBankCreationDisabled, SelfServiceBankLimitReached, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.scheduler.JobScheduler import net.liftweb.mapper.By @@ -2608,4 +2608,170 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── /my/banks — self-service bank creation ───────────────────────────────── + + feature("Http4s700 self-service bank creation — /my/banks") { + + def extractMessage(json: JValue): String = json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg + case _ => fail("Expected message field in error response") + } + case _ => fail("Expected JSON object error response") + } + + scenario("Unauthenticated POST /my/banks returns 401", Http4s700RoutesTag) { + Given("self_service_bank_creation.limit is 1 but no auth is supplied") + setPropsValues("self_service_bank_creation.limit" -> "1") + val (statusCode, json, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/my/banks") + Then("Response is 401") + statusCode shouldBe 401 + extractMessage(json) should include(AuthenticatedUserIsRequired) + } + + scenario("Unauthenticated GET /my/banks returns 401", Http4s700RoutesTag) { + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/my/banks") + statusCode shouldBe 401 + extractMessage(json) should include(AuthenticatedUserIsRequired) + } + + scenario("POST /my/banks returns 400 when self-service creation is disabled (default limit 0)", Http4s700RoutesTag) { + Given("self_service_bank_creation.limit is 0 (the default)") + setPropsValues("self_service_bank_creation.limit" -> "0") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/my/banks", headers) + Then("Response is 400 with SelfServiceBankCreationDisabled") + statusCode shouldBe 400 + extractMessage(json) should include(SelfServiceBankCreationDisabled) + } + + scenario("POST /my/banks with a non-empty body returns 400", Http4s700RoutesTag) { + Given("self_service_bank_creation.limit is 1 and a body is supplied") + setPropsValues("self_service_bank_creation.limit" -> "1") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = + makeHttpRequestWithBody("POST", "/obp/v7.0.0/my/banks", """{"full_name":"VERY RUDE BANK NAME"}""", headers) + Then("Response is 400 — the bank identity is server-generated, no body is accepted") + statusCode shouldBe 400 + extractMessage(json) should include(InvalidJsonFormat) + } + + scenario("POST /my/banks creates a generated bank; second POST is 403; GET /my/banks lists it", Http4s700RoutesTag) { + Given("self_service_bank_creation.limit is 1") + setPropsValues("self_service_bank_creation.limit" -> "1") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("POST /obp/v7.0.0/my/banks with an empty JSON object body (as API Explorer sends)") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", "/obp/v7.0.0/my/banks", "{}", headers) + + Then("Response is 201 with a fully generated bank identity") + statusCode shouldBe 201 + val fieldMap = json match { + case JObject(fields) => toFieldMap(fields) + case _ => fail("Expected JSON object for created bank") + } + val bankId = fieldMap.get("bank_id") match { + case Some(JString(id)) => id + case _ => fail("Expected bank_id field") + } + bankId should fullyMatch regex "[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}" + val fullName = fieldMap.get("full_name") match { + case Some(JString(name)) => name + case _ => fail("Expected full_name field") + } + fullName should endWith(" Bank") + + And("the creator is granted CanCreateEntitlementAtOneBank at the new bank") + Entitlement.entitlement.vend + .getEntitlement(bankId, resourceUser1.userId, "CanCreateEntitlementAtOneBank") + .isDefined shouldBe true + + And("GET /my/banks lists the created bank") + val (getStatus, getJson, _) = makeHttpRequest("/obp/v7.0.0/my/banks", headers) + getStatus shouldBe 200 + val listedBankIds = getJson \ "banks" match { + case JArray(banks) => banks.map(bank => bank \ "bank_id").collect { case JString(id) => id } + case _ => fail("Expected banks array") + } + listedBankIds should contain(bankId) + + And("a second POST returns 403 — the quota is exhausted") + val (secondStatus, secondJson, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/my/banks", headers) + secondStatus shouldBe 403 + extractMessage(secondJson) should include(SelfServiceBankLimitReached) + } + + scenario("Different consent-agents and the human itself create banks — all listed, one shared quota", Http4s700RoutesTag) { + + /** Simulate a consent granted by the human minting an agent user which creates a bank. */ + def createBankViaNewConsentAgent(humanUserId: String): String = { + val consent = code.consent.MappedConsent.create.mUserId(humanUserId).saveMe() + val agentUser = code.users.Users.users.vend.createResourceUser( + provider = "test-consent-issuer", + providerId = Some(APIUtil.generateUUID()), + createdByConsentId = Some(consent.consentId), + name = Some("test-agent-user"), + email = None, + userId = None, + createdByUserInvitationId = None, + company = None, + lastMarketingAgreementSignedDate = None + ).openOrThrowException("Expected agent user to be created") + val agentBankId = s"agent-made-${APIUtil.generateUUID().take(8)}" + code.model.dataAccess.MappedBank.create + .permalink(agentBankId) + .fullBankName("Agent Made Bank") + .shortBankName("Agent Made") + .CreatedByUserId(agentUser.userId) + .saveMe() + agentBankId + } + + Given("user3 granted two different consents whose agents each created a bank") + setPropsValues("self_service_bank_creation.limit" -> "3") + val bankFromAgent1 = createBankViaNewConsentAgent(resourceUser3.userId) + val bankFromAgent2 = createBankViaNewConsentAgent(resourceUser3.userId) + val headers = Map("DirectLogin" -> s"token=${token3.value}") + + When("user3 creates a bank directly — quota is 2 of 3 so this succeeds") + val (postStatus, postJson, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/my/banks", headers) + postStatus shouldBe 201 + val directBankId = postJson \ "bank_id" match { + case JString(id) => id + case _ => fail("Expected bank_id field") + } + + Then("GET /my/banks lists all three: both agents' banks and the direct one") + val (getStatus, getJson, _) = makeHttpRequest("/obp/v7.0.0/my/banks", headers) + getStatus shouldBe 200 + val listedBankIds = getJson \ "banks" match { + case JArray(banks) => banks.map(bank => bank \ "bank_id").collect { case JString(id) => id } + case _ => fail("Expected banks array") + } + listedBankIds should contain(bankFromAgent1) + listedBankIds should contain(bankFromAgent2) + listedBankIds should contain(directBankId) + + And("the quota is shared — a fourth bank is refused with 403") + val (secondPostStatus, secondPostJson, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/my/banks", headers) + secondPostStatus shouldBe 403 + extractMessage(secondPostJson) should include(SelfServiceBankLimitReached) + } + + scenario("Each user has an independent self-service quota", Http4s700RoutesTag) { + Given("user1 has exhausted their quota but user2 has not") + setPropsValues("self_service_bank_creation.limit" -> "1") + val headers = Map("DirectLogin" -> s"token=${token2.value}") + When("user2 POSTs /obp/v7.0.0/my/banks") + val (statusCode, json, _) = makeHttpRequestWithMethod("POST", "/obp/v7.0.0/my/banks", headers) + Then("Response is 201") + statusCode shouldBe 201 + json \ "bank_id" match { + case JString(id) => id should fullyMatch regex "[a-z]+-[a-z]+-[a-z]+-[0-9a-f]{4}" + case _ => fail("Expected bank_id field") + } + } + } + }