diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index a3deda1da7..6b14c3d17b 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -240,7 +240,7 @@ jobs: test_filter: >- code.api.v4_0_0.Dynamic - shard: 10 - name: "PropGatedPublicEndpoint (read_*_requires_role=true)" + name: "PropGatedPublicEndpoint (read_*_requires_role=true, allow_cascade_delete=false)" # JsonSchemaValidationPublicPropTrueTest / AuthenticationTypeValidationPublicPropTrueTest # need read_json_schema_validation_requires_role / # read_authentication_type_validation_requires_role forced true, which is baked into @@ -248,9 +248,12 @@ jobs: # observe. Every other shard boots with these props unset (false, the default) and # the pom.xml default tagsToExclude skips this tag there. This shard is the only place # the true branch is exercised; see the "Run tests" step's shard-10 special case below. + # CascadeDeleteDisabledTest is here for the opposite reason: every other shard has + # allow_cascade_delete=true, and only this JVM starts with it false. test_filter: >- code.api.v4_0_0.JsonSchemaValidationPublicPropTrueTest code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest + code.api.v4_0_0.CascadeDeleteDisabledTest services: redis: @@ -341,6 +344,7 @@ jobs: echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props + echo allow_cascade_delete=true >> obp-api/src/main/resources/props/test.default.props echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props @@ -449,6 +453,8 @@ jobs: if [ "${{ matrix.shard }}" = "10" ]; then export OBP_READ_JSON_SCHEMA_VALIDATION_REQUIRES_ROLE="true" export OBP_READ_AUTHENTICATION_TYPE_VALIDATION_REQUIRES_ROLE="true" + # The cascade-delete routes are fixed when the API objects initialise; off here only. + export OBP_ALLOW_CASCADE_DELETE="false" EXTRA_MVN_ARGS="-DtagsToInclude=PropGatedPublicEndpoint -Dtest.tagsToExclude=" fi diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index a14a3c89b5..5db92301df 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -234,7 +234,7 @@ jobs: test_filter: >- code.api.v4_0_0.Dynamic - shard: 10 - name: "PropGatedPublicEndpoint (read_*_requires_role=true)" + name: "PropGatedPublicEndpoint (read_*_requires_role=true, allow_cascade_delete=false)" # JsonSchemaValidationPublicPropTrueTest / AuthenticationTypeValidationPublicPropTrueTest # need read_json_schema_validation_requires_role / # read_authentication_type_validation_requires_role forced true, which is baked into @@ -242,9 +242,12 @@ jobs: # observe. Every other shard boots with these props unset (false, the default) and # the pom.xml default tagsToExclude skips this tag there. This shard is the only place # the true branch is exercised; see the "Run tests" step's shard-10 special case below. + # CascadeDeleteDisabledTest is here for the opposite reason: every other shard has + # allow_cascade_delete=true, and only this JVM starts with it false. test_filter: >- code.api.v4_0_0.JsonSchemaValidationPublicPropTrueTest code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest + code.api.v4_0_0.CascadeDeleteDisabledTest services: redis: @@ -335,6 +338,7 @@ jobs: echo allow_sandbox_data_import=true >> obp-api/src/main/resources/props/test.default.props echo sandbox_data_import_secret=change_me >> obp-api/src/main/resources/props/test.default.props echo allow_account_deletion=true >> obp-api/src/main/resources/props/test.default.props + echo allow_cascade_delete=true >> obp-api/src/main/resources/props/test.default.props echo allowed_internal_redirect_urls = /,/oauth/authorize >> obp-api/src/main/resources/props/test.default.props echo transactionRequests_enabled=true >> obp-api/src/main/resources/props/test.default.props echo transactionRequests_supported_types=SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,SIMPLE >> obp-api/src/main/resources/props/test.default.props @@ -453,6 +457,8 @@ jobs: if [ "${{ matrix.shard }}" = "10" ]; then export OBP_READ_JSON_SCHEMA_VALIDATION_REQUIRES_ROLE="true" export OBP_READ_AUTHENTICATION_TYPE_VALIDATION_REQUIRES_ROLE="true" + # The cascade-delete routes are fixed when the API objects initialise; off here only. + export OBP_ALLOW_CASCADE_DELETE="false" EXTRA_MVN_ARGS="-DtagsToInclude=PropGatedPublicEndpoint -Dtest.tagsToExclude=" fi diff --git a/CLAUDE.md b/CLAUDE.md index df8b1d9bac..4e6554936a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -295,9 +295,9 @@ Shards are defined per-matrix-entry in `.github/workflows/build_pull_request.yml | `code.connector`, `code.util`, `code.api.Authentication*`, `code.api.dauthTest`, `code.api.DirectLoginTest`, `code.api.gateWayloginTest`, `code.api.OBPRestHelperTest`, `code.entitlement`, `code.bankaccountcreation`, `code.bankconnectors`, `code.container`, `code.management`, `code.metrics`, `code.concurrency` | 8 | | anything else | **8** (catch-all) | | `code.api.v4_0_0.Dynamic*` | 9 | -| `code.api.v4_0_0.JsonSchemaValidationPublicPropTrueTest`, `code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest` (tagged `PropGatedPublicEndpoint`) | 10 | +| `code.api.v4_0_0.JsonSchemaValidationPublicPropTrueTest`, `code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest`, `code.api.v4_0_0.CascadeDeleteDisabledTest` (tagged `PropGatedPublicEndpoint`) | 10 | -Shard 10 is a special case: it's the only shard that overrides pom.xml's default `tagsToExclude` (which otherwise skips `PropGatedPublicEndpoint` everywhere) and sets `OBP_READ_JSON_SCHEMA_VALIDATION_REQUIRES_ROLE`/`OBP_READ_AUTHENTICATION_TYPE_VALIDATION_REQUIRES_ROLE=true`, because those two props are baked into `Http4s400`'s `ResourceDoc` error lists at object-init time — a single JVM can only ever observe one value of each, so the `true` branch needs its own shard while every other shard (which boots with the props unset, i.e. `false`) exercises the default branch. `run_tests_parallel.sh` (local runner) mirrors this with a dedicated sequential step after its 4 main shards — see that script's own comment near `PropGatedPublicEndpoint`. +Shard 10 is a special case: it's the only shard that overrides pom.xml's default `tagsToExclude` (which otherwise skips `PropGatedPublicEndpoint` everywhere) and sets `OBP_READ_JSON_SCHEMA_VALIDATION_REQUIRES_ROLE`/`OBP_READ_AUTHENTICATION_TYPE_VALIDATION_REQUIRES_ROLE=true`, because those two props are baked into `Http4s400`'s `ResourceDoc` error lists at object-init time — a single JVM can only ever observe one value of each, so the `true` branch needs its own shard while every other shard (which boots with the props unset, i.e. `false`) exercises the default branch. Shard 10 also sets `OBP_ALLOW_CASCADE_DELETE=false` for `CascadeDeleteDisabledTest`: every other shard runs with `allow_cascade_delete=true`, and the cascade-delete routes are likewise fixed at object-init time. `run_tests_parallel.sh` (local runner) mirrors this with a dedicated sequential step after its 4 main shards — see that script's own comment near `PropGatedPublicEndpoint`. To explicitly move a package to a different shard, add it to that shard's `test_filter` block — it will be excluded from the catch-all automatically. `run_tests_parallel.sh` (local runner) uses a coarser 4-shard layout that folds all 9 CI shards' coverage into 4 wildcardSuites groups — see its own header comment for the mapping. diff --git a/obp-api/pom.xml b/obp-api/pom.xml index c528526d3d..c1da052fd7 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -60,7 +60,7 @@ org.bouncycastle bcpg-jdk18on - 1.84 + 1.85 org.http4s @@ -75,7 +75,7 @@ org.bouncycastle bcpkix-jdk18on - 1.84 + 1.85 @@ -633,7 +633,9 @@ observe. Excluded here by default (every ordinary shard/local/IDE run boots with the props unset, i.e. false) and run separately with -Dtest.tagsToExclude= -DtagsToInclude=PropGatedPublicEndpoint plus the two OBP_READ_*_REQUIRES_ROLE - env vars set true (see run_tests_parallel.sh and the CI job that runs them); + env vars set true (see run_tests_parallel.sh and the CI job that runs them). + CascadeDeleteDisabledTest shares the tag and runs there with + OBP_ALLOW_CASCADE_DELETE=false; see the ${test.tagsToExclude} property declared above for why this is not a plain literal. --> ${test.tagsToExclude} diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 2f4d3d6a1e..5ae6962c19 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -639,6 +639,20 @@ messageQueue.createBankAccounts=true ## Set this to true if you want to allow users to delete accounts (local ones like HBCI connected) allow_account_deletion=true +## Cascade-delete endpoints: each deletes a bank object together with everything stored under it. +## DELETE /obp/v4.0.0/management/cascading/banks/BANK_ID +## DELETE /obp/v4.0.0/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID +## DELETE /obp/v4.0.0/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID +## DELETE /obp/v4.0.0/management/cascading/banks/BANK_ID/products/PRODUCT_CODE +## DELETE /obp/v4.0.0/management/cascading/banks/BANK_ID/customers/CUSTOMER_ID +## When false (the default) they are not routed and not listed in the Resource Docs of any version, +## so a request gets 404. Set to true to make them available. Read at startup. The Dynamic Entity +## cascade deletes (.../dynamic-entities/cascade/DYNAMIC_ENTITY_ID) are not affected. +## The Resource Docs responses are cached in Redis (staticResourceDocsObp.cache.ttl.seconds), so after a +## change and restart they can list the old set of endpoints until that TTL passes, or until the rd_static +## and rd_all cache namespaces are invalidated (POST /obp/v6.0.0/management/cache/namespaces/invalidate). +allow_cascade_delete=false + ## Secret key that allows access to api calls to get info about oauth tokens. You should change this ## to your own secret key BankMockKey=change_me @@ -1237,6 +1251,7 @@ display_internal_errors=false # Please note that in case that redirect_url_value contains special word PLACEHOLDER it will be replaced with actual ID # http://127.0.0.1:8080/confirm-bg-consent-request?PAYMENT_ID=PLACEHOLDER # psu_make_payment_sca_redirect_url = redirect_url_value +# When it is not set (or blank), the response to a payment initiation has no scaRedirect link. # -------------------------------------------------------------- Authentication methods -- ## This property is used for documenting at Resource Doc. It may include the port also (but not /obp) diff --git a/obp-api/src/main/resources/props/test.default.props.template b/obp-api/src/main/resources/props/test.default.props.template index 4a9812f382..3ed0258096 100644 --- a/obp-api/src/main/resources/props/test.default.props.template +++ b/obp-api/src/main/resources/props/test.default.props.template @@ -115,6 +115,9 @@ sandbox_data_import_secret=change_me #set this to true if you want to allow users to delete accounts allow_account_deletion=true +# The cascade-delete endpoints are off by default; Delete*CascadeTest and DeleteCascadeCounterpartiesTest +# need them. CascadeDeleteDisabledTest runs with them off. +allow_cascade_delete=true # This needs to be a list all the types of transaction_requests that we have tests for. Else those tests will fail transactionRequests_supported_types=SANDBOX_TAN,COUNTERPARTY,SEPA,ACCOUNT_OTP,ACCOUNT,SIMPLE,AGENT_CASH_WITHDRAWAL,CARD diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index f16cae5f9e..f755552630 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -384,24 +384,12 @@ class Boot extends MdcLoggable { } // The remaining two stay opt-in: nothing in the code requires them to exist, so whether an - // instance has them is still the operator's call. Their permission sets ARE code-defined - // though, so where they do exist they are kept current the same way -- the prop decides - // existence, not whether the code is authoritative about what a view grants. - APIUtil.getPropsValue("additional_system_views") match { - case Full(value) => - val additionalSystemViewsFromProps = value.split(",").map(_.trim).toList - val additionalSystemViews = List( - SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, - SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID - ) - for { - systemView <- additionalSystemViewsFromProps - if additionalSystemViews.exists(_ == systemView) - } { - Views.views.vend.ensureSystemViewUpToDate(systemView) - } - case _ => // Do nothing - } + // instance has them is still the operator's call. (The mapped connector is the exception: it lists + // both in viewsToGenerate, so a refresh creates them and grants them to the holders of their accounts + // whether or not they are listed here.) Their permission sets ARE code-defined though, so wherever they + // exist, listed or not, they are kept current the same way, unless reconcile_permissions_at_boot is false -- + // the prop decides existence, not whether the code is authoritative about what a view grants. + Views.views.vend.ensureOptInSystemViewsUpToDate(APIUtil.getPropsValue("additional_system_views")) } diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala index 7d4109f5ec..e33f655746 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/SwaggerDefinitionsJSON.scala @@ -4310,8 +4310,8 @@ object SwaggerDefinitionsJSON { lazy val callLimitPostJsonV600 = CallLimitPostJsonV600( from_date = DateWithDayExampleObject, to_date = DateWithDayExampleObject, - api_version = Some(ApiVersion.v6_0_0.toString), - api_name = Some("getConsumerCallLimits"), + api_version = None, + api_name = None, bank_id = None, per_second_call_limit = "100", per_minute_call_limit = "1000", diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala index 29bbf77ddb..39b163b3b0 100644 --- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala +++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3.scala @@ -318,7 +318,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ ) case class InitiatePaymentResponseLinks( - scaRedirect: LinkHrefJson, + scaRedirect: Option[LinkHrefJson], self: LinkHrefJson, status: LinkHrefJson, scaStatus: LinkHrefJson @@ -801,18 +801,18 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ // Remark: This code may be //map OBP transactionRequestId to BerlinGroup PaymentId val paymentId = transactionRequest.id.value - val scaRedirectUrlPattern = getPropsValue("psu_make_payment_sca_redirect_url") - .openOr(MissingPropsValueAtThisInstance + "psu_make_payment_sca_redirect_url") - val scaRedirectUrl = - if (scaRedirectUrlPattern.contains("PLACEHOLDER")) - scaRedirectUrlPattern.replace("PLACEHOLDER", paymentId) + // Without psu_make_payment_sca_redirect_url there is no page to send the PSU to, so the link is left out. + val scaRedirectUrl = getPropsValue("psu_make_payment_sca_redirect_url").toOption.filter(_.trim.nonEmpty).map { pattern => + if (pattern.contains("PLACEHOLDER")) + pattern.replace("PLACEHOLDER", paymentId) else - s"$scaRedirectUrlPattern/${paymentId}" + s"$pattern/$paymentId" + } InitiatePaymentResponseJson( transactionStatus = mapTransactionStatus(transactionRequest.status), paymentId = paymentId, _links = InitiatePaymentResponseLinks( - scaRedirect = LinkHrefJson(s"$scaRedirectUrl/$paymentId"), + scaRedirect = scaRedirectUrl.map(LinkHrefJson(_)), self = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/payments/sepa-credit-transfers/$paymentId"), status = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/payments/$paymentId/status"), scaStatus = LinkHrefJson(s"/${ConstantsBG.berlinGroupVersion1.apiShortVersion}/payments/$paymentId/authorisations/${paymentId}") diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index f82a6bccc7..59059e4a52 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2655,6 +2655,13 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def getDisabledEndpointOperationIds() : List[String] = APIUtil.getPropsValue("api_disabled_endpoints").getOrElse("").replace("[", "").replace("]", "").split(",").toList.filter(_.nonEmpty) + /** + * allow_cascade_delete (default false). The v4.0.0 /management/cascading/... endpoints are + * routed and documented only when this is true. It is read when the API version objects are + * initialised, so a change takes effect on restart. The Dynamic Entity cascade deletes, which + * back up to a ZZ_BAK_ entity first, are not affected. + */ + def cascadeDeleteEnabled: Boolean = getPropsAsBoolValue("allow_cascade_delete", false) def getEnabledVersions() : List[String] = APIUtil.getPropsValue("api_enabled_versions").getOrElse("").replace("[", "").replace("]", "").split(",").toList.filter(_.nonEmpty).map(_.trim) 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 0e30a86af3..73edfda8d2 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1690,10 +1690,22 @@ object ApiRole extends MdcLoggable{ } } + // Checked only by the /management/cascading endpoints, so not offered while allow_cascade_delete=false + // leaves those endpoints out. valueOf still resolves them, for entitlements granted earlier. + private lazy val cascadeDeleteRoleNames: Set[String] = Set[ApiRole]( + canDeleteTransactionCascade, + canDeleteAccountCascade, + canDeleteBankCascade, + canDeleteProductCascade, + canDeleteCustomerCascade + ).map(_.toString) + private lazy val offeredRoleNames: List[String] = + roles.map(_.toString).filter(name => APIUtil.cascadeDeleteEnabled || !cascadeDeleteRoleNames.contains(name)) + def availableRoles: List[String] = { import scala.jdk.CollectionConverters._ val dynamicRoles = dynamicApiRoles.keys().asScala.toList - dynamicRoles ::: roles.map(_.toString) + dynamicRoles ::: offeredRoleNames } } diff --git a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala index 1151eb49ca..f98a322752 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1057,6 +1057,35 @@ object Consent extends MdcLoggable { consentJwtBox.foreach(releaseVrpMandateArtefacts(consent, _)) } + /** + * The consent statuses after which an SCA challenge raised for the consent can no longer be used: + * a REVOKED consent is re-authenticated with a fresh challenge, and REJECTED and EXPIRED are terminal. + */ + val consentChallengeEndingStatuses: Set[ConsentStatus] = + Set(ConsentStatus.REVOKED, ConsentStatus.REJECTED, ConsentStatus.EXPIRED) + + /** + * Delete the OBP_CONSENT_CHALLENGE rows of a consent once none of them can be used any more: when + * one has been answered correctly, or when the consent reaches one of consentChallengeEndingStatuses. + * + * Nothing reads these rows after the answer -- the attempt counter only matters while a challenge + * is still open -- so the row is not an audit trail, it is a hashed one-time password waiting to + * be swept. Berlin Group consent challenges are left alone (their authorisation endpoints keep + * listing them), and so are transaction request challenges. + * + * Never fails the caller: by the time this runs the answer or the status change has happened, and + * a row left behind is logged rather than turned into an error for something that succeeded. + */ + def removeConsentChallenges(consentId: String): Unit = + code.transactionChallenge.Challenges.ChallengeProvider.vend + .deleteChallengesByConsentId(consentId, com.openbankproject.commons.model.enums.ChallengeType.OBP_CONSENT_CHALLENGE.toString) match { + case Failure(msg, ex, _) => + logger.error( + s"removeConsentChallenges: the challenges of consent $consentId could not be deleted: $msg" + + ex.map(e => s" (${e.getClass.getSimpleName}: ${e.getMessage})").getOrElse("")) + case _ => + } + /** * Release the artefacts a VRP mandate created, when the consent that owns them is revoked. * 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 7ad767f3a4..3fe6c392ba 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -598,6 +598,10 @@ object ErrorMessages { val AssetChainIdentityAlreadyExists = "OBP-30584: Another asset already has this chain identity. Please specify a different chain_scheme and chain_asset_id." val AssetDtiAlreadyExists = "OBP-30585: Another asset already has this DTI. Please specify a different value for dti." + // Rate limits (OBP-30624 .. OBP-30625) + val RateLimitScopeNotSupported = "OBP-30624: A rate limit applies to every call of the consumer, so it cannot name an api_name, api_version or bank_id yet. Leave them out." + val RateLimitNotUnique = "OBP-30625: The consumer has more than one rate limit, so it is not clear which one to change. Change one by its RATE_LIMITING_ID with PUT /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits/RATE_LIMITING_ID." + // Files (OBP-30586 .. OBP-30596) val FileNotFound = "OBP-30586: File not found. Please specify a valid value for FILE_ID." val InvalidFile = "OBP-30587: Invalid file." @@ -960,6 +964,8 @@ object ErrorMessages { val SigningBasketMemberNotFound = "OBP-35056: A payment named for the signing basket was not found. " val SigningBasketMemberStatusInvalid = "OBP-35057: A payment named for the signing basket is not waiting for SCA. " val SigningBasketConsentsNotSupported = "OBP-35058: Consents in a signing basket are not supported yet. Name payments only. " + val ChallengeAnswerersTooFew = "OBP-35060: Fewer users may answer the challenge than the account's REQUIRED_CHALLENGE_ANSWERS asks for. Grant a view with can_answer_transaction_request_challenge to more users, or lower REQUIRED_CHALLENGE_ANSWERS." + val ChallengeRecipientNotFound = "OBP-35059: There is no e-mail address or phone number to send the challenge to. Link a Customer with an e-mail address or phone number to the user, or have the user confirm their own e-mail address." val ConsentMyResourcesInvalid = "OBP-35042: The Consent's my_resources block is invalid. " val ConsentMyResourcesMissing = "OBP-35043: The Consent does not cover this personal resource. A consent user may use a personal (my) endpoint only if the Consent lists the resource in my_resources with the needed action. " val ConsentAccountAccessCannotBeGranted = "OBP-35041: The Consent's account access cannot be granted. The Consent has not been authorised; please retry the authorisation. " diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index fea65cb89e..d1e56b0b8b 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -651,7 +651,7 @@ object Glossary extends MdcLoggable { | |Returns the aggregated active rate limits for the specified hour, including which rate limit records contributed to the totals. | - |Rate limits are cached and queried at hour-level granularity for performance. All hours are interpreted in UTC for consistency across all servers. + |Rate limit records are cached and queried per hour for performance, and a request is held only to the records whose `from_date` and `to_date` enclose the time of the request. All hours are interpreted in UTC for consistency across all servers. | |### System Defaults | diff --git a/obp-api/src/main/scala/code/api/util/NewStyle.scala b/obp-api/src/main/scala/code/api/util/NewStyle.scala index e0f9842f7e..baef6e44b0 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3202,9 +3202,9 @@ object NewStyle extends MdcLoggable{ s"bank_id_pattern(${methodRouting.bankIdPattern.getOrElse("")})" ), callContext) } - def getCardAttributeById(cardAttributeId: String, callContext:Option[CallContext]) = + def getCardAttributeById(cardAttributeId: String, callContext:Option[CallContext], notFoundCode: Int = 400) = Connector.connector.vend.getCardAttributeById(cardAttributeId: String, callContext:Option[CallContext]) map { - i => (unboxFullOrFail(i._1, callContext, s"$CardAttributeNotFound Current CardAttributeId($cardAttributeId)"), i._2) + i => (unboxFullOrFail(i._1, callContext, s"$CardAttributeNotFound Current CardAttributeId($cardAttributeId)", notFoundCode), i._2) } @@ -3229,6 +3229,12 @@ object NewStyle extends MdcLoggable{ i => (connectorEmptyResponse(i._1, callContext), i._2) } } + def deleteCardAttribute(cardAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { + Connector.connector.vend.deleteCardAttribute(cardAttributeId, callContext) map { + i => (connectorEmptyResponse(i._1, callContext), i._2) + } + } + def getCardAttributesFromProvider( cardId: String, callContext: Option[CallContext] @@ -3652,34 +3658,40 @@ object NewStyle extends MdcLoggable{ */ def deleteDynamicEntity(bankId: Option[String], dynamicEntityId: String): Future[Box[Boolean]] = { validateBankId(bankId, None) - Future { - val result = for { - entity <- DynamicEntityProvider.connectorMethodProvider.vend.getById(bankId, dynamicEntityId) - deleteEntityResult <- DynamicEntityProvider.connectorMethodProvider.vend.delete(entity) - deleteEntitleMentResult <- if (deleteEntityResult) { - Entitlement.entitlement.vend.deleteDynamicEntityEntitlement(entity.entityName, entity.bankId) - } else { - Box !! false - } - } yield { - if (deleteEntitleMentResult) { - // The Role names carry no bank, so while an entity of this name is left in another space - // they are still its Roles and stay registered. - val nameStillUsed = DynamicEntityProvider.connectorMethodProvider.vend - .getDynamicEntities(None, returnBothBankAndSystemLevel = true) - .exists(_.entityName == entity.entityName) - if (!nameStillUsed) - DynamicEntityInfo.roleNames(entity.entityName, entity.bankId).foreach(ApiRole.removeDynamicApiRole(_)) - // Cascade row-level ACL rows for this entity (§7) — safety net for any rows not already - // cleaned up by per-row delete; no-op for non-row-level entities. - code.DynamicData.DynamicDataAccessProvider.provider.vend.deleteAllForEntity(entity.bankId, entity.entityName) - } - deleteEntitleMentResult + Future(deleteDynamicEntityNow(bankId, dynamicEntityId)) + } + + /** + * The delete of deleteDynamicEntity on the calling thread, for a caller that deletes inside its own + * database transaction (the bank cascade). + */ + def deleteDynamicEntityNow(bankId: Option[String], dynamicEntityId: String): Box[Boolean] = { + val result = for { + entity <- DynamicEntityProvider.connectorMethodProvider.vend.getById(bankId, dynamicEntityId) + deleteEntityResult <- DynamicEntityProvider.connectorMethodProvider.vend.delete(entity) + deleteEntitleMentResult <- if (deleteEntityResult) { + Entitlement.entitlement.vend.deleteDynamicEntityEntitlement(entity.entityName, entity.bankId) + } else { + Box !! false + } + } yield { + if (deleteEntitleMentResult) { + // The Role names carry no bank, so while an entity of this name is left in another space + // they are still its Roles and stay registered. + val nameStillUsed = DynamicEntityProvider.connectorMethodProvider.vend + .getDynamicEntities(None, returnBothBankAndSystemLevel = true) + .exists(_.entityName == entity.entityName) + if (!nameStillUsed) + DynamicEntityInfo.roleNames(entity.entityName, entity.bankId).foreach(ApiRole.removeDynamicApiRole(_)) + // Cascade row-level ACL rows for this entity (§7) — safety net for any rows not already + // cleaned up by per-row delete; no-op for non-row-level entities. + code.DynamicData.DynamicDataAccessProvider.provider.vend.deleteAllForEntity(entity.bankId, entity.entityName) } - // The entity's generated resource docs go with it: the provider's delete calls - // DynamicEntityHelper.forgetDefinitions, which drops the doc caches once the delete has committed. - result + deleteEntitleMentResult } + // The entity's generated resource docs go with it: the provider's delete calls + // DynamicEntityHelper.forgetDefinitions, which drops the doc caches once the delete has committed. + result } def getDynamicEntityById(bankId: Option[String], dynamicEntityId : String, callContext: Option[CallContext]): OBPReturnType[DynamicEntityT] = { diff --git a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala index 52b64c2afb..5f50f2e520 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -102,6 +102,13 @@ object RateLimitingJson { } object RateLimitingUtil extends MdcLoggable { + + /** The fields of a rate limit that name something narrower than the consumer, which nothing enforces yet. An empty + * value names nothing. */ + def rateLimitScopeFields(apiName: Option[String], apiVersion: Option[String], bankId: Option[String]): List[String] = + List("api_name" -> apiName, "api_version" -> apiVersion, "bank_id" -> bankId) + .collect { case (name, Some(value)) if value.trim.nonEmpty => name } + import code.api.util.RateLimitingPeriod._ /** State of a rate limiting counter from Redis */ @@ -125,10 +132,13 @@ object RateLimitingUtil extends MdcLoggable { * @param date The date to check active limits for * @return Future containing (aggregated CallLimit, List of rate_limiting_ids that contributed) */ - def getActiveRateLimitsWithIds(consumerId: String, date: Date): Future[(CallLimit, List[String])] = { + def getActiveRateLimitsWithIds(consumerId: String, date: Date, exact: Boolean = true): Future[(CallLimit, List[String])] = { def getActiveRateLimitings(consumerId: String): Future[List[RateLimiting]] = { useConsumerLimits match { - case true => RateLimitingDI.rateLimiting.vend.getActiveCallLimitsByConsumerIdAtDate(consumerId, date) + // The provider returns every row that touches the hour of `date`. `exact` keeps those whose dates enclose `date` + // itself (what a request is held to); without it the hour is the unit, as in the report for an hour. + case true => RateLimitingDI.rateLimiting.vend.getActiveCallLimitsByConsumerIdAtDate(consumerId, date).map( + _.filter(limit => !exact || (!limit.fromDate.after(date) && !limit.toDate.before(date)))) case false => Future(List.empty) } } diff --git a/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala b/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala index c26288867a..3a2ade1b8f 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala @@ -193,7 +193,8 @@ object RequestScopeConnection extends MdcLoggable { /** * Wrap a real Connection in a proxy that no-ops commit, rollback, and close. - * All other methods delegate to the real connection. + * All other methods delegate to the real connection, rollback(Savepoint) among them, so + * that a part of the request can still undo its own writes (DeletionUtil.databaseAtomicTask). * * This prevents Lift's per-DB.use lifecycle from committing or returning the * connection to the pool before the request transaction scope ends. @@ -205,7 +206,8 @@ object RequestScopeConnection extends MdcLoggable { new InvocationHandler { def invoke(proxy: Any, method: Method, args: Array[AnyRef]): AnyRef = method.getName match { - case "commit" | "rollback" | "close" => null + case "commit" | "close" => null + case "rollback" if args == null || args.isEmpty => null case _ => try { val result = diff --git a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala index 1047b39693..78bed1a85a 100644 --- a/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala +++ b/obp-api/src/main/scala/code/api/v3_1_0/Http4s310.scala @@ -2240,7 +2240,7 @@ object Http4s310 { EmptyBody, List(AuthenticatedUserIsRequired, UserHasMissingRoles, AllowedValuesAre, UnknownError), List(apiTagCard), - Some(List(canCreateCardsForBank)), + Some(List(canDeleteCardsForBank)), http4sPartialFunction = Some(deleteCardForBank) ) @@ -2666,6 +2666,9 @@ object Http4s310 { for { _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateRateLimits, Some(cc)) _ <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + // The consumer's one rate limit is changed; with several, which one is meant is not known. + sameScope <- RateLimitingDI.rateLimiting.vend.countConsumerCallLimits(consumerIdStr, None, None, None) + _ <- code.util.Helper.booleanToFuture(RateLimitNotUnique, 400, Some(cc)) { sameScope <= 1 } rateLimiting <- RateLimitingDI.rateLimiting.vend.createOrUpdateConsumerCallLimits( consumerIdStr, postJson.from_date, @@ -2700,6 +2703,9 @@ object Http4s310 { |Per Week |Per Month | + |This changes the Consumer's rate limit, or creates it when there is none. A Consumer given several rate limits + |with Create Rate Limits (v6.0.0) is refused with 400: change one of them by its RATE_LIMITING_ID instead. + | | |${userAuthenticationMessage(true)} | @@ -2707,7 +2713,7 @@ object Http4s310 { callLimitPostJson, callLimitPostJson, List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidConsumerId, - ConsumerNotFoundByConsumerId, UserHasMissingRoles, UpdateConsumerError, UnknownError), + ConsumerNotFoundByConsumerId, RateLimitNotUnique, UserHasMissingRoles, UpdateConsumerError, UnknownError), List(apiTagConsumer), Some(List(canUpdateRateLimits)), http4sPartialFunction = Some(callsLimit) diff --git a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala index ea25ba9bc8..eba457b2ee 100644 --- a/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala +++ b/obp-api/src/main/scala/code/api/v4_0_0/Http4s400.scala @@ -65,7 +65,7 @@ import code.DynamicEndpoint.DynamicEndpointSwagger import code.api.util.http4s.Http4sRequestAttributes.{EndpointHelpers, RequestOps} import code.api.util.http4s.{ResourceDocMatcher, ResourceDocMiddleware} import code.api.util.http4s.IdempotencyMiddleware -import code.api.util.{APIUtil, AttributeTypeDocs, CallContext, CustomJsonFormats, NewStyle} +import code.api.util.{APIUtil, AttributeTypeDocs, CallContext, CustomJsonFormats, NewStyle, RateLimitingUtil} import code.api.v4_0_0.JSONFactory400._ import code.DynamicData.DynamicData import code.api.util.migration.Migration @@ -406,10 +406,17 @@ object Http4s400 { for { _ <- NewStyle.function.handleEntitlementsAndScopes("", user.userId, List(canUpdateRateLimits), Some(cc)) _ <- NewStyle.function.getConsumerByConsumerId(consumerIdStr, Some(cc)) + scopeFields = RateLimitingUtil.rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) + _ <- code.util.Helper.booleanToFuture(s"$RateLimitScopeNotSupported Remove: ${scopeFields.mkString(", ")}", 400, Some(cc)) { + scopeFields.isEmpty + } + // The consumer's one rate limit is changed; with several, which one is meant is not known. + sameScope <- RateLimitingDI.rateLimiting.vend.countConsumerCallLimits(consumerIdStr, None, None, None) + _ <- code.util.Helper.booleanToFuture(RateLimitNotUnique, 400, Some(cc)) { sameScope <= 1 } rateLimiting <- RateLimitingDI.rateLimiting.vend.createOrUpdateConsumerCallLimits( consumerIdStr, postJson.from_date, postJson.to_date, - postJson.api_version, postJson.api_name, postJson.bank_id, + None, None, None, // rateLimitScopeFields has refused any value that is not blank Some(postJson.per_second_call_limit), Some(postJson.per_minute_call_limit), Some(postJson.per_hour_call_limit), @@ -439,6 +446,12 @@ object Http4s400 { |Per Week |Per Month | + |This changes the Consumer's rate limit, or creates it when there is none. A Consumer given several rate limits + |with Create Rate Limits (v6.0.0) is refused with 400: change one of them by its RATE_LIMITING_ID instead. + | + |A rate limit applies to every call of the Consumer: `api_name`, `api_version` and `bank_id` cannot be set yet, and + |a request that names one is refused with 400. An empty value is taken as not set. + | | |${userAuthenticationMessage(true)} | @@ -446,7 +459,7 @@ object Http4s400 { callLimitPostJsonV400, callLimitPostJsonV400, List(AuthenticatedUserIsRequired, InvalidJsonFormat, InvalidConsumerId, - ConsumerNotFoundByConsumerId, UserHasMissingRoles, UpdateConsumerError, UnknownError), + ConsumerNotFoundByConsumerId, RateLimitScopeNotSupported, RateLimitNotUnique, UserHasMissingRoles, UpdateConsumerError, UnknownError), List(apiTagConsumer, apiTagRateLimits), Some(List(canUpdateRateLimits)), http4sPartialFunction = Some(callsLimit) @@ -5386,18 +5399,29 @@ object Http4s400 { consent.userId == user.userId } status = code.consent.ConsentStatus.withName(consentJson.status) - updated <- APIUtil.getPropsAsBoolValue("consents.sca.enabled", true) match { - case true => + updated <- (refusalOf(consent, status), APIUtil.getPropsAsBoolValue("consents.sca.enabled", true)) match { + case (None, true) => Future.successful(consent) - case false => + case (refusal, _) => Future( - code.consent.Consents.consentProvider.vend.updateConsentStatus(consentId, status) + code.consent.Consents.consentProvider.vend.updateConsentStatus(consentId, refusal.getOrElse(status)) ) map { box => connectorEmptyResponse(box, Some(cc)) } } } yield code.api.v3_1_0.ConsentJsonV310(updated.consentId, updated.jsonWebToken, updated.status) } } + // Refusing a consent its PSU has not authorised yet grants nothing, so it needs no SCA. The refusal is stored + // under the consent's own standard's name for it: Berlin Group reads `rejected`, the others REJECTED. + private val awaitingAuthorisation = Set(ConsentStatus.INITIATED, ConsentStatus.received, + ConsentStatus.AWAITINGAUTHORISATION).map(_.toString) + private def refusalOf(consent: code.consent.ConsentTrait, requested: ConsentStatus.Value): Option[ConsentStatus.Value] = + if ((requested == ConsentStatus.REJECTED || requested == ConsentStatus.rejected) && + awaitingAuthorisation.contains(consent.status)) + Some(if (consent.apiStandard == code.api.berlin.group.ConstantsBG.berlinGroupVersion1.apiStandard) + ConsentStatus.rejected else ConsentStatus.REJECTED) + else None + lazy val addConsentUser: Http4sRoute = Http4sRoute { case req @ PUT -> `prefixPath` / "banks" / _ / "consents" / consentId / "user-update-request" => EndpointHelpers.withUserAndBankAndBody[PutConsentUserJsonV400, Any](req) { (_, _, putJson, cc) => @@ -5578,6 +5602,10 @@ object Http4s400 { | |The consent must belong to the logged-in user; another user's consent returns 404 $ConsentNotFound | + |A consent its user has not authorised yet (INITIATED, received or AWAITINGAUTHORISATION) can be refused with + |the status REJECTED without SCA; a Berlin Group consent is stored as rejected. While consents.sca.enabled is + |true, any other change leaves the status as it is. + | |Each Consent has one of the following states: ${ConsentStatus.values.toList.sorted .mkString(", ")}. | @@ -7975,6 +8003,8 @@ object Http4s400 { "Delete Bank Cascade", s"""Delete a Bank Cascade specified by BANK_ID. | + |The bank's dynamic entity definitions and all their records go with it, whoever owns the records. Unlike + |the cascade delete of a single dynamic entity, no `ZZ_BAK_` copy is made first. | |${userAuthenticationMessage(true)} | @@ -8027,6 +8057,11 @@ object Http4s400 { http4sPartialFunction = Some(deleteCustomerCascade) ) + // Cascade deletes are documented, and routed, only when allow_cascade_delete=true. Their docs come + // out here and their routes out of routesInOrder, both by cascadeDeleteRoutes. + if (!cascadeDeleteEnabled) + staticResourceDocs --= staticResourceDocs.filter(_.http4sPartialFunction.exists(cascadeDeleteRoutes.contains)) + staticResourceDocs += ResourceDoc( implementedInApiVersion, nameOf(deleteSystemLevelEndpointTag), "DELETE", "/management/endpoints/OPERATION_ID/tags/ENDPOINT_TAG_ID", @@ -11253,6 +11288,19 @@ object Http4s400 { // ─── allRoutes ──────────────────────────────────────────────────────────── + // Read once: whether the cascade-delete endpoints exist is fixed when this object initialises. + private lazy val cascadeDeleteEnabled: Boolean = APIUtil.cascadeDeleteEnabled + + // Left out of routesInOrder, and their ResourceDocs out of staticResourceDocs, unless + // allow_cascade_delete=true. + private lazy val cascadeDeleteRoutes: Set[Http4sHandler] = Set( + deleteTransactionCascade, + deleteAccountCascade, + deleteBankCascade, + deleteProductCascade, + deleteCustomerCascade + ) + // The routes in the order they are tried. ResourceDocMiddleware selects the doc of the first // route that serves a request, so it is given the docs in this same order. lazy val routesInOrder: List[Http4sHandler] = List( @@ -11529,7 +11577,7 @@ object Http4s400 { createUserWithRoles, createUserWithAccountAccess, createUserInvitation - ) + ).filter(route => cascadeDeleteEnabled || !cascadeDeleteRoutes.contains(route)) lazy val orderedResourceDocs: ArrayBuffer[ResourceDoc] = ResourceDocMatcher.orderByRoutes(resourceDocs, routesInOrder) diff --git a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 835fdfafd8..5e233fec71 100644 --- a/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala +++ b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala @@ -4590,6 +4590,12 @@ object Http4s510 { _ <- Helper.booleanToFuture(s"$InvalidChallengeAnswer", 403, Some(cc)) { challenge.scaStatus.contains(StrongCustomerAuthenticationStatus.finalised) } + // The answered challenge is spent (validateChallenge lets one correct answer through, + // once), and any other challenge the PSU started on this consent is superseded by it, so + // they are deleted now rather than left in the table for good. Done here and not after + // the consent is AUTHORISED: if the steps below refuse the request, the PSU has to start + // a new challenge either way. + _ <- Future(Consent.removeConsentChallenges(consentId)) // Bind the consented permissions to the PSU-selected accounts, replacing the // (bank_id=null, account_id=null) dead views createUKConsentJWT wrote at consent // creation time. diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 6a84330f37..f0a30b37ef 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -110,7 +110,7 @@ import code.util.Helper.SILENCE_IS_GOLDEN import com.openbankproject.commons.dto.GetProductsParam import code.model.ModeratedTransaction import com.openbankproject.commons.model.{CreditLimit, CreditRating, CustomerFaceImage} -import net.liftweb.common.{Empty, Failure} +import net.liftweb.common.{Box, Empty, Failure} import scala.util.Random import code.metrics.APIMetrics @@ -5057,37 +5057,46 @@ object Http4s600 { box <- code.abacrule.AbacRuleEngine.validateRuleCodeAsync(validateJson.rule_code) } yield box match { case Full(msg) => ValidateAbacRuleSuccessJsonV600(valid = true, message = msg): Any - case Failure(errorMsg, _, _) => - val cleanError = errorMsg.replace("Invalid ABAC rule code: ", "") - .replace("Failed to compile ABAC rule: ", "") - val (obpMsg, errorType) = - if (cleanError.toLowerCase.contains("too permissive") || cleanError.toLowerCase.contains("tautological")) { - val ec = if (cleanError.toLowerCase.contains("statistical")) - AbacRuleStatisticallyTooPermissive else AbacRuleTooPermissive - (ec, "PermissivenessError") - } else if (cleanError.toLowerCase.contains("type mismatch") || - (cleanError.toLowerCase.contains("found:") && cleanError.toLowerCase.contains("required: boolean"))) - (AbacRuleTypeMismatch, "TypeError") - else if (cleanError.toLowerCase.contains("syntax") || cleanError.toLowerCase.contains("parse")) - (AbacRuleSyntaxError, "SyntaxError") - else if (cleanError.toLowerCase.contains("not found") || cleanError.toLowerCase.contains("not a member")) - (AbacRuleFieldReferenceError, "FieldReferenceError") - else if (cleanError.toLowerCase.contains("compilation failed") || - cleanError.toLowerCase.contains("reflective compilation has failed")) - (AbacRuleCompilationFailed, "CompilationError") - else (AbacRuleValidationFailed, "ValidationError") + case failed => + val (obpMsg, errorType, error) = classifyAbacRuleCodeFailure(failed) ValidateAbacRuleFailureJsonV600( - valid = false, error = cleanError, message = obpMsg, + valid = false, error = error, message = obpMsg, details = ValidateAbacRuleErrorDetailsJsonV600(error_type = errorType)) - case _ => - ValidateAbacRuleFailureJsonV600( - valid = false, error = "Unknown validation error", - message = AbacRuleValidationFailed, - details = ValidateAbacRuleErrorDetailsJsonV600(error_type = "UnknownError")) } } } + /** The OBP error message, error type and engine message of a failed ABAC rule code validation. */ + private def classifyAbacRuleCodeFailure(validation: Box[String]): (String, String, String) = validation match { + case Failure(errorMsg, _, _) => + val cleanError = errorMsg.replace("Invalid ABAC rule code: ", "") + .replace("Failed to compile ABAC rule: ", "") + val (obpMsg, errorType) = + if (cleanError.toLowerCase.contains("too permissive") || cleanError.toLowerCase.contains("tautological")) { + val ec = if (cleanError.toLowerCase.contains("statistical")) + AbacRuleStatisticallyTooPermissive else AbacRuleTooPermissive + (ec, "PermissivenessError") + } else if (cleanError.toLowerCase.contains("type mismatch") || + (cleanError.toLowerCase.contains("found:") && cleanError.toLowerCase.contains("required: boolean"))) + (AbacRuleTypeMismatch, "TypeError") + else if (cleanError.toLowerCase.contains("syntax") || cleanError.toLowerCase.contains("parse")) + (AbacRuleSyntaxError, "SyntaxError") + else if (cleanError.toLowerCase.contains("not found") || cleanError.toLowerCase.contains("not a member")) + (AbacRuleFieldReferenceError, "FieldReferenceError") + else if (cleanError.toLowerCase.contains("compilation failed") || + cleanError.toLowerCase.contains("reflective compilation has failed")) + (AbacRuleCompilationFailed, "CompilationError") + else (AbacRuleValidationFailed, "ValidationError") + (obpMsg, errorType, cleanError) + case _ => (AbacRuleValidationFailed, "UnknownError", "Unknown validation error") + } + + /** Fails with the same OBP error the validate endpoint reports, followed by the engine's message. */ + private def failOnInvalidAbacRuleCode(validation: Box[String], cc: CallContext): Future[Box[Unit]] = { + val (obpMsg, _, error) = classifyAbacRuleCodeFailure(validation) + Helper.booleanToFuture(s"$obpMsg $error", 400, Some(cc)) { validation.isDefined } + } + lazy val executeAbacRule: Http4sRoute = Http4sRoute { case req @ POST -> `prefixPath` / "management" / "abac-rules" / ruleId / "execute" => EndpointHelpers.executeAndRespond(req) { implicit cc => @@ -5988,8 +5997,8 @@ object Http4s600 { } _ <- Helper.booleanToFuture("Rule name must not be empty", cc = Some(cc)) { createJson.rule_name.nonEmpty } _ <- Helper.booleanToFuture("Rule code must not be empty", cc = Some(cc)) { createJson.rule_code.nonEmpty } - _ <- AbacRuleEngine.validateRuleCodeAsync(createJson.rule_code) - .map(unboxFullOrFail(_, Some(cc), "Invalid ABAC rule code", 400)) + validation <- AbacRuleEngine.validateRuleCodeAsync(createJson.rule_code) + _ <- failOnInvalidAbacRuleCode(validation, cc) // Maker/checker: a managed instance queues the rule for a second user's approval (202) intercepted <- Future(MakerChecker.intercept( com.openbankproject.commons.model.enums.DynamicChangeRequestTargetType.ABAC_RULE, @@ -6050,8 +6059,8 @@ object Http4s600 { updateJson <- NewStyle.function.tryons(InvalidJsonFormat, 400, Some(cc)) { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[UpdateAbacRuleJsonV600] } - _ <- AbacRuleEngine.validateRuleCodeAsync(updateJson.rule_code) - .map(unboxFullOrFail(_, Some(cc), "Invalid ABAC rule code", 400)) + validation <- AbacRuleEngine.validateRuleCodeAsync(updateJson.rule_code) + _ <- failOnInvalidAbacRuleCode(validation, cc) _ <- Future(MappedAbacRuleProvider.getAbacRuleById(ruleId)) .map(unboxFullOrFail(_, Some(cc), s"ABAC Rule not found with ID: $ruleId", 404)) intercepted <- Future(MakerChecker.intercept( @@ -6215,9 +6224,13 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CallLimitPostJsonV600] } _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + scopeFields = RateLimitingUtil.rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) + _ <- Helper.booleanToFuture(s"$RateLimitScopeNotSupported Remove: ${scopeFields.mkString(", ")}", 400, Some(cc)) { + scopeFields.isEmpty + } rateLimitingBox <- RateLimitingDI.rateLimiting.vend.createConsumerCallLimits( consumerId, postJson.from_date, postJson.to_date, - postJson.api_version, postJson.api_name, postJson.bank_id, + None, None, None, // rateLimitScopeFields has refused any value that is not blank Some(postJson.per_second_call_limit), Some(postJson.per_minute_call_limit), Some(postJson.per_hour_call_limit), Some(postJson.per_day_call_limit), Some(postJson.per_week_call_limit), Some(postJson.per_month_call_limit)) @@ -6237,9 +6250,16 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CallLimitPostJsonV400] } _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + scopeFields = RateLimitingUtil.rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) + _ <- Helper.booleanToFuture(s"$RateLimitScopeNotSupported Remove: ${scopeFields.mkString(", ")}", 400, Some(cc)) { + scopeFields.isEmpty + } + existing <- RateLimitingDI.rateLimiting.vend.getByRateLimitingId(rateLimitingId) _ <- RateLimitingDI.rateLimiting.vend.updateConsumerCallLimits( rateLimitingId, postJson.from_date, postJson.to_date, - postJson.api_version, postJson.api_name, postJson.bank_id, + // rateLimitScopeFields has refused any value that is not blank; the bank_id of a record an + // API Product Subscription wrote is kept. + None, None, existing.toOption.flatMap(_.bankId), Some(postJson.per_second_call_limit), Some(postJson.per_minute_call_limit), Some(postJson.per_hour_call_limit), Some(postJson.per_day_call_limit), Some(postJson.per_week_call_limit), Some(postJson.per_month_call_limit)) @@ -6288,7 +6308,7 @@ object Http4s600 { val ldt = java.time.LocalDateTime.parse(dateWithHourString, fmt) java.util.Date.from(ldt.atZone(java.time.ZoneOffset.UTC).toInstant()) } - (rateLimit, ids) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumerId, date) + (rateLimit, ids) <- RateLimitingUtil.getActiveRateLimitsWithIds(consumerId, date, exact = false) } yield JSONFactory600.createActiveRateLimitsJsonV600FromCallLimit(rateLimit, ids, date) } } @@ -14795,8 +14815,11 @@ object Http4s600 { | |A consumer with no records at all gets the system defaults (`rate_limiting_per_*` props). | + |A record applies to every call of the consumer from `from_date` to `to_date`. `api_name`, `api_version` and `bank_id` cannot be set yet: a request that names one is refused with 400, and an empty value is stored as not set. + | |A record created by an API Product Subscription is managed by that subscription: it is rewritten on the - |subscription's next status change and removed when the subscription is cancelled. + |subscription's next status change and removed when the subscription is cancelled. Such a record shows the + |subscription's `bank_id` for reference; it too applies to every call of the consumer. | |See ${Glossary.getGlossaryItemLink("Rate Limiting")} for details. | @@ -14810,6 +14833,7 @@ object Http4s600 { InvalidJsonFormat, InvalidConsumerId, ConsumerNotFoundByConsumerId, + RateLimitScopeNotSupported, UserHasMissingRoles, UnknownError ), @@ -14843,8 +14867,11 @@ object Http4s600 { | |A consumer with no records at all gets the system defaults (`rate_limiting_per_*` props). | + |A record applies to every call of the consumer from `from_date` to `to_date`. `api_name`, `api_version` and `bank_id` cannot be set yet: a request that names one is refused with 400, and an empty value is stored as not set. + | |A record created by an API Product Subscription is managed by that subscription: it is rewritten on the - |subscription's next status change and removed when the subscription is cancelled. + |subscription's next status change and removed when the subscription is cancelled. Such a record shows the + |subscription's `bank_id` for reference; it too applies to every call of the consumer. | |See ${Glossary.getGlossaryItemLink("Rate Limiting")} for details. | @@ -14858,6 +14885,7 @@ object Http4s600 { InvalidJsonFormat, InvalidConsumerId, ConsumerNotFoundByConsumerId, + RateLimitScopeNotSupported, UserHasMissingRoles, UpdateConsumerError, UnknownError 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 3b04f334a7..aa29f2d9e8 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 @@ -6794,6 +6794,39 @@ object Http4s700 { http4sPartialFunction = Some(deleteApiProductSubscriptionAttribute) ).disableAutoValidateRoles() + // Route: DELETE /obp/v7.0.0/management/banks/BANK_ID/cards/CARD_ID/attributes/CARD_ATTRIBUTE_ID + val deleteCardAttribute: Http4sRoute = Http4sRoute { + case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "cards" / cardId / "attributes" / attributeId => + EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => + for { + (attribute, _) <- NewStyle.function.getCardAttributeById(attributeId, Some(cc), 404) + _ <- Helper.booleanToFuture(s"$CardAttributeNotFound The attribute does not belong to CARD_ID($cardId) at BANK_ID(${bank.bankId.value})", 404, Some(cc)) { + attribute.cardId.contains(cardId) && attribute.bankId.contains(bank.bankId) + } + _ <- NewStyle.function.deleteCardAttribute(attributeId, Some(cc)) + } yield () + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteCardAttribute), + "DELETE", + "/management/banks/BANK_ID/cards/CARD_ID/attributes/CARD_ATTRIBUTE_ID", + "Delete Card Attribute", + s"""Delete an attribute of a Card at the bank. + | + |The attribute must belong to the card CARD_ID at BANK_ID. Deleting a card or its account deletes the card's attributes as well. + | + |${userAuthenticationMessage(true)}""".stripMargin, + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, CardAttributeNotFound, UnknownError), + apiTagCard :: apiTagCardAttribute :: apiTagAttribute :: Nil, + Some(List(ApiRole.canDeleteCardsForBank)), + http4sPartialFunction = Some(deleteCardAttribute) + ) + // ═══════════════════════════════════════════════════════════════════════════ // Maker/checker for dynamic code: Dynamic Change Requests // Design: docs/MAKER_CHECKER_DYNAMIC_CODE_DESIGN.md. Approval is system level (no bank endpoints). diff --git a/obp-api/src/main/scala/code/bankconnectors/Connector.scala b/obp-api/src/main/scala/code/bankconnectors/Connector.scala index 342e36d040..68f1b8a071 100644 --- a/obp-api/src/main/scala/code/bankconnectors/Connector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/Connector.scala @@ -1598,6 +1598,8 @@ trait Connector extends MdcLoggable { ): OBPReturnType[Box[CardAttribute]] = Future{(Failure(setUnimplementedError(nameOf(createOrUpdateCardAttribute _))), callContext)} def getCardAttributeById(cardAttributeId: String, callContext:Option[CallContext]): OBPReturnType[Box[CardAttribute]] = Future{(Failure(setUnimplementedError(nameOf(getCardAttributeById _))), callContext)} + + def deleteCardAttribute(cardAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Box[Boolean]] = Future{(Failure(setUnimplementedError(nameOf(deleteCardAttribute _))), callContext)} def getCardAttributesFromProvider(cardId: String, callContext: Option[CallContext]): OBPReturnType[Box[List[CardAttribute]]] = Future{(Failure(setUnimplementedError(nameOf(getCardAttributesFromProvider _))), callContext)} diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index d8906171b8..e8d68b4016 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -333,9 +333,9 @@ object LocalMappedConnector extends Connector with MdcLoggable { challengeType = OBP_TRANSACTION_REQUEST_CHALLENGE.toString, callContext ) - challenge.map(_.challengeId).toList + challenge.map(_.challengeId) } - (Full(challenges.flatten), callContext) + challengesOrFailure(challenges, callContext) } override def createChallengesC2( @@ -362,9 +362,9 @@ object LocalMappedConnector extends Connector with MdcLoggable { challengeType = challengeType.toString, callContext ) - challengeId.toList + challengeId } - (Full(challenges.flatten), callContext) + challengesOrFailure(challenges, callContext) } override def createChallengesC3( @@ -392,11 +392,38 @@ object LocalMappedConnector extends Connector with MdcLoggable { challengeType = challengeType.toString, callContext ) - challengeId.toList + challengeId } - (Full(challenges.flatten), callContext) + challengesOrFailure(challenges, callContext) } + /** + * The challenges of the users who could be sent one, or the first failure when no one could. A caller that needs + * a number of answers checks how many challenges it got. + */ + private def challengesOrFailure[T](results: List[Box[T]], callContext: Option[CallContext]): (Box[List[T]], Option[CallContext]) = { + val created = results.flatMap(_.toList) + results.collectFirst { case failure: Failure => failure } match { + case Some(failure) if created.isEmpty => (failure, callContext) + case Some(failure) => + logger.warn(s"challengesOrFailure says: ${results.size - created.size} of ${results.size} users could not be sent a challenge: ${failure.msg}") + (Full(created), callContext) + case None => (Full(created), callContext) + } + } + + /** The values that hold an address: a customer's e-mail or mobile number may be blank or NULL. */ + private def nonBlank(values: List[String]): List[String] = + values.flatMap(value => Option(value).map(_.trim).filter(_.nonEmpty)) + + /** The user's own address, when the user signed up on this instance and has confirmed it. */ + private def validatedOwnEmail(userId: String): Box[String] = + for { + user <- Users.users.vend.getUserByUserId(userId) + authUser <- AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) + if authUser.validated.get + } yield authUser.email.get + private def createChallengeInternal( userId: String, transactionRequestId: String, @@ -432,52 +459,66 @@ object LocalMappedConnector extends Connector with MdcLoggable { case Some(StrongCustomerAuthentication.DUMMY) => createHashedPassword("123") case Some(StrongCustomerAuthentication.EMAIL) => - val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() - val hashedPassword = createHashedPassword(challengeAnswer) - APIUtil.getEmailsByUserId(userId) map { - pair => - val emailContent = CommonsEmailWrapper.EmailContent( + // The customers linked to the user; else the user's own address, once the user has confirmed it. + val linkedCustomerEmails = nonBlank(APIUtil.getEmailsByUserId(userId).map(_._2)).distinct + val recipients = + if (linkedCustomerEmails.nonEmpty) linkedCustomerEmails + else nonBlank(validatedOwnEmail(userId).toList) + if (recipients.isEmpty) { + (Failure(ChallengeRecipientNotFound), callContext) + } else { + val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() + // sendTextEmail answers Full only once the mail has been handed to the SMTP server. + val sent = recipients forall { to => + CommonsEmailWrapper.sendTextEmail(CommonsEmailWrapper.EmailContent( from = mailUsersUserinfoSenderAddress, - to = List(pair._2), + to = List(to), subject = "Challenge", textContent = Some(s"Your OTP challenge : ${challengeAnswer}") - ) - CommonsEmailWrapper.sendTextEmail(emailContent) + )).isDefined + } + // The challenge is saved once its passcode has been sent, so a failed send leaves no challenge no one can answer. + if (sent) createHashedPassword(challengeAnswer) else (Failure(EmailSendingFailed), callContext) } - hashedPassword case Some(StrongCustomerAuthentication.SMS) | Some(StrongCustomerAuthentication.SMS_OTP) => - val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() - logger.debug(s"${scaMethod.toString} challengeAnswer is $challengeAnswer") - val hashedPassword = createHashedPassword(challengeAnswer) - val sendingResult: Seq[Box[Boolean]] = APIUtil.getPhoneNumbersByUserId(userId) map { - tuple => - for { - smsProviderApiKey <- APIUtil.getPropsValue("sca_phone_api_key") ?~! s"$MissingPropsValueAtThisInstance sca_phone_api_key" - smsProviderApiSecret <- APIUtil.getPropsValue("sca_phone_api_secret") ?~! s"$MissingPropsValueAtThisInstance sca_phone_api_secret" - scaPhoneApiId <- APIUtil.getPropsValue("sca_phone_api_id") ?~! s"$MissingPropsValueAtThisInstance sca_phone_api_id" - client = Twilio.init(smsProviderApiKey, smsProviderApiSecret) //TODO, move this to other place, we only need to init it once. - phoneNumber = tuple._2 - messageText = s"Your consent challenge : ${challengeAnswer}"; - message: Message <- tryo {Message.creator( - new PhoneNumber(phoneNumber), - scaPhoneApiId, - messageText).create()} - - isSuccess <- tryo {message.getErrorMessage == null} - - _ = logger.debug(s"createChallengeInternal.send message to $phoneNumber, detail is $message") - - failMsg = if (message.getErrorMessage ==null) - s"$SmsServerNotResponding: $phoneNumber. Or Please to use EMAIL first. ${message.getErrorMessage}" - else - s"$SmsServerNotResponding: $phoneNumber. Or Please to use EMAIL first." - - _ <- Helper.booleanToBox(isSuccess, failMsg) - } yield true + val phoneNumbers = nonBlank(APIUtil.getPhoneNumbersByUserId(userId).map(_._2)).distinct + if (phoneNumbers.isEmpty) { + // No linked customer with a phone number: there is no one to send the passcode to. + (Failure(ChallengeRecipientNotFound), callContext) + } else { + val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() + logger.debug(s"${scaMethod.toString} challengeAnswer is $challengeAnswer") + // The caller may not be the owner of the phone number, so the number stays in the log. + val sendFailed = s"${SmsServerNotResponding.stripSuffix(":")}. Please use EMAIL instead." + val sendingResult: Seq[Box[Boolean]] = phoneNumbers map { + phoneNumber => + for { + smsProviderApiKey <- APIUtil.getPropsValue("sca_phone_api_key") ?~! s"$MissingPropsValueAtThisInstance sca_phone_api_key" + smsProviderApiSecret <- APIUtil.getPropsValue("sca_phone_api_secret") ?~! s"$MissingPropsValueAtThisInstance sca_phone_api_secret" + scaPhoneApiId <- APIUtil.getPropsValue("sca_phone_api_id") ?~! s"$MissingPropsValueAtThisInstance sca_phone_api_id" + client = Twilio.init(smsProviderApiKey, smsProviderApiSecret) //TODO, move this to other place, we only need to init it once. + messageText = s"Your consent challenge : ${challengeAnswer}"; + message: Message <- tryo {Message.creator( + new PhoneNumber(phoneNumber), + scaPhoneApiId, + messageText).create()} match { + case Full(message) => Full(message) + case failure => + logger.warn(s"createChallengeInternal could not send the passcode to $phoneNumber: $failure") + Failure(sendFailed) + } + + _ = logger.debug(s"createChallengeInternal.send message to $phoneNumber, detail is $message") + + _ = if (message.getErrorMessage != null) + logger.warn(s"createChallengeInternal could not send the passcode to $phoneNumber: ${message.getErrorMessage}") + _ <- Helper.booleanToBox(message.getErrorMessage == null, sendFailed) + } yield true + } + val errorMessages = sendingResult.collect { case failure: Failure => failure.msg } + // As with EMAIL, the challenge is saved once its passcode has been sent. + if (errorMessages.isEmpty) createHashedPassword(challengeAnswer) else (Failure(errorMessages.toSet.mkString(" <- ")), callContext) } - val errorMessage = sendingResult.filter(_.isInstanceOf[Failure]).map(_.asInstanceOf[Failure].msg) - - if (sendingResult.forall(_ == Full(true))) hashedPassword else (Failure(errorMessage.toSet.mkString(" <- ")), callContext) case _ => // All versions which precede v4.0.0 i.e. to keep backward compatibility createHashedPassword("123") } @@ -672,7 +713,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { */ override def getBankAccountsForUserLegacy(provider: String, username:String, callContext: Option[CallContext]): Box[(List[InboundAccount], Option[CallContext])] = { //1st: get the accounts from userAuthContext - val viewsToGenerate = List(SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID,SYSTEM_OWNER_VIEW_ID, SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) //TODO, so far only set the `owner` view, later need to simulate other views. + val viewsToGenerate = List(SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID,SYSTEM_OWNER_VIEW_ID, SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) //TODO, so far only set the `owner` view, later need to simulate other views. val user = Users.users.vend.getUserByProviderId(provider, username).getOrElse(throw new RuntimeException(s"$RefreshUserError at getBankAccountsForUserLegacy($username, ${callContext})")) val userId = user.userId tryo{net.liftweb.common.Logger(this.getClass).debug(s"getBankAccountsForUser.user says: provider($provider), username($username)")} @@ -2020,6 +2061,11 @@ object LocalMappedConnector extends Connector with MdcLoggable { } } + override def deleteCardAttribute(cardAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Box[Boolean]] = + CardAttributeX.cardAttributeProvider.vend.deleteCardAttribute(cardAttributeId) map { + (_, callContext) + } + override def createOrUpdateCardAttribute( bankId: Option[BankId], cardId: Option[String], @@ -5215,6 +5261,12 @@ object LocalMappedConnector extends Connector with MdcLoggable { } } + // How many of those users have to answer, as getUsersForChallenges reads it. + def requiredChallengeAnswers(bankId: BankId, accountId: AccountId): Future[Int] = + Connector.connector.vend.getAccountAttributesByAccount(bankId, accountId, None) map { + _._1.toList.flatten.find(_.name == "REQUIRED_CHALLENGE_ANSWERS").map(_.value).getOrElse("1").toInt + } + for { // The challenge belongs to the person the payment is for, who is not the caller when // an agent started it. See paymentChallengeRecipient. @@ -5223,6 +5275,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { } //if challenge necessary, create a new one users <- getUsersForChallenges(fromAccount.bankId, fromAccount.accountId, challengeRecipient) + requiredAnswers <- requiredChallengeAnswers(fromAccount.bankId, fromAccount.accountId) //now we support multiple challenges. We can support multiple people to answer the challenges. //So here we return the challengeIds. (challenges, callContext) <- Connector.connector.vend.createChallengesC2( @@ -5234,8 +5287,21 @@ object LocalMappedConnector extends Connector with MdcLoggable { consentId = None, // Note: consentId and transactionRequestId are exclusive here. authenticationMethodId = None, callContext = callContext - ) map { i => - (unboxFullOrFail(i._1, callContext, s"$InvalidConnectorResponseForCreateChallenge ", 400), i._2) + ) flatMap { + case (Full(challenges), callContext) if challenges.length >= requiredAnswers => Future.successful((challenges, callContext)) + case (result, callContext) => + // With fewer challenges than the answers it needs, the request can never be answered. + val mayAnswer = users.toList.flatten.size + val failure: Box[List[ChallengeTrait]] = result match { + case _ if mayAnswer < requiredAnswers => + Failure(s"$ChallengeAnswerersTooFew $mayAnswer may answer, $requiredAnswers answers are required.") + case Full(challenges) => + Failure(s"$ChallengeRecipientNotFound ${challenges.length} of the $requiredAnswers users who have to answer could be sent a challenge.") + case other => other + } + saveTransactionRequestStatusImpl(transactionRequest.id, TransactionRequestStatus.FAILED.toString, callContext) map { _ => + (unboxFullOrFail(failure, callContext, s"$InvalidConnectorResponseForCreateChallenge ", 400), callContext) + } } //NOTE:this is only for Backward compatibility, now we use the MappedExpectedChallengeAnswer tables instead of the single field in TransactionRequest. diff --git a/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala b/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala index 5c0e2d7254..c46588b826 100644 --- a/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala +++ b/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala @@ -66,5 +66,9 @@ class MappedCardAttribute extends CardAttribute with LongKeyedMapper[MappedCardA object MappedCardAttribute extends MappedCardAttribute with LongKeyedMetaMapper[MappedCardAttribute] { override def dbIndexes: List[BaseIndex[MappedCardAttribute]] = Index(mCardId) :: Index(mCardAttributeId) :: super.dbIndexes + + /** Delete the attributes that the bank holds for the given cards, for when the cards themselves are deleted. */ + def deleteForCards(bankId: String, cardIds: List[String]): Boolean = + cardIds.isEmpty || bulkDelete_!!(By(mBankId, bankId), ByList(mCardId, cardIds)) } diff --git a/obp-api/src/main/scala/code/cards/MappedPhisicalCard.scala b/obp-api/src/main/scala/code/cards/MappedPhisicalCard.scala index 0b22e69b54..05050a6d57 100644 --- a/obp-api/src/main/scala/code/cards/MappedPhisicalCard.scala +++ b/obp-api/src/main/scala/code/cards/MappedPhisicalCard.scala @@ -30,6 +30,7 @@ package code.cards import java.util.{Date, UUID} import code.api.util.ErrorMessages.BankAccountNotFound +import code.cardattribute.MappedCardAttribute import code.api.util._ import code.model.dataAccess.MappedBankAccount import code.model._ @@ -315,7 +316,9 @@ object MappedPhysicalCardProvider extends PhysicalCardProvider { MappedPhysicalCard.find( By(MappedPhysicalCard.mBankId, bankId.value), By(MappedPhysicalCard.mCardId, cardId), - ).map(_.delete_!) + ).map { card => + MappedCardAttribute.deleteForCards(bankId.value, List(cardId)) && card.delete_! + } } } diff --git a/obp-api/src/main/scala/code/consent/MappedConsent.scala b/obp-api/src/main/scala/code/consent/MappedConsent.scala index d6a87b18f3..2ef02ecace 100644 --- a/obp-api/src/main/scala/code/consent/MappedConsent.scala +++ b/obp-api/src/main/scala/code/consent/MappedConsent.scala @@ -61,11 +61,14 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo MappedConsent.find(By(MappedConsent.mConsentId, consentId)) match { case Full(consent) => Consent.expireAllPreviousValidBerlinGroupConsents(consent, status) - tryo(consent + val updated = tryo(consent .mStatus(status.toString) .mLastActionDate(now) //maybe not right, but for the create we use the `now`, we need to update it later. .saveMe() ) + if (updated.isDefined && Consent.consentChallengeEndingStatuses.contains(status)) + Consent.removeConsentChallenges(consentId) + updated case Empty => Empty ?~! ErrorMessages.ConsentNotFound case Failure(msg, _, _) => @@ -423,6 +426,7 @@ object MappedConsentProvider extends ConsentProvider with code.util.Helper.MdcLo ex.map(e => s" (${e.getClass.getSimpleName}: ${e.getMessage})").getOrElse("")) case _ => } + Consent.removeConsentChallenges(consentId) MappedConsent.find(By(MappedConsent.mConsentId, consentId)) } else Failure(ErrorMessages.ConsentAlreadyRevoked) 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 503bbf6b79..dc37353f2e 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -1183,47 +1183,48 @@ def restoreSomeSessions(): Unit = { view } - //3rd: if the ids are not change, but views are changed, we still need compare the view for each account: - if(cbsRemovedBankAccountIds.equals(csbNewBankAccountIds)) { - for { - bankAccountId <- obpBankAccountIds - // we can not get the views from the `viewDefinition` table, because we can not delete system views at all. we need to read the view from accountAccess table. - //obpViewsForAccount = MapperViews.availableViewsForAccount(bankAccountId).map(_.viewId.value) - obpViewsForAccount = Views.views.vend.privateViewsUserCanAccessForAccount(user, bankAccountId).map(_.viewId.value) - _ = logger.debug("refreshViewsAccountAccessAndHolders.obpViewsForAccount-------" + obpViewsForAccount) - - cbsViewsForAccount = accountsHeld.find(account => account.bankId.equals(bankAccountId.bankId.value) && account.accountId.equals(bankAccountId.accountId.value)).map(_.viewsToGenerate).getOrElse(Nil) - _ = logger.debug("refreshViewsAccountAccessAndHolders.cbsViewsForAccount-------" + cbsViewsForAccount) - //cbs removed these views, but OBP still contains the data for them, so we need to clean data in OBP side. - cbsRemovedViewsForAccount = obpViewsForAccount diff cbsViewsForAccount - _ = logger.debug("refreshViewsAccountAccessAndHolders.cbsRemovedViewsForAccount-------" + cbsRemovedViewsForAccount) - _ = if(cbsRemovedViewsForAccount.nonEmpty){ - val cbsRemovedBankIdAccountIdViewIds = cbsRemovedViewsForAccount.map(view => BankIdAccountIdViewId(bankAccountId.bankId, bankAccountId.accountId, ViewId(view))) - Views.views.vend.revokeAccessToMultipleViews(cbsRemovedBankIdAccountIdViewIds, user) - cbsRemovedViewsForAccount.map(view =>Views.views.vend.removeCustomView(ViewId(view), bankAccountId)) - UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId) - } - //cbs has new views which are not in obp yet, we need to create new data for these accounts. - csbNewViewsForAccount = cbsViewsForAccount diff obpViewsForAccount - _ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount-------" + csbNewViewsForAccount) - success = if(csbNewViewsForAccount.nonEmpty){ - for{ - newViewForAccount <- csbNewViewsForAccount - _ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount.newViewForAccount start:-------" + newViewForAccount) - view <- Views.views.vend.getOrCreateSystemViewFromCbs(newViewForAccount)//TODO, only support system views so far, may add custom views later. - _ = UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId) - view <- if (view.isSystem) //if the view is a system view, we will call `grantAccessToSystemView` - Views.views.vend.grantAccessToSystemView(bankAccountId.bankId, bankAccountId.accountId, view, user) - else //otherwise, we will call `grantAccessToCustomView` - Views.views.vend.grantAccessToCustomView(view.uid, user) - _ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount.newViewForAccount finish:-------" + newViewForAccount) - }yield{ - view - } + //3rd: the accounts both sides still have may have gained or lost views, so compare the views of each one, also + //when other accounts were added or removed above. Views the connector does not list are taken away, as before, + //only on a refresh that leaves the accounts as they were, so opening an account does not by itself remove them. + val accountsUnchanged = cbsRemovedBankAccountIds.equals(csbNewBankAccountIds) + for { + bankAccountId <- obpBankAccountIds intersect cbsBankAccountIds + // we can not get the views from the `viewDefinition` table, because we can not delete system views at all. we need to read the view from accountAccess table. + //obpViewsForAccount = MapperViews.availableViewsForAccount(bankAccountId).map(_.viewId.value) + obpViewsForAccount = Views.views.vend.privateViewsUserCanAccessForAccount(user, bankAccountId).map(_.viewId.value) + _ = logger.debug("refreshViewsAccountAccessAndHolders.obpViewsForAccount-------" + obpViewsForAccount) + + cbsViewsForAccount = accountsHeld.find(account => account.bankId.equals(bankAccountId.bankId.value) && account.accountId.equals(bankAccountId.accountId.value)).map(_.viewsToGenerate).getOrElse(Nil) + _ = logger.debug("refreshViewsAccountAccessAndHolders.cbsViewsForAccount-------" + cbsViewsForAccount) + //cbs removed these views, but OBP still contains the data for them, so we need to clean data in OBP side. + cbsRemovedViewsForAccount = obpViewsForAccount diff cbsViewsForAccount + _ = logger.debug("refreshViewsAccountAccessAndHolders.cbsRemovedViewsForAccount-------" + cbsRemovedViewsForAccount) + _ = if(cbsRemovedViewsForAccount.nonEmpty && accountsUnchanged){ + val cbsRemovedBankIdAccountIdViewIds = cbsRemovedViewsForAccount.map(view => BankIdAccountIdViewId(bankAccountId.bankId, bankAccountId.accountId, ViewId(view))) + Views.views.vend.revokeAccessToMultipleViews(cbsRemovedBankIdAccountIdViewIds, user) + cbsRemovedViewsForAccount.map(view =>Views.views.vend.removeCustomView(ViewId(view), bankAccountId)) + UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId) + } + //cbs has new views which are not in obp yet, we need to create new data for these accounts. + csbNewViewsForAccount = cbsViewsForAccount diff obpViewsForAccount + _ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount-------" + csbNewViewsForAccount) + success = if(csbNewViewsForAccount.nonEmpty){ + for{ + newViewForAccount <- csbNewViewsForAccount + _ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount.newViewForAccount start:-------" + newViewForAccount) + view <- Views.views.vend.getOrCreateSystemViewFromCbs(newViewForAccount)//TODO, only support system views so far, may add custom views later. + _ = UserRefreshes.UserRefreshes.vend.createOrUpdateRefreshUser(user.userId) + view <- if (view.isSystem) //if the view is a system view, we will call `grantAccessToSystemView` + Views.views.vend.grantAccessToSystemView(bankAccountId.bankId, bankAccountId.accountId, view, user) + else //otherwise, we will call `grantAccessToCustomView` + Views.views.vend.grantAccessToCustomView(view.uid, user) + _ = logger.debug("refreshViewsAccountAccessAndHolders.csbNewViewsForAccount.newViewForAccount finish:-------" + newViewForAccount) + }yield{ + view } - } yield { - success } + } yield { + success } true } diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index 0f279125e5..a89707320f 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -148,18 +148,27 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait with Logger def findMostRecentRateLimitCommon(consumerId: String, bankId: Option[String], apiVersion: Option[String], - apiName: Option[String]): Option[RateLimiting] = { - val byConsumerParam = By(RateLimiting.ConsumerId, consumerId) - val byBankParam = bankId.map(v => By(RateLimiting.BankId, v)).getOrElse(NullRef(RateLimiting.BankId)) - val byApiVersionParam = apiVersion.map(v => By(RateLimiting.ApiVersion, v)).getOrElse(NullRef(RateLimiting.ApiVersion)) - val byApiNameParam = apiName.map(v => By(RateLimiting.ApiName, v)).getOrElse(NullRef(RateLimiting.ApiName)) - - RateLimiting.findAll( - byConsumerParam, byBankParam, byApiVersionParam, byApiNameParam, - OrderBy(RateLimiting.updatedAt, Descending) - ).headOption + apiName: Option[String]): Option[RateLimiting] = + sameScope(consumerId, bankId, apiVersion, apiName).sortBy(_.updatedAt.get)(Ordering[java.util.Date].reverse).headOption + + def countConsumerCallLimits(consumerId: String, + bankId: Option[String], + apiVersion: Option[String], + apiName: Option[String]): Future[Int] = Future { + sameScope(consumerId, bankId, apiVersion, apiName).size } + /** + * The consumer's rows with exactly this bank_id, api_version and api_name, None matching a row that does not set it. + * Compared through the getters, which take an empty column as not set: older versions stored "" there as well as NULL. + */ + private def sameScope(consumerId: String, + bankId: Option[String], + apiVersion: Option[String], + apiName: Option[String]): List[RateLimiting] = + RateLimiting.findAll(By(RateLimiting.ConsumerId, consumerId)) + .filter(row => row.bankId == bankId && row.apiVersion == apiVersion && row.apiName == apiName) + def createConsumerCallLimits(consumerId: String, fromDate: Date, toDate: Date, @@ -240,7 +249,7 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait with Logger case Some(limit) => createOrUpdateRateLimit(limit) case None => createOrUpdateRateLimit(RateLimiting.create) } - + result.foreach(_ => Caching.invalidateRateLimitCache(consumerId)) result } def updateConsumerCallLimits(rateLimitingId: String, diff --git a/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala index 292e3d3dca..f72328b387 100644 --- a/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/RateLimiting.scala @@ -45,6 +45,8 @@ trait RateLimitingProviderTrait { def getAllByConsumerId(consumerId: String, date: Option[Date] = None): Future[List[RateLimiting]] def getByConsumerId(consumerId: String, apiVersion: String, apiName: String, date: Option[Date] = None): Future[Box[RateLimiting]] def findMostRecentRateLimit(consumerId: String, bankId: Option[String], apiVersion: Option[String], apiName: Option[String]): Future[Option[RateLimiting]] + /** How many of the consumer's rate limits have this bank_id, api_version and api_name (None: not set), whatever their dates. */ + def countConsumerCallLimits(consumerId: String, bankId: Option[String], apiVersion: Option[String], apiName: Option[String]): Future[Int] def createOrUpdateConsumerCallLimits(consumerId: String, fromDate: Date, toDate: Date, diff --git a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index db7f5f490e..0db09e0efb 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -258,7 +258,8 @@ object ConsentScheduler extends MdcLoggable { } } - private def expiredUKConsents(): Unit = { + // private[code] so a test can run one sweep without waiting for the schedule. + private[code] def expiredUKConsents(): Unit = { Try { logger.debug("|---> Checking for expired UK Open Banking consents...") @@ -285,6 +286,9 @@ object ConsentScheduler extends MdcLoggable { if (rows > 0) { // An expired consent must give its granted access back, exactly as a revoked one does. Consent.revokeConsentAccountAccess(consent) + // Only UK consents are authorised through OBP_CONSENT_CHALLENGEs, so this is the one + // expiry sweep that can leave such a challenge behind. + Consent.removeConsentChallenges(consent.consentId) logger.warn(message) } else logger.debug(s"|---> Skipped stale update for UK consent ${consent.id}: status already changed") diff --git a/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala b/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala index d88f24cdfb..b22d08684b 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/ChallengeProvider.scala @@ -60,6 +60,13 @@ trait ChallengeProvider { def getChallengesByConsentId(consentId: String): Box[List[ChallengeTrait]] def getChallengesByBasketId(basketId: String): Box[List[ChallengeTrait]] + /** + * Deletes the challenges of the given type that were raised for this consent. + * The type is part of the match because a consent's challenges are not all alike: the Berlin Group + * authorisation endpoints keep listing theirs after they are answered. + */ + def deleteChallengesByConsentId(consentId: String, challengeType: String): Box[Boolean] + /** * There is another method: Connector.validateChallengeAnswer, it validates the challenge over CBS. * This method, will validate the answer in OBP side. diff --git a/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala b/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala index c01b6b20b5..9535265ae0 100644 --- a/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala +++ b/obp-api/src/main/scala/code/transactionChallenge/MappedChallengeProvider.scala @@ -102,6 +102,12 @@ object MappedChallengeProvider extends ChallengeProvider { Full(MappedExpectedChallengeAnswer.findAll(By(MappedExpectedChallengeAnswer.ConsentId,consentId))) override def getChallengesByBasketId(basketId: String): Box[List[ChallengeTrait]] = Full(MappedExpectedChallengeAnswer.findAll(By(MappedExpectedChallengeAnswer.BasketId,basketId))) + + override def deleteChallengesByConsentId(consentId: String, challengeType: String): Box[Boolean] = + tryo(MappedExpectedChallengeAnswer.bulkDelete_!!( + By(MappedExpectedChallengeAnswer.ConsentId, consentId), + By(MappedExpectedChallengeAnswer.ChallengeType, challengeType) + )) override def validateChallenge( challengeId: String, diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 6e3ecdc5cf..204f86023f 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -727,6 +727,17 @@ object MapperViews extends Views with MdcLoggable { } } + def ensureOptInSystemViewsUpToDate(additionalSystemViews: Box[String]) : List[Box[View]] = { + val listed = additionalSystemViews.toList.flatMap(_.split(",").map(_.trim)) + // Listing a view creates it. One that exists without being listed is kept current too, unless + // system_views.reconcile_permissions_at_boot is false: a refresh of the mapped connector creates both of them for + // the holders of its accounts. + val reconcileExisting = APIUtil.getPropsAsBoolValue("system_views.reconcile_permissions_at_boot", true) + List(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) + .filter(viewId => listed.contains(viewId) || (reconcileExisting && ViewDefinition.findSystemView(viewId).isDefined)) + .map(ensureSystemViewUpToDate) + } + def getOrCreateSystemView(viewId: String) : Box[View] = { getExistingSystemView(viewId) match { case Empty => diff --git a/obp-api/src/main/scala/code/views/Views.scala b/obp-api/src/main/scala/code/views/Views.scala index e49d7eb49d..a530e94f76 100644 --- a/obp-api/src/main/scala/code/views/Views.scala +++ b/obp-api/src/main/scala/code/views/Views.scala @@ -163,6 +163,12 @@ trait Views { */ def ensureSystemViewUpToDate(viewId: String) : Box[View] + /** + * Keeps the opt-in system views (ReadTransactionsBerlinGroup, InitiatePaymentsBerlinGroup) current as + * ensureSystemViewUpToDate does. `additionalSystemViews` is the value of the additional_system_views prop. + */ + def ensureOptInSystemViewsUpToDate(additionalSystemViews: Box[String]) : List[Box[View]] + def getOwners(view: View): Set[User] def removeAllAccountAccess(bankId: BankId, accountId: AccountId) : Boolean diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 0a1f1eb81f..fe6c8a3750 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -28,11 +28,15 @@ TESOBE (http://www.tesobe.com/) package deletion import code.accountattribute.MappedAccountAttribute +import code.accountholders.MapperAccountHolders import code.api.APIFailureNewStyle import code.api.util.APIUtil.fullBoxOrException +import code.api.util.{ConsentJWT, CustomJsonFormats, JwtUtil} import code.api.util.ErrorMessages.CouldNotDeleteCascade import code.bankconnectors.Connector +import code.cardattribute.MappedCardAttribute import code.cards.MappedPhysicalCard +import code.consent.Consents import code.counterpartyattribute.CounterpartyAttribute import code.counterpartylimit.CounterpartyLimit import code.entitlement.MappedEntitlement @@ -43,11 +47,12 @@ import code.transactionrequests.MappedTransactionRequest import code.views.system.{AccountAccess, ViewDefinition} import code.webhook.MappedAccountWebhook import com.openbankproject.commons.model.{AccountId, BankId} +import com.openbankproject.commons.util.JsonAliases import deletion.DeletionUtil.databaseAtomicTask import net.liftweb.common.{Box, Empty, Full} -import net.liftweb.db.DB -import net.liftweb.mapper.{By, ByList} -import net.liftweb.util.DefaultConnectionIdentifier +import net.liftweb.mapper.{By, ByList, NotBy} +import net.liftweb.util.Helpers.tryo +import org.json4s.Formats import scala.collection.immutable.List @@ -57,12 +62,14 @@ object DeleteAccountCascade { // The tasks run in list order: the challenges are found through the account's transaction // requests, so they must go before deleteTransactionRequests and deleteTransactions remove them. val doneTasks = - deleteTransactionRequestChallenges(bankId, accountId) :: + deleteConsentChallenges(bankId, accountId) :: + deleteTransactionRequestChallenges(bankId, accountId) :: deleteTransactionRequests(bankId, accountId) :: deleteDoubleEntryBookTransactions(bankId, accountId) :: deleteTransactions(bankId, accountId) :: deleteEntitlements(bankId, accountId) :: deleteAccountAccess(bankId, accountId) :: + deleteAccountHolders(bankId, accountId) :: deleteCustomViews(bankId, accountId) :: deleteAccountAttributes(bankId, accountId) :: deleteAccountWebhooks(bankId, accountId) :: @@ -81,7 +88,7 @@ object DeleteAccountCascade { case true => Full(true) case false => - DB.rollback(DefaultConnectionIdentifier) + // Throws, so databaseAtomicTask restores what delete has removed. fullBoxOrException(Empty ~> APIFailureNewStyle(CouldNotDeleteCascade, 400)) } } @@ -108,10 +115,13 @@ object DeleteAccountCascade { By(MappedBankAccount.bank, bankId.value), By(MappedBankAccount.theAccountId, accountId.value) ) map ( - account => - MappedPhysicalCard.bulkDelete_!!( - By(MappedPhysicalCard.mAccount, account.id.get) - ) + account => { + val cardIds = MappedPhysicalCard.findAll(By(MappedPhysicalCard.mAccount, account.id.get)).map(_.mCardId.get) + MappedCardAttribute.deleteForCards(bankId.value, cardIds) && + MappedPhysicalCard.bulkDelete_!!( + By(MappedPhysicalCard.mAccount, account.id.get) + ) + } ) }.forall(_ == true) @@ -145,6 +155,14 @@ object DeleteAccountCascade { By(AccountAccess.account_id, accountId.value) ) } + // A holder row that outlives its account makes the next refresh of the holder's accounts + // (AuthUser.refreshUser, run e.g. when the holder creates another account) grant the system views on it again. + private def deleteAccountHolders(bankId: BankId, accountId: AccountId): Boolean = { + MapperAccountHolders.bulkDelete_!!( + By(MapperAccountHolders.accountBankPermalink, bankId.value), + By(MapperAccountHolders.accountPermalink, accountId.value) + ) + } private def deleteAccountRoutings(bankId: BankId, accountId: AccountId): Boolean = { BankAccountRouting.bulkDelete_!!( By(BankAccountRouting.BankId, bankId.value), @@ -210,6 +228,26 @@ object DeleteAccountCascade { } } + // A consent names its accounts only in the views of its JWT, so the consents that still have + // challenges are read and those whose JWT names this account lose them. The consents are found + // through their challenges rather than through users: a Berlin Group consent awaiting its first + // challenge answer has no PSU bound to it yet. A consent bound to other accounts as well loses its + // challenges too: they record the authorisation of the consent as a whole. + private def deleteConsentChallenges(bankId: BankId, accountId: AccountId): Boolean = { + implicit val formats: Formats = CustomJsonFormats.formats + val consentIds = MappedExpectedChallengeAnswer.findAll(NotBy(MappedExpectedChallengeAnswer.ConsentId, "")) + .map(_.ConsentId.get).distinct + .flatMap(Consents.consentProvider.vend.getConsentByConsentId(_).toList) + .filter { consent => + JwtUtil.getSignedPayloadAsJson(consent.jsonWebToken) + .flatMap(payload => tryo(JsonAliases.parse(payload).extract[ConsentJWT])) + .exists(_.views.exists(view => view.bank_id == bankId.value && view.account_id == accountId.value)) + } + .map(_.consentId) + consentIds.isEmpty || MappedExpectedChallengeAnswer.bulkDelete_!!( + ByList(MappedExpectedChallengeAnswer.ConsentId, consentIds) + ) + } private def deleteTransactionRequestChallenges(bankId: BankId, accountId: AccountId): Boolean = { val transactionRequestIds = MappedTransactionRequest.findAll( By(MappedTransactionRequest.mFrom_BankId, bankId.value), diff --git a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala index 75655c365b..f05e330db6 100644 --- a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala @@ -29,17 +29,19 @@ package deletion import code.accountattribute.MappedAccountAttribute import code.api.APIFailureNewStyle +import code.api.Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID import code.api.util.APIUtil.fullBoxOrException import code.api.util.ErrorMessages.CouldNotDeleteCascade +import code.DynamicData.{DynamicData, DynamicDataAccess, DynamicDataProvider} +import code.api.util.NewStyle import code.customer.CustomerX +import code.dynamicEntity.DynamicEntityProvider import code.customeraccountlinks.CustomerAccountLink import code.model.dataAccess.{MappedBank, MappedBankAccount} import com.openbankproject.commons.model.{BankId, CustomerId} import deletion.DeletionUtil.databaseAtomicTask import net.liftweb.common.{Box, Empty, Full} -import net.liftweb.db.DB import net.liftweb.mapper.By -import net.liftweb.util.DefaultConnectionIdentifier object DeleteBankCascade { @@ -60,19 +62,50 @@ object DeleteBankCascade { ) // Delete account DeleteAccountCascade.delete(i.bankId, i.accountId) - } && deleteBank(bankId) + } && (isSystemSpaceMarker(bankId) || deleteDynamicEntities(bankId)) && deleteBank(bankId) } + + /** + * Dynamic entities of the system space are stored under this marker, so a bank that has it as its id cannot have + * dynamic entities of its own, and deleting the bank must not touch the system space. + */ + private def isSystemSpaceMarker(bankId: BankId): Boolean = bankId.value == DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID def atomicDelete(bankId: BankId): Box[Boolean] = databaseAtomicTask { delete(bankId) match { case true => Full(true) case false => - DB.rollback(DefaultConnectionIdentifier) + // Throws, so databaseAtomicTask restores what delete has removed. fullBoxOrException(Empty ~> APIFailureNewStyle(CouldNotDeleteCascade, 400)) } } + /** + * The bank's dynamic entities with their records (whoever owns them), access rows, entitlements and roles, the + * ZZ_BAK_ copies among them. With the bank gone their endpoints answer BankNotFound, so nothing else could + * remove them. + */ + private def deleteDynamicEntities(bankId: BankId): Boolean = { + val space = Some(bankId.value) + DynamicEntityProvider.connectorMethodProvider.vend.getDynamicEntities(space, returnBothBankAndSystemLevel = false).forall { entity => + val recordsDeleted = DynamicDataProvider.connectorMethodProvider.vend.getAllCommunity(space, entity.entityName).forall { record => + record.dynamicDataId.exists(id => DynamicDataProvider.connectorMethodProvider.vend.deleteCommunity(space, entity.entityName, id).openOr(false)) + } + recordsDeleted && entity.dynamicEntityId.exists(id => NewStyle.function.deleteDynamicEntityNow(space, id).openOr(false)) + } && deleteRecordsWithoutDefinition(bankId) + } + + /** + * What is left of the bank's records once its definitions are gone: a definition can be deleted while personal + * records of it remain, so some records, and the access rows on them, may have no definition. + */ + private def deleteRecordsWithoutDefinition(bankId: BankId): Boolean = { + DynamicData.bulkDelete_!!(By(DynamicData.BankId, bankId.value)) + DynamicDataAccess.bulkDelete_!!(By(DynamicDataAccess.BankId, bankId.value)) + true + } + private def deleteBank(bankId: BankId): Boolean = { MappedBank.bulkDelete_!!( By(MappedBank.permalink, bankId.value) diff --git a/obp-api/src/main/scala/deletion/DeleteCustomerCascade.scala b/obp-api/src/main/scala/deletion/DeleteCustomerCascade.scala index 4883b810be..abbe9455c4 100644 --- a/obp-api/src/main/scala/deletion/DeleteCustomerCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteCustomerCascade.scala @@ -45,9 +45,7 @@ import code.usercustomerlinks.MappedUserCustomerLink import com.openbankproject.commons.model.CustomerId import deletion.DeletionUtil.databaseAtomicTask import net.liftweb.common.{Box, Empty, Full} -import net.liftweb.db.DB import net.liftweb.mapper.By -import net.liftweb.util.DefaultConnectionIdentifier object DeleteCustomerCascade { @@ -74,7 +72,7 @@ object DeleteCustomerCascade { case true => Full(true) case false => - DB.rollback(DefaultConnectionIdentifier) + // Throws, so databaseAtomicTask restores what delete has removed. fullBoxOrException(Empty ~> APIFailureNewStyle(CouldNotDeleteCascade, 400)) } } diff --git a/obp-api/src/main/scala/deletion/DeleteProductCascade.scala b/obp-api/src/main/scala/deletion/DeleteProductCascade.scala index 6e767a9eba..6e57bd001e 100644 --- a/obp-api/src/main/scala/deletion/DeleteProductCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteProductCascade.scala @@ -38,9 +38,7 @@ import code.products.MappedProduct import com.openbankproject.commons.model.{BankId, ProductCode} import deletion.DeletionUtil.databaseAtomicTask import net.liftweb.common.{Box, Empty, Full} -import net.liftweb.db.DB import net.liftweb.mapper.By -import net.liftweb.util.DefaultConnectionIdentifier object DeleteProductCascade { @@ -60,7 +58,7 @@ object DeleteProductCascade { case true => Full(true) case false => - DB.rollback(DefaultConnectionIdentifier) + // Throws, so databaseAtomicTask restores what delete has removed. fullBoxOrException(Empty ~> APIFailureNewStyle(CouldNotDeleteCascade, 400)) } } diff --git a/obp-api/src/main/scala/deletion/DeleteTransactionCascade.scala b/obp-api/src/main/scala/deletion/DeleteTransactionCascade.scala index 087b6b53fc..0954f40973 100644 --- a/obp-api/src/main/scala/deletion/DeleteTransactionCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteTransactionCascade.scala @@ -39,9 +39,7 @@ import code.transaction.MappedTransaction import code.transactionattribute.MappedTransactionAttribute import code.transactionrequests.MappedTransactionRequestProvider import com.openbankproject.commons.model.{AccountId, BankId, TransactionId} -import net.liftweb.db.DB import net.liftweb.mapper.By -import net.liftweb.util.DefaultConnectionIdentifier import deletion.DeletionUtil.databaseAtomicTask import net.liftweb.common.{Box, Empty, Full} @@ -64,7 +62,7 @@ object DeleteTransactionCascade { case true => Full(true) case false => - DB.rollback(DefaultConnectionIdentifier) + // Throws, so databaseAtomicTask restores what delete has removed. fullBoxOrException(Empty ~> APIFailureNewStyle(CouldNotDeleteCascade, 400)) } } diff --git a/obp-api/src/main/scala/deletion/DeletionUtil.scala b/obp-api/src/main/scala/deletion/DeletionUtil.scala index cb6b55d238..27c63f5bc8 100644 --- a/obp-api/src/main/scala/deletion/DeletionUtil.scala +++ b/obp-api/src/main/scala/deletion/DeletionUtil.scala @@ -30,10 +30,37 @@ package deletion import net.liftweb.db.DB import net.liftweb.util.DefaultConnectionIdentifier +import java.sql.Connection +import scala.util.control.NonFatal + object DeletionUtil { + /** + * Runs a cascade as one unit: when it throws, every row it wrote is restored before the exception goes on. + * + * Within a request the connection is the request's own (RequestScopeConnection), which ignores rollback and is + * committed when the request ends, whatever the response. So the cascade rolls back to a savepoint taken here + * instead. Outside a request this is a savepoint within the transaction DB.use already runs. + */ def databaseAtomicTask[R](blockOfCode: => R): R = { - DB.use(DefaultConnectionIdentifier){_ => - blockOfCode + DB.use(DefaultConnectionIdentifier){ conn => + withSavepoint(conn.connection)(blockOfCode) } } + + /** Runs `blockOfCode` after a savepoint on `connection`, rolling back to it when the block throws. */ + private[deletion] def withSavepoint[R](connection: Connection)(blockOfCode: => R): R = { + val savepoint = connection.setSavepoint() + val result = + try blockOfCode + catch { + case e: Throwable => + try connection.rollback(savepoint) + catch { case rollbackFailure: Throwable => e.addSuppressed(rollbackFailure) } + throw e + } + // Oracle's and SQL Server's drivers do not release savepoints; the savepoint then ends with the transaction. + try connection.releaseSavepoint(savepoint) + catch { case NonFatal(_) => } + result + } } 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 bc059dbaf9..129ee644f3 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 @@ -33,12 +33,15 @@ import code.api.util.ErrorMessages.{ConsentDoesNotMatchConsumer, ConsentDoesNotM import code.api.util.{CallContext, CertificateUtil, Consent} import code.consent.{ConsentStatus, Consents} import code.model.UserExtended -import code.transactionChallenge.Challenges +import code.scheduler.ConsentScheduler +import code.transactionChallenge.{Challenges, MappedChallengeProvider, MappedExpectedChallengeAnswer} import code.views.Views import com.openbankproject.commons.model.{BankIdAccountId, ErrorMessage, ViewId} import com.openbankproject.commons.model.enums.ChallengeType import com.openbankproject.commons.util.ApiVersion import net.liftweb.common.{Failure, Full} +import net.liftweb.mapper.By +import org.mindrot.jbcrypt.BCrypt import org.json4s._ import org.scalatest.Tag @@ -928,4 +931,128 @@ class UKOpenBankingV401AccountInfoTests extends UKOpenBankingV401ServerSetup { storedConsent(consentId).status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) } } + + // A consent's OBP_CONSENT_CHALLENGE rows are deleted once none of them can be used any more: after + // a correct answer, and when the consent becomes REVOKED, REJECTED or EXPIRED. A wrong answer keeps + // its row, because the attempt counter on it is what limits the next guesses. Rows of other types + // on the same consent stay: the Berlin Group authorisation endpoints list theirs after answering. + private def consentChallengesOf(consentId: String, challengeType: ChallengeType.Value) = + MappedExpectedChallengeAnswer.findAll( + By(MappedExpectedChallengeAnswer.ConsentId, consentId), + By(MappedExpectedChallengeAnswer.ChallengeType, challengeType.toString)) + private def seedConsentChallenge(consentId: String, challengeType: ChallengeType.Value): String = { + val challengeId = java.util.UUID.randomUUID.toString + val salt = BCrypt.gensalt() + MappedChallengeProvider.saveChallenge( + challengeId = challengeId, + transactionRequestId = "", + salt = salt, + expectedAnswer = BCrypt.hashpw("123", salt).substring(0, 44), + expectedUserId = resourceUser1.userId, + scaMethod = None, + scaStatus = None, + consentId = Some(consentId), + basketId = None, + authenticationMethodId = None, + challengeType = challengeType.toString + ).openOrThrowException(s"challenge for consent $consentId") + challengeId + } + + feature("UKOB v4.0.1 a consent's SCA challenges are deleted once they can no longer be used") { + scenario("a correct answer deletes the consent's challenges, other challenge types stay", UKOpenBankingV401AccountInfo) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUKConsent(None, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + + val challenge = makePostRequest(scaChallengeRequest(consentId) <@ (user1), "") + challenge.code should equal(200) + val challengeId = (challenge.body \ "challenge_id").extract[String] + // A second challenge the PSU started and never answered, and a Berlin Group one on the same id. + seedConsentChallenge(consentId, ChallengeType.OBP_CONSENT_CHALLENGE) + seedConsentChallenge(consentId, ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE) + consentChallengesOf(consentId, ChallengeType.OBP_CONSENT_CHALLENGE).size should equal(2) + + When("the PSU answers the challenge correctly") + val authorised = makePostRequest(authoriseRequest(consentId) <@ (user1), + s"""{"account_ids":["$acc"],"challenge_id":"$challengeId","answer":"123"}""") + authorised.code should equal(200) + storedConsent(consentId).status should equal(ConsentStatus.AUTHORISED.toString) + + Then("both OBP consent challenges are gone, and the Berlin Group one is still there") + consentChallengesOf(consentId, ChallengeType.OBP_CONSENT_CHALLENGE) should be(empty) + Challenges.ChallengeProvider.vend.getChallenge(challengeId).isDefined should equal(false) + consentChallengesOf(consentId, ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE).size should equal(1) + } + scenario("a wrong answer keeps the challenge and its attempt count", UKOpenBankingV401AccountInfo) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUKConsent(None, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + + val challenge = makePostRequest(scaChallengeRequest(consentId) <@ (user1), "") + challenge.code should equal(200) + val challengeId = (challenge.body \ "challenge_id").extract[String] + + val refused = makePostRequest(authoriseRequest(consentId) <@ (user1), + s"""{"account_ids":["$acc"],"challenge_id":"$challengeId","answer":"999"}""") + refused.code should not equal (200) + + val stored = Challenges.ChallengeProvider.vend.getChallenge(challengeId) + .openOrThrowException(s"challenge $challengeId") + stored.attemptCounter should equal(1) + stored.successful should equal(false) + storedConsent(consentId).status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + scenario("a correct answer that is then refused still spends the challenge", UKOpenBankingV401AccountInfo) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + val consentId = createUKConsent(None, Some(new java.util.Date(System.currentTimeMillis() + 3600000L))) + + // user2 does not hold testAccountId1, so the authorisation is refused after the answer is checked. + val challenge = makePostRequest(scaChallengeRequest(consentId) <@ (user2), "") + challenge.code should equal(200) + val challengeId = (challenge.body \ "challenge_id").extract[String] + + val refused = makePostRequest(authoriseRequest(consentId) <@ (user2), + s"""{"account_ids":["$acc"],"challenge_id":"$challengeId","answer":"123"}""") + refused.code should equal(403) + + consentChallengesOf(consentId, ChallengeType.OBP_CONSENT_CHALLENGE) should be(empty) + storedConsent(consentId).status should equal(ConsentStatus.AWAITINGAUTHORISATION.toString) + } + scenario("REJECTED, REVOKED and EXPIRED delete the consent's challenges, AUTHORISED does not", UKOpenBankingV401AccountInfo) { + val expiry = Some(new java.util.Date(System.currentTimeMillis() + 3600000L)) + + val authorised = createUKConsent(None, expiry) + seedConsentChallenge(authorised, ChallengeType.OBP_CONSENT_CHALLENGE) + Consents.consentProvider.vend.updateConsentStatus(authorised, ConsentStatus.AUTHORISED) + consentChallengesOf(authorised, ChallengeType.OBP_CONSENT_CHALLENGE).size should equal(1) + + // REJECTED is what the management endpoint PUT /management/banks/BANK_ID/consents/CONSENT_ID sets. + val rejected = createUKConsent(None, expiry) + seedConsentChallenge(rejected, ChallengeType.OBP_CONSENT_CHALLENGE) + seedConsentChallenge(rejected, ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE) + Consents.consentProvider.vend.updateConsentStatus(rejected, ConsentStatus.REJECTED) + consentChallengesOf(rejected, ChallengeType.OBP_CONSENT_CHALLENGE) should be(empty) + consentChallengesOf(rejected, ChallengeType.BERLIN_GROUP_CONSENT_CHALLENGE).size should equal(1) + + // Every revoke endpoint, the UK DELETE included, goes through ConsentProvider.revoke. + val revoked = createUKConsent(None, expiry) + seedConsentChallenge(revoked, ChallengeType.OBP_CONSENT_CHALLENGE) + Consents.consentProvider.vend.revoke(revoked).isDefined should equal(true) + consentChallengesOf(revoked, ChallengeType.OBP_CONSENT_CHALLENGE) should be(empty) + + val expired = createUKConsent(None, expiry) + seedConsentChallenge(expired, ChallengeType.OBP_CONSENT_CHALLENGE) + Consents.consentProvider.vend.updateConsentStatus(expired, ConsentStatus.EXPIRED) + consentChallengesOf(expired, ChallengeType.OBP_CONSENT_CHALLENGE) should be(empty) + } + scenario("the UK expiry sweep deletes the challenges of the consents it expires", UKOpenBankingV401AccountInfo) { + val consentId = createUKConsent(Some(resourceUser1), Some(new java.util.Date(System.currentTimeMillis() - 60000L))) + Consents.consentProvider.vend.updateConsentStatus(consentId, ConsentStatus.AUTHORISED) + seedConsentChallenge(consentId, ChallengeType.OBP_CONSENT_CHALLENGE) + + ConsentScheduler.expiredUKConsents() + + storedConsent(consentId).status should equal(ConsentStatus.EXPIRED.toString) + consentChallengesOf(consentId, ChallengeType.OBP_CONSENT_CHALLENGE) should be(empty) + } + } } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala index 80155de1c1..0d9f31e682 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/AccountInformationServiceAISApiTest.scala @@ -779,6 +779,8 @@ class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures { val consentId =response.body.extract[PostConsentResponseJson].consentId Then(s"We test the $startConsentAuthorisationTransactionAuthorisation") + // a challenge the user can be sent: with the default SMS and no linked customer there is no one to send it to + setPropsValues("suggested_default_sca_method" -> "DUMMY") val requestStartConsentAuthorisation = (V1_3_BG / "consents"/consentId /"authorisations" ).POST <@ (user1) val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""") responseStartConsentAuthorisation.code should be (201) @@ -837,6 +839,8 @@ class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures { val consentId =response.body.extract[PostConsentResponseJson].consentId Then(s"We test the $startConsentAuthorisationTransactionAuthorisation") + // a challenge the user can be sent: with the default SMS and no linked customer there is no one to send it to + setPropsValues("suggested_default_sca_method" -> "DUMMY") val requestStartConsentAuthorisation = (V1_3_BG / "consents"/consentId /"authorisations" ).POST <@ (user1) val responseStartConsentAuthorisation = makePostRequest(requestStartConsentAuthorisation, """{"scaAuthenticationData":""}""") responseStartConsentAuthorisation.code should be (201) diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala index 12973f60ca..5aa89419ab 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/BerlinGroupConsentFixtures.scala @@ -66,14 +66,15 @@ trait BerlinGroupConsentFixtures extends BerlinGroupServerSetupV1_3 with Default def getNextMonthDate(): String = LocalDate.now().plusMonths(1).format(DateTimeFormatter.ISO_LOCAL_DATE) - /** One account, addressed by the first IBAN routing in the test data. */ - def bgConsentPostBody(): PostConsentJson = { - val acountRoutingIban = BankAccountRouting + /** One account, addressed by `iban`, or by the first IBAN routing in the test data. */ + def bgConsentPostBody(iban: Option[String] = None): PostConsentJson = { + val address = iban.getOrElse(BankAccountRouting .findAll(By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString)).head + .accountRouting.address) PostConsentJson( access = ConsentAccessJson( accounts = Option(List(ConsentAccessAccountsJson( - iban = Some(acountRoutingIban.accountRouting.address), + iban = Some(address), bban = None, pan = None, maskedPan = None, @@ -131,8 +132,8 @@ trait BerlinGroupConsentFixtures extends BerlinGroupServerSetupV1_3 with Default * An unclaimed (PSU-less) consent lodged under testConsumer, built straight through the provider * -- the shape POST /consents leaves behind for a client_credentials caller. */ - def createUnclaimedBerlinGroupConsent(): ConsentTrait = { - val postJsonBody = bgConsentPostBody() + def createUnclaimedBerlinGroupConsent(iban: Option[String] = None): ConsentTrait = { + val postJsonBody = bgConsentPostBody(iban) val validUntilDate = BgSpecValidation.getDate(postJsonBody.validUntil) val createdConsent = Consents.consentProvider.vend.createBerlinGroupConsent( diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala new file mode 100644 index 0000000000..3a5252bd41 --- /dev/null +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala @@ -0,0 +1,97 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.berlin.group.v1_3 + +import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.StartConsentAuthorisationJson +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.entitlement.Entitlement +import code.model.dataAccess.BankAccountRouting +import code.transactionChallenge.MappedExpectedChallengeAnswer +import com.openbankproject.commons.model.enums.AccountRoutingScheme +import net.liftweb.mapper.By +import org.scalatest.Tag + +/** + * A Berlin Group consent names its accounts when it is lodged, so once its PSU starts the + * authorisation the consent is bound to an account and has a challenge nobody has answered yet. + * Deleting that account with the cascading endpoint must take the challenge along; a challenge of a + * consent bound to another account stays. + */ +class DeleteAccountCascadeConsentChallengeTest extends BerlinGroupConsentFixtures { + + object DeleteAccountCascadeConsentChallenge extends Tag("DeleteAccountCascadeConsentChallenge") + + private def ibanOf(bankId: String, accountId: String): String = + BankAccountRouting.find( + By(BankAccountRouting.BankId, bankId), + By(BankAccountRouting.AccountId, accountId), + By(BankAccountRouting.AccountRoutingScheme, AccountRoutingScheme.IBAN.toString) + ).openOrThrowException(s"no IBAN for $bankId/$accountId").accountRouting.address + + private def challengesOf(consentId: String): Int = + MappedExpectedChallengeAnswer.findAll(By(MappedExpectedChallengeAnswer.ConsentId, consentId)).size + + // The consent is lodged for one account and resourceUser1 starts its authorisation, which binds + // the PSU and mints the challenge; the challenge is left unanswered. + private def consentAwaitingItsChallenge(bankId: String, accountId: String): String = { + val consentId = createUnclaimedBerlinGroupConsent(Some(ibanOf(bankId, accountId))).consentId + val started = makePostRequest( + (V1_3_BG / "consents" / consentId / "authorisations").POST <@ (user1), + """{"scaAuthenticationData":""}""") + started.code should equal(201) + started.body.extract[StartConsentAuthorisationJson].authorisationId should not be empty + consentId + } + + feature("Deleting an account by cascade deletes the challenges of the consents bound to it") { + scenario("A Berlin Group consent whose challenge is still unanswered", DeleteAccountCascadeConsentChallenge) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + Given("Two accounts resourceUser1 holds, each named by a consent awaiting its challenge") + val held = ibanAddressableAccountsHeldBy(resourceUser1).toList.sorted + held.size should be >= 2 + val (bankId, accountId) = held.head + val (otherBankId, otherAccountId) = held(1) + val consent = consentAwaitingItsChallenge(bankId, accountId) + val otherConsent = consentAwaitingItsChallenge(otherBankId, otherAccountId) + challengesOf(consent) should equal(1) + challengesOf(otherConsent) should equal(1) + + When("The first account is deleted with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + val request = (baseRequest / "obp" / "v4.0.0" / "management" / "cascading" / "banks" / bankId / + "accounts" / accountId).DELETE <@ (user1) + makeDeleteRequest(request).code should equal(200) + + Then("The challenge of the consent bound to the deleted account is gone") + challengesOf(consent) should equal(0) + And("The challenge of the consent bound to the other account is kept") + challengesOf(otherConsent) should equal(1) + } + } +} diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3Test.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3Test.scala index 36512956c7..1780a5d237 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3Test.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/JSONFactory_BERLIN_GROUP_1_3Test.scala @@ -117,4 +117,35 @@ class JSONFactory_BERLIN_GROUP_1_3Test extends FeatureSpec with Matchers with Gi println(jsonString) } } + + feature("createTransactionRequestJson builds the scaRedirect link of a payment initiation") { + def scaRedirectOf(paymentId: String): JValue = { + val response = JSONFactory_BERLIN_GROUP_1_3.createTransactionRequestJson( + TransactionRequestBGV1(TransactionRequestId(paymentId), "INITIATED") + ) + Extraction.decompose(response._links) \ "scaRedirect" \ "href" + } + + scenario("a URL without PLACEHOLDER gets the payment id appended once") { + setPropsValues("psu_make_payment_sca_redirect_url" -> "https://psu.example/pay") + scaRedirectOf("p1") shouldBe JString("https://psu.example/pay/p1") + } + + scenario("a URL with PLACEHOLDER gets the payment id in its place and nowhere else") { + setPropsValues("psu_make_payment_sca_redirect_url" -> "https://psu.example/confirm?PAYMENT_ID=PLACEHOLDER") + scaRedirectOf("p1") shouldBe JString("https://psu.example/confirm?PAYMENT_ID=p1") + } + + scenario("a blank psu_make_payment_sca_redirect_url gives no scaRedirect link") { + setPropsValues("psu_make_payment_sca_redirect_url" -> " ") + scaRedirectOf("p1") shouldBe JNothing + } + + scenario("without psu_make_payment_sca_redirect_url there is no scaRedirect link and no error text") { + scaRedirectOf("p1") shouldBe JNothing + compactRender(Extraction.decompose( + JSONFactory_BERLIN_GROUP_1_3.createTransactionRequestJson(TransactionRequestBGV1(TransactionRequestId("p1"), "INITIATED")) + )) should not include "OBP-00010" + } + } } diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala index 7257fc9768..257aeb59d8 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/PaymentInitiationServicePISApiTest.scala @@ -29,14 +29,15 @@ package code.api.berlin.group.v1_3 import org.json4s._ import code.api.BerlinGroup.ScaStatus -import code.api.Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID +import code.api.Constant.{SYSTEM_AUDITOR_VIEW_ID, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID} import code.api.berlin.group.v1_3.JSONFactory_BERLIN_GROUP_1_3.{CancelPaymentResponseJson, CancellationJsonV13, ErrorMessagesBG, InitiatePaymentResponseJson, StartPaymentAuthorisationJson} import code.api.berlin.group.v1_3.model.{ScaStatusResponse, TransactionStatus, UpdatePsuAuthenticationResponse} import code.api.berlin.group.v1_3.Http4sBGv13PIS import code.api.util.APIUtil.OAuth._ import code.api.util.APIUtil.extractErrorMessageCode +import code.api.util.CommonsEmailWrapper import code.api.util.ErrorMessages._ -import code.model.dataAccess.{BankAccountRouting, MappedBankAccount} +import code.model.dataAccess.{AuthUser, BankAccountRouting, MappedBankAccount} import code.model.TokenType import code.setup.{APIResponse, DefaultUsers} import code.token.Tokens @@ -44,9 +45,12 @@ import code.transactionrequests.MappedTransactionRequest import net.liftweb.util.Helpers.randomString import net.liftweb.util.TimeHelpers.TimeSpan import code.views.Views +import code.views.system.AccountAccess import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.enums.{AccountRoutingScheme, PaymentServiceTypes, TransactionRequestTypes} -import com.openbankproject.commons.model.{SepaCreditTransfers, SepaCreditTransfersBerlinGroupV13, ViewId} +import code.accountholders.AccountHolders +import code.api.util.APIUtil +import com.openbankproject.commons.model.{AccountId, BankIdAccountId, BankIdAccountIdViewId, SepaCreditTransfers, SepaCreditTransfersBerlinGroupV13, User, ViewId} import org.json4s.native.Serialization.write import net.liftweb.mapper.By import org.scalatest.Tag @@ -813,6 +817,137 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with By(MappedBankAccount.theAccountId, routing.accountId.value)) .map(_.balance).openOrThrowException("Can not be empty here") + feature("test the BG v1.3 - the passcode of a payment authorisation sent by EMAIL") { + def startAuthorisationAsUser1(): APIResponse = { + val (paymentId, _, _) = lodgePaymentAsUser1() + makePostRequest( + (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId / "authorisations").POST <@ (user1), + """{"scaAuthenticationData":""}""") + } + + scenario("goes to the user's own address when no customer is linked", BerlinGroupV1_3, PIS, initiatePayment) { + setPropsValues("SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT" -> "EMAIL", "mail.test.mode" -> "true") + CommonsEmailWrapper.clearTestModeSentMail() + // The user's own address is used once the user has signed up here and confirmed it. + val authUser = AuthUser.create.user(resourceUser1.id.get).username(resourceUser1.name) + .email(resourceUser1.emailAddress).validated(true).saveMe() + try { + startAuthorisationAsUser1().code should equal(201) + + CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge").map(_.to) should equal(List(List(resourceUser1.emailAddress))) + } finally AuthUser.bulkDelete_!!(By(AuthUser.id, authUser.id.get)) + } + + scenario("is refused when the user has no confirmed address either", BerlinGroupV1_3, PIS, initiatePayment) { + // resourceUser1 has no linked customer and did not sign up here, so there is no address to send it to. + setPropsValues("SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT" -> "EMAIL", "mail.test.mode" -> "true") + CommonsEmailWrapper.clearTestModeSentMail() + AuthUser.findAll(By(AuthUser.user, resourceUser1.id.get)) shouldBe empty + + val response = startAuthorisationAsUser1() + + response.code should equal(400) + response.body.toString should include(extractErrorMessageCode(ChallengeRecipientNotFound)) + CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge") shouldBe empty + } + } + + private def initiatePaymentsAccessOf(account: BankAccountRouting, user: User = resourceUser1) = + AccountAccess.findAll( + By(AccountAccess.user_fk, user.userPrimaryKey.value), + By(AccountAccess.bank_id, account.bankId.value), + By(AccountAccess.account_id, account.accountId.value), + By(AccountAccess.view_id, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)) + + feature("test the BG v1.3 - the mapped connector grants the payment initiation view to the account holder") { + scenario("refreshing the user gives the holder InitiatePaymentsBerlinGroup on the accounts it holds", + BerlinGroupV1_3, PIS, initiatePayment) { + val account = ibanAccounts.head + initiatePaymentsAccessOf(account) shouldBe empty + + When("the user's accounts are refreshed") + AuthUser.refreshUserLegacy(resourceUser1, None) + + Then("the holder has access through the payment initiation view") + initiatePaymentsAccessOf(account) should not be empty + } + + scenario("a second refresh keeps the view, and a user who holds no account gets none", + BerlinGroupV1_3, PIS, initiatePayment) { + val account = ibanAccounts.head + + When("the holder is refreshed twice and a user without accounts is refreshed") + AuthUser.refreshUserLegacy(resourceUser1, None) + AuthUser.refreshUserLegacy(resourceUser1, None) + AuthUser.refreshUserLegacy(resourceUser2, None) + + Then("the holder still has the view and the other user has none") + initiatePaymentsAccessOf(account) should not be empty + initiatePaymentsAccessOf(account, resourceUser2) shouldBe empty + } + + scenario("a holder who opens another account gets the view on the accounts it already held", + BerlinGroupV1_3, PIS, initiatePayment) { + val account = ibanAccounts.head + AuthUser.refreshUserLegacy(resourceUser1, None) + // As on an instance upgraded from a version that did not grant the view: the holder has the account's + // other views, but not this one. + Views.views.vend.revokeAccess( + BankIdAccountIdViewId(account.bankId, account.accountId, ViewId(SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID)), resourceUser1) + initiatePaymentsAccessOf(account) shouldBe empty + + When("the holder opens another account and is refreshed") + val newAccount = createAccount(account.bankId, AccountId(APIUtil.generateUUID()), "EUR") + AccountHolders.accountHolders.vend.getOrCreateAccountHolder(resourceUser1, BankIdAccountId(newAccount.bankId, newAccount.accountId)) + AuthUser.refreshUserLegacy(resourceUser1, None) + + Then("the account it already held has the view too") + initiatePaymentsAccessOf(account) should not be empty + } + + scenario("a holder who opens another account keeps the views it was given on the accounts it already held", + BerlinGroupV1_3, PIS, initiatePayment) { + val account = ibanAccounts.head + AuthUser.refreshUserLegacy(resourceUser1, None) + // A view the connector does not list, given to the holder some other way (as a VRP mandate's view is). + val auditor = Views.views.vend.getOrCreateSystemView(SYSTEM_AUDITOR_VIEW_ID).openOrThrowException("expected the auditor view") + Views.views.vend.grantAccessToSystemView(account.bankId, account.accountId, auditor, resourceUser1) + def auditorAccess = AccountAccess.findAll( + By(AccountAccess.user_fk, resourceUser1.userPrimaryKey.value), + By(AccountAccess.bank_id, account.bankId.value), + By(AccountAccess.account_id, account.accountId.value), + By(AccountAccess.view_id, SYSTEM_AUDITOR_VIEW_ID)) + auditorAccess should not be empty + + When("the holder opens another account and is refreshed") + val newAccount = createAccount(account.bankId, AccountId(APIUtil.generateUUID()), "EUR") + AccountHolders.accountHolders.vend.getOrCreateAccountHolder(resourceUser1, BankIdAccountId(newAccount.bankId, newAccount.accountId)) + AuthUser.refreshUserLegacy(resourceUser1, None) + + Then("the view on the account it already held is kept") + auditorAccess should not be empty + } + + scenario("a payment can be initiated after the refresh without granting the view by hand", + BerlinGroupV1_3, PIS, initiatePayment) { + val ibanFrom = ibanAccounts.head + val ibanTo = ibanAccounts.last + AuthUser.refreshUserLegacy(resourceUser1, None) + + val initiatePaymentJson = + s"""{ + | "debtorAccount": { "iban": "${ibanFrom.accountRouting.address}" }, + | "instructedAmount": { "currency": "EUR", "amount": "2001" }, + | "creditorAccount": { "iban": "${ibanTo.accountRouting.address}" }, + | "creditorName": "70charname" + }""".stripMargin + val response: APIResponse = makePostRequest( + (V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString).POST <@ (user1), + initiatePaymentJson) + response.code should equal(201) + } + } + private def paymentUrl(paymentId: String) = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId diff --git a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala index 244907d3ae..8e752fa211 100644 --- a/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala +++ b/obp-api/src/test/scala/code/api/berlin/group/v1_3/SigningBasketServiceSBSApiTest.scala @@ -437,6 +437,8 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } scenario("C2: _links.scaStatus of a started authorisation is a {href} object (L4801, L10752)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + // a challenge the user can be sent: with the default SMS and no linked customer there is no one to send it to + setPropsValues("suggested_default_sca_method" -> "DUMMY") val basketId = createBasket(List(lodgePayment())) val response = startAuthorisation(basketId) response.code should equal(201) @@ -486,6 +488,7 @@ class SigningBasketServiceSBSApiTest extends BerlinGroupConsentFixtures { } scenario("C7: POST authorisations accepts the two variants it supports (L3653)", BerlinGroupV1_3, SBS, startSigningBasketAuthorisation) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") val basketId = createBasket(List(lodgePayment())) startAuthorisation(basketId, body = "{}").code should equal(201) startAuthorisation(basketId, body = """{"scaAuthenticationData":"123"}""").code should equal(201) diff --git a/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala b/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala index cf375a0f91..7e38db773a 100644 --- a/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala +++ b/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala @@ -131,6 +131,32 @@ class RequestScopeConnectionTest extends FeatureSpec with Matchers with GivenWhe t.rollbackCount shouldBe 0 } + scenario("rollback to a savepoint on the proxy reaches the real connection") { + Given("A real connection that records the savepoint it is rolled back to") + val savepoint = new java.sql.Savepoint { + def getSavepointId: Int = 1 + def getSavepointName: String = "cascade" + } + + var rolledBackTo: Option[AnyRef] = None + val real = JProxy.newProxyInstance( + classOf[Connection].getClassLoader, + Array(classOf[Connection]), + new InvocationHandler { + def invoke(proxy: Any, method: Method, args: Array[AnyRef]): AnyRef = { + if (method.getName == "rollback" && args != null && args.length == 1) rolledBackTo = Some(args(0)) + null + } + } + ).asInstanceOf[Connection] + + When("rollback(savepoint) is called on the proxy, as a cascade that failed does") + RequestScopeConnection.makeProxy(real).rollback(savepoint) + + Then("The real connection rolls back to that savepoint, so the cascade can undo its own writes") + rolledBackTo shouldBe Some(savepoint) + } + scenario("close on the proxy does not reach the real connection") { Given("A tracked real connection wrapped in a proxy") val t = new ConnectionTracker diff --git a/obp-api/src/test/scala/code/api/v3_1_0/CardAttributeTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/CardAttributeTest.scala index a43c657035..605f812807 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/CardAttributeTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/CardAttributeTest.scala @@ -38,6 +38,11 @@ import code.api.util.ApiRole import code.api.util.ErrorMessages.UserHasMissingRoles import com.openbankproject.commons.model.ErrorMessage import code.api.v3_1_0.Http4s310.Implementations3_1_0 +import code.cardattribute.MappedCardAttribute +import code.cards.MappedPhysicalCard +import code.model.dataAccess.MappedBankAccount +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString import code.entitlement.Entitlement import code.setup.DefaultUsers import com.github.dwickern.macros.NameOf.nameOf @@ -157,4 +162,38 @@ class CardAttributeTest extends V310ServerSetup with DefaultUsers { } } -} + feature("Deleting a card deletes its attributes") { + scenario("Delete Card removes the attributes of the card", ApiEndpointDeleteCardForBank, VersionOfApi) { + Given("a card with an attribute") + val account = MappedBankAccount.find( + By(MappedBankAccount.bank, testBankId1.value), + By(MappedBankAccount.theAccountId, testAccountId1.value) + ).openOrThrowException("test account not found") + val cardId = MappedPhysicalCard.create + .mBankId(testBankId1.value) + .mAccount(account.id.get) + .mBankCardNumber(randomString(16)) + .mSerialNumber(randomString(10)) + .saveMe() + .mCardId.get + MappedCardAttribute.create + .mBankId(testBankId1.value) + .mCardId(cardId) + .mName("TAX_NUMBER") + .mType("STRING") + .mValue("123") + .saveMe() + + When("the card is deleted") + // the Resource Doc of Delete Card names CanCreateCardsForBank as well as CanDeleteCardsForBank + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canCreateCardsForBank.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + val response = makeDeleteRequest((v3_1_0_Request / "management" / "banks" / testBankId1.value / "cards" / cardId).DELETE <@ (user1)) + + Then("We should get a 204 and no attribute of the card is left") + response.code should equal(204) + MappedPhysicalCard.find(By(MappedPhysicalCard.mCardId, cardId)).isDefined should equal(false) + MappedCardAttribute.findAll(By(MappedCardAttribute.mCardId, cardId)) shouldBe empty + } + } +} diff --git a/obp-api/src/test/scala/code/api/v3_1_0/CardTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/CardTest.scala index 01b414b1f2..148a180d4a 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/CardTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/CardTest.scala @@ -270,4 +270,39 @@ class CardTest extends V310ServerSetup with DefaultUsers { } } -} + feature("Delete Card checks only the delete role") { + scenario("A user with CanDeleteCardsForBank alone deletes a card", ApiEndpointDeleteCardForBank, VersionOfApi) { + Given("A card created by user1") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, CanCreateCustomer.toString) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canCreateCardsForBank.toString) + val customerId = makePostRequest( + (v3_1_0_Request / "banks" / testBankId1.value / "customers").POST <@ (user1), + write(SwaggerDefinitionsJSON.postCustomerJsonV310) + ).body.extract[CustomerJsonV310].customer_id + val cardJson = createPhysicalCardJsonV310.copy(account_id = testAccountId1.value, issue_number = "123", customer_id = customerId) + val responseCreate = makePostRequest((v3_1_0_Request / "management" / "banks" / testBankId1.value / "cards").POST <@ (user1), write(cardJson)) + responseCreate.code should equal(201) + val cardId = responseCreate.body.extract[PhysicalCardJsonV310].card_id + val requestDelete = (v3_1_0_Request / "management" / "banks" / testBankId1.value / "cards" / cardId).DELETE <@ (user2) + + When("user2 holds only CanCreateCardsForBank") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, ApiRole.canCreateCardsForBank.toString) + val responseCreateRoleOnly = makeDeleteRequest(requestDelete) + Then("the delete is refused for the missing delete role") + responseCreateRoleOnly.code should equal(403) + responseCreateRoleOnly.body.toString should include(ApiRole.canDeleteCardsForBank.toString) + + When("user2 holds only CanDeleteCardsForBank") + Entitlement.entitlement.vend.deleteEntitlement( + Entitlement.entitlement.vend.getEntitlement(testBankId1.value, resourceUser2.userId, ApiRole.canCreateCardsForBank.toString)) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser2.userId, ApiRole.canDeleteCardsForBank.toString) + val responseDelete = makeDeleteRequest(requestDelete) + Then("the card is deleted") + responseDelete.code should equal(204) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canGetCardsForBank.toString) + makeGetRequest((v3_1_0_Request / "management" / "banks" / testBankId1.value / "cards" / cardId).GET <@ (user1)) + .body.toString should include(CardNotFound) + } + } + +} diff --git a/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala index 4d22cea4b6..6ae11ffaa7 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala @@ -36,7 +36,10 @@ import code.api.util.{ApiRole, RateLimitingUtil} import code.api.util.ApiRole.{CanReadCallLimits, CanUpdateRateLimits} import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired} import code.api.v3_1_0.Http4s310.Implementations3_1_0 +import code.api.cache.Caching import code.consumer.Consumers +import code.ratelimiting.RateLimiting +import net.liftweb.mapper.By import code.entitlement.Entitlement import code.setup.PropsReset import com.github.dwickern.macros.NameOf.nameOf @@ -47,6 +50,18 @@ import org.scalatest.Tag class RateLimitTest extends V310ServerSetup with PropsReset { + /** Sets the consumer's call limits as another consumer, so this consumer starts the period with no calls made. */ + private def setLimitAsAnotherConsumer(consumerId: String, json: CallLimitPostJson) = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(json)) + } + + /** The consumer's rate limits are gone, and so is the cached copy: the limit just set would refuse the next PUT. */ + private def removeRateLimits(consumerId: String): Unit = { + RateLimiting.bulkDelete_!!(By(RateLimiting.ConsumerId, consumerId)) + Caching.invalidateRateLimitCache(consumerId) + } + /** * Test tags * Example: To run tests with tag "getPermissions": @@ -186,147 +201,61 @@ class RateLimitTest extends V310ServerSetup with PropsReset { val id: Long = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.id.get).getOrElse(0) Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanUpdateRateLimits.toString) val request310 = (v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user1) - val response01 = makePutRequest(request310, write(callLimitSecondJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitSecondJson) Then("We should get a 200") response01.code should equal(200) When("We make the first call after update") + val secondStarted = System.nanoTime() val response02 = makePutRequest(request310, write(callLimitSecondJson)) Then("We should get a 200") response02.code should equal(200) When("We make the second call after update") + // Redis rounds the counter's TTL to whole seconds, so the one-second window reads as expired about half a second + // after the first call: a slower run cannot tell whether the limit was applied. + assume((System.nanoTime() - secondStarted) / 1000000 < 400, "the first call took too long to test a per-second limit") val response03 = makePutRequest(request310, write(callLimitSecondJson)) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) - - // Revert to initial state - Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) - } - scenario("We will set calls limit per minute for a Consumer", ApiEndpoint, VersionOfApi) { - - When("We make a request v3.1.0 with a Role " + ApiRole.canUpdateRateLimits) - val Some((c, _)) = user1 - val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") - val id: Long = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.id.get).getOrElse(0) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanUpdateRateLimits.toString) - val request310 = (v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user1) - val response01 = makePutRequest(request310, write(callLimitMinuteJson)) - Then("We should get a 200") - response01.code should equal(200) - - When("We make the first call after update") - val response02 = makePutRequest(request310, write(callLimitMinuteJson)) - Then("We should get a 200") - response02.code should equal(200) - - When("We make the second call after update") - val response03 = makePutRequest(request310, write(callLimitMinuteJson)) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) - - // Revert to initial state - Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) - } - scenario("We will set calls limit per hour for a Consumer", ApiEndpoint, VersionOfApi) { - - When("We make a request v3.1.0 with a Role " + ApiRole.canUpdateRateLimits) - val Some((c, _)) = user1 - val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") - val id: Long = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.id.get).getOrElse(0) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanUpdateRateLimits.toString) - val request310 = (v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user1) - val response01 = makePutRequest(request310, write(callLimitHourJson)) - Then("We should get a 200") - response01.code should equal(200) - - When("We make the first call after update") - val response02 = makePutRequest(request310, write(callLimitHourJson)) - Then("We should get a 200") - response02.code should equal(200) - - When("We make the second call after update") - val response03 = makePutRequest(request310, write(callLimitHourJson)) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) + removeRateLimits(consumerId) } - scenario("We will set calls limit per day for a Consumer", ApiEndpoint, VersionOfApi) { - - When("We make a request v3.1.0 with a Role " + ApiRole.canUpdateRateLimits) - val Some((c, _)) = user1 - val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") - val id: Long = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.id.get).getOrElse(0) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanUpdateRateLimits.toString) - val request310 = (v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user1) - val response01 = makePutRequest(request310, write(callLimitDayJson)) - Then("We should get a 200") - response01.code should equal(200) - - When("We make the first call after update") - val response02 = makePutRequest(request310, write(callLimitDayJson)) - Then("We should get a 200") - response02.code should equal(200) - - When("We make the second call after update") - val response03 = makePutRequest(request310, write(callLimitDayJson)) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) - - // Revert to initial state - Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) - } - scenario("We will set calls limit per week for a Consumer", ApiEndpoint, VersionOfApi) { - - When("We make a request v3.1.0 with a Role " + ApiRole.canUpdateRateLimits) - val Some((c, _)) = user1 - val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") - val id: Long = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.id.get).getOrElse(0) - Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanUpdateRateLimits.toString) - val request310 = (v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user1) - val response01 = makePutRequest(request310, write(callLimitWeekJson)) - Then("We should get a 200") - response01.code should equal(200) - - When("We make the first call after update") - val response02 = makePutRequest(request310, write(callLimitWeekJson)) - Then("We should get a 200") - response02.code should equal(200) - - When("We make the second call after update") - val response03 = makePutRequest(request310, write(callLimitWeekJson)) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) - - // Revert to initial state - Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) - } - scenario("We will set calls limit per month for a Consumer", ApiEndpoint, VersionOfApi) { - - When("We make a request v3.1.0 with a Role " + ApiRole.canUpdateRateLimits) + // The same steps for each period longer than a second; the per-second scenario above also checks its timing. + List( + "minute" -> callLimitMinuteJson, + "hour" -> callLimitHourJson, + "day" -> callLimitDayJson, + "week" -> callLimitWeekJson, + "month" -> callLimitMonthJson + ).foreach { case (period, callLimitJson) => + scenario(s"We will set calls limit per $period for a Consumer", ApiEndpoint, VersionOfApi) { + When("We make a request v3.1.0 with a Role " + ApiRole.canUpdateRateLimits) val Some((c, _)) = user1 val consumerId = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.consumerId.get).getOrElse("") val id: Long = Consumers.consumers.vend.getConsumerByConsumerKey(c.key).map(_.id.get).getOrElse(0) Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, ApiRole.CanUpdateRateLimits.toString) val request310 = (v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user1) - val response01 = makePutRequest(request310, write(callLimitMonthJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitJson) Then("We should get a 200") response01.code should equal(200) When("We make the first call after update") - val response02 = makePutRequest(request310, write(callLimitMonthJson)) + val response02 = makePutRequest(request310, write(callLimitJson)) Then("We should get a 200") response02.code should equal(200) When("We make the second call after update") - val response03 = makePutRequest(request310, write(callLimitMonthJson)) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + val response03 = makePutRequest(request310, write(callLimitJson)) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) + removeRateLimits(consumerId) + } } } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteAtomicityTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteAtomicityTest.scala new file mode 100644 index 0000000000..074c99927a --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteAtomicityTest.scala @@ -0,0 +1,148 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v4_0_0 + +import code.api.Constant +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON +import code.api.util.APIUtil.OAuth._ +import code.api.util.APIUtil.extractErrorMessageCode +import code.api.util.ApiRole +import code.api.util.ErrorMessages.CouldNotDeleteCascade +import code.api.v3_1_0.CreateAccountResponseJsonV310 +import code.api.v4_0_0.Http4s400.Implementations4_0_0 +import code.entitlement.Entitlement +import code.methodrouting.{MethodRouting, MethodRoutingCommons, MethodRoutingProvider} +import code.model.dataAccess.{MappedBank, MappedBankAccount} +import code.views.system.AccountAccess +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.{AmountOfMoneyJsonV121, ErrorMessage} +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.db.DB +import net.liftweb.mapper.By +import net.liftweb.util.DefaultConnectionIdentifier +import org.json4s.native.Serialization.write +import org.scalatest.Tag + +/** + * A cascading delete either removes everything it covers or nothing: one that fails part way must leave every row + * it had already deleted in place. + */ +class CascadeDeleteAtomicityTest extends V400ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + object DeleteAccountCascade extends Tag(nameOf(Implementations4_0_0.deleteAccountCascade)) + object DeleteBankCascade extends Tag(nameOf(Implementations4_0_0.deleteBankCascade)) + + private def createAccountWithSecondViewer(bankId: String): String = { + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canCreateAccount.toString) + val addAccountJson = SwaggerDefinitionsJSON.createAccountRequestJsonV310 + .copy(user_id = resourceUser1.userId, balance = AmountOfMoneyJsonV121("EUR", "0")) + val response = makePostRequest((v4_0_0_Request / "banks" / bankId / "accounts").POST <@ (user1), write(addAccountJson)) + response.code should equal(201) + val accountId = response.body.extract[CreateAccountResponseJsonV310].account_id + grantUserAccessToViewViaEndpoint(bankId, accountId, resourceUser2.userId, user1, + PostViewJsonV400(view_id = Constant.SYSTEM_OWNER_VIEW_ID, is_system = true)) + accountId + } + + private def accountExists(bankId: String, accountId: String): Boolean = + MappedBankAccount.find(By(MappedBankAccount.bank, bankId), By(MappedBankAccount.theAccountId, accountId)).isDefined + + private def accessRows(bankId: String, accountId: String): Int = + AccountAccess.count(By(AccountAccess.bank_id, bankId), By(AccountAccess.account_id, accountId)).toInt + + /** The account cascade lists the account's transactions through the connector; one that cannot fails the step. */ + private def withTransactionsUnreadable[T](block: => T): T = { + val routing = MethodRoutingProvider.connectorMethodProvider.vend.createOrUpdate(MethodRoutingCommons( + "getTransactionsLegacy", "cardano_vJun2025", isBankIdExactMatch = false, bankIdPattern = Some(MethodRouting.bankIdPatternMatchAny) + )).openOrThrowException("expected the method routing to be created") + try block + finally MethodRoutingProvider.connectorMethodProvider.vend.delete(routing.methodRoutingId.get) + } + + private def runSql(sql: String): Unit = + DB.use(DefaultConnectionIdentifier) { conn => DB.prepareStatement(sql, conn)(_.executeUpdate()) } + + feature("A cascading delete that fails part way deletes nothing") { + + scenario("An account cascade whose transactions cannot be read keeps the account and its access", DeleteAccountCascade, VersionOfApi) { + val bankId = randomBankId + val accountId = createAccountWithSecondViewer(bankId) + val accessBefore = accessRows(bankId, accountId) + accessBefore should be >= 2 + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + + val response = withTransactionsUnreadable { + makeDeleteRequest((v4_0_0_Request / "management" / "cascading" / "banks" / bankId / "accounts" / accountId).DELETE <@ (user1)) + } + + response.code should equal(400) + response.body.extract[ErrorMessage].message should include(extractErrorMessageCode(CouldNotDeleteCascade)) + accountExists(bankId, accountId) should equal(true) + accessRows(bankId, accountId) should equal(accessBefore) + } + + scenario("A bank cascade whose account cannot be deleted keeps the bank and the account", DeleteBankCascade, VersionOfApi) { + val bankId = createBank(code.api.util.APIUtil.generateUUID()).bankId.value + val accountId = createAccountWithSecondViewer(bankId) + val accessBefore = accessRows(bankId, accountId) + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + + val response = withTransactionsUnreadable { + makeDeleteRequest((v4_0_0_Request / "management" / "cascading" / "banks" / bankId).DELETE <@ (user1)) + } + + response.code should equal(400) + MappedBank.find(By(MappedBank.permalink, bankId)).isDefined should equal(true) + accountExists(bankId, accountId) should equal(true) + accessRows(bankId, accountId) should equal(accessBefore) + } + + scenario("An account cascade that a database error stops keeps everything it had deleted", DeleteAccountCascade, VersionOfApi) { + val bankId = randomBankId + val accountId = createAccountWithSecondViewer(bankId) + val accessBefore = accessRows(bankId, accountId) + val accountPk = MappedBankAccount.find(By(MappedBankAccount.bank, bankId), By(MappedBankAccount.theAccountId, accountId)) + .openOrThrowException("expected the account").id.get + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + // A row that still refers to the account, so deleting the account itself, the last step, fails. On H2 (and + // MySQL, SQL Server, Oracle) the deletes before it stay in the transaction; Postgres aborts the whole one. + val table = MappedBankAccount.dbTableName + runSql(s"CREATE TABLE cascade_atomicity_blocker (account_pk BIGINT REFERENCES $table(id))") + try { + runSql(s"INSERT INTO cascade_atomicity_blocker (account_pk) VALUES ($accountPk)") + + val response = makeDeleteRequest( + (v4_0_0_Request / "management" / "cascading" / "banks" / bankId / "accounts" / accountId).DELETE <@ (user1)) + + response.code should not equal 200 + accountExists(bankId, accountId) should equal(true) + accessRows(bankId, accountId) should equal(accessBefore) + } finally runSql("DROP TABLE cascade_atomicity_blocker") + } + } +} diff --git a/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteDisabledTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteDisabledTest.scala new file mode 100644 index 0000000000..2ae38adf83 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteDisabledTest.scala @@ -0,0 +1,93 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v4_0_0 + +import code.api.util.ErrorMessages.InvalidUri +import code.api.util.APIUtil.OAuth._ +import com.openbankproject.commons.util.ApiVersion +import org.json4s.JString +import org.scalatest.Tag + +// Boots its own JVM with allow_cascade_delete false, via OBP_ALLOW_CASCADE_DELETE=false (see +// run_tests_parallel.sh and CI's shard 10). Whether the cascade-delete endpoints exist is decided +// when the API objects initialise, so a JVM sees only one value of the prop; every other shard +// runs with it true, and CascadeDeleteEndpointsTest covers that case. +class CascadeDeleteDisabledTest extends V400ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + object PropGatedPublicEndpoint extends Tag("PropGatedPublicEndpoint") + + private def v6_0_0_Request = baseRequest / "obp" / "v6.0.0" + private def v7_0_0_Request = baseRequest / "obp" / "v7.0.0" + + feature("allow_cascade_delete=false") { + scenario("The prop is off in this JVM", PropGatedPublicEndpoint, VersionOfApi) { + CascadeDeleteEndpoints.enabled should be(false) + } + + scenario("No cascade-delete endpoint is routed or listed in the Resource Docs", PropGatedPublicEndpoint, VersionOfApi) { + CascadeDeleteEndpoints.gated.foreach { case (where, names) => + withClue(where) { names should be(empty) } + } + } + + scenario("The cascade-delete roles are not offered", PropGatedPublicEndpoint, VersionOfApi) { + CascadeDeleteEndpoints.offeredRoles should be(empty) + } + + scenario("The Dynamic Entity cascade deletes stay routed", PropGatedPublicEndpoint, VersionOfApi) { + CascadeDeleteEndpoints.dynamicEntityCascadesRouted should be(true) + } + + scenario("A request to any cascade-delete path gets 404", PropGatedPublicEndpoint, VersionOfApi) { + val bankId = testBankId1.value + val requests = List( + v4_0_0_Request / "management" / "cascading" / "banks" / bankId / "accounts" / "ACCOUNT_ID" / "transactions" / "TRANSACTION_ID", + v4_0_0_Request / "management" / "cascading" / "banks" / bankId / "accounts" / "ACCOUNT_ID", + v4_0_0_Request / "management" / "cascading" / "banks" / bankId, + v4_0_0_Request / "management" / "cascading" / "banks" / bankId / "products" / "PRODUCT_CODE", + v4_0_0_Request / "management" / "cascading" / "banks" / bankId / "customers" / "CUSTOMER_ID", + // Every later version hands paths it has no route for down to the version below it. + v5_0_0_Request / "management" / "cascading" / "banks" / bankId, + v5_1_0_Request / "management" / "cascading" / "banks" / bankId, + v6_0_0_Request / "management" / "cascading" / "banks" / bankId, + v7_0_0_Request / "management" / "cascading" / "banks" / bankId + ) + requests.foreach { request => + val response = makeDeleteRequest(request <@ (user1)) + withClue(request.baseUrl) { + response.code should equal(404) + (response.body \ "message") match { + case JString(message) => message should startWith(InvalidUri) + case other => fail(s"no message in $other") + } + } + } + } + } +} diff --git a/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteEndpointsTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteEndpointsTest.scala new file mode 100644 index 0000000000..f2c6656369 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteEndpointsTest.scala @@ -0,0 +1,116 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ + +package code.api.v4_0_0 + +import code.api.util.{APIUtil, ApiRole} +import code.api.util.APIUtil.{Http4sHandler, ResourceDoc} +import code.api.util.http4s.Http4sResourceDocAggregation +import code.api.v4_0_0.Http4s400.Implementations4_0_0 +import code.api.v6_0_0.Http4s600.Implementations6_0_0 +import code.api.v7_0_0.Http4s700.Implementations7_0_0 +import code.api.v7_0_0.Http4s700DynamicEntityDefinitions +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.util.ApiVersion +import org.scalatest.Tag + +/** + * With allow_cascade_delete=true, which every ordinary shard runs with, the cascade-delete + * endpoints are routed and documented. CascadeDeleteDisabledTest covers the default, false, in a + * JVM of its own: the routes and ResourceDocs are fixed when the API objects initialise. + */ +class CascadeDeleteEndpointsTest extends V400ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + + feature("allow_cascade_delete=true") { + scenario("The prop is on in this JVM", VersionOfApi) { + withClue("allow_cascade_delete must be true in test.default.props (see test.default.props.template); " + + "the Delete*CascadeTest suites get 404 without it: ") { + CascadeDeleteEndpoints.enabled should be(true) + } + } + + scenario("Every cascade-delete endpoint is routed and listed in the Resource Docs", VersionOfApi) { + CascadeDeleteEndpoints.gated.foreach { case (where, names) => + withClue(where) { names should equal(CascadeDeleteEndpoints.gatedNames.toSet) } + } + } + + scenario("The cascade-delete roles are offered", VersionOfApi) { + CascadeDeleteEndpoints.offeredRoles should equal(CascadeDeleteEndpoints.gatedRoleNames) + } + + scenario("The Dynamic Entity cascade deletes are routed", VersionOfApi) { + CascadeDeleteEndpoints.dynamicEntityCascadesRouted should be(true) + } + } +} + +/** The cascade-delete endpoints that allow_cascade_delete switches on and off, and the ones it leaves alone. */ +object CascadeDeleteEndpoints { + + private val gatedRoutes: List[(String, Http4sHandler)] = List( + nameOf(Implementations4_0_0.deleteTransactionCascade) -> Implementations4_0_0.deleteTransactionCascade, + nameOf(Implementations4_0_0.deleteAccountCascade) -> Implementations4_0_0.deleteAccountCascade, + nameOf(Implementations4_0_0.deleteBankCascade) -> Implementations4_0_0.deleteBankCascade, + nameOf(Implementations4_0_0.deleteProductCascade) -> Implementations4_0_0.deleteProductCascade, + nameOf(Implementations4_0_0.deleteCustomerCascade) -> Implementations4_0_0.deleteCustomerCascade + ) + + val gatedNames: List[String] = gatedRoutes.map(_._1) + + private def names(docs: Iterable[ResourceDoc]): Set[String] = docs.map(_.partialFunctionName).toSet + + /** The gated names in each list the endpoints are routed or documented from. */ + def gated: Map[String, Set[String]] = Map( + // The route chain itself, not only the docs derived from it. + "v4.0.0 routes" -> gatedRoutes.collect { case (name, route) if Implementations4_0_0.routesInOrder.contains(route) => name }.toSet, + "v4.0.0 routed docs" -> (names(Implementations4_0_0.orderedResourceDocs) intersect gatedNames.toSet), + "v4.0.0 catalog" -> (names(Http4sResourceDocAggregation.v400) intersect gatedNames.toSet), + "v6.0.0 catalog" -> (names(Http4sResourceDocAggregation.v600) intersect gatedNames.toSet) + ) + + /** The Dynamic Entity cascade deletes, which back up first and stay routed whatever the prop says. */ + def dynamicEntityCascadesRouted: Boolean = + names(Implementations6_0_0.orderedResourceDocs).contains(nameOf(Implementations6_0_0.deleteSystemDynamicEntityCascade)) && + names(Implementations7_0_0.orderedResourceDocs).contains(nameOf(Http4s700DynamicEntityDefinitions.deleteDynamicEntityDefinitionCascade)) + + /** The roles only the gated endpoints check. */ + val gatedRoleNames: Set[String] = Set[ApiRole]( + ApiRole.canDeleteTransactionCascade, + ApiRole.canDeleteAccountCascade, + ApiRole.canDeleteBankCascade, + ApiRole.canDeleteProductCascade, + ApiRole.canDeleteCustomerCascade + ).map(_.toString) + + /** The gated roles GET /roles lists and entitlement creation accepts by name. */ + def offeredRoles: Set[String] = ApiRole.availableRoles.toSet intersect gatedRoleNames + + def enabled: Boolean = APIUtil.cascadeDeleteEnabled +} diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ConsentTests.scala b/obp-api/src/test/scala/code/api/v4_0_0/ConsentTests.scala index 67c4949c13..3f366b7959 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/ConsentTests.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/ConsentTests.scala @@ -30,7 +30,9 @@ package code.api.v4_0_0 import code.api.util.APIUtil.OAuth._ import code.api.util.ErrorMessages import code.api.v4_0_0.Http4s400.Implementations4_0_0 -import code.consent.Consents +import code.api.berlin.group.ConstantsBG +import code.api.v3_1_0.ConsentJsonV310 +import code.consent.{ConsentStatus, Consents} import code.setup.DefaultUsers import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage @@ -100,4 +102,55 @@ class ConsentTests extends V400ServerSetup with DefaultUsers { } } + + feature(s"Assuring that endpoint $ApiEndpoint2 lets the PSU refuse a consent it has not authorised - v4.0.0") { + + def reject(consentId: String) = makePutRequest( + (v4_0_0_Request / "banks" / testBankId1.value / "consents" / consentId).PUT <@ (user1), + write(PutConsentStatusJsonV400(status = "REJECTED"))) + + def storedStatus(consentId: String) = + Consents.consentProvider.vend.getConsentByConsentId(consentId).openOrThrowException("consent").status + + scenario(s"We refuse an OBP consent awaiting its challenge, with SCA enabled - $ApiEndpoint2", ApiEndpoint2, VersionOfApi) { + setPropsValues("consents.sca.enabled" -> "true") + Given("An OBP consent of user1 that is still INITIATED") + val consent = Consents.consentProvider.vend.createObpConsent(resourceUser1, "12345678", None, None) + .openOrThrowException("consent") + When("user1 sets it to REJECTED") + val response = reject(consent.consentId) + Then("The consent is rejected, and the answer says so") + response.code should equal(200) + response.body.extract[ConsentJsonV310].status should equal(ConsentStatus.REJECTED.toString) + storedStatus(consent.consentId) should equal(ConsentStatus.REJECTED.toString) + } + + scenario(s"We refuse a Berlin Group consent awaiting authorisation, with SCA enabled - $ApiEndpoint2", ApiEndpoint2, VersionOfApi) { + setPropsValues("consents.sca.enabled" -> "true") + Given("A Berlin Group consent of user1 that is still received") + val consent = Consents.consentProvider.vend.createBerlinGroupConsent( + user = Some(resourceUser1), consumer = None, recurringIndicator = false, + validUntil = new java.util.Date(System.currentTimeMillis() + 86400000L), frequencyPerDay = 1, + combinedServiceIndicator = false, apiStandard = Some(ConstantsBG.berlinGroupVersion1.apiStandard), + apiVersion = Some(ConstantsBG.berlinGroupVersion1.apiShortVersion) + ).openOrThrowException("consent") + When("user1 sets it to REJECTED, as Portal's Deny does") + val response = reject(consent.consentId) + Then("The consent carries Berlin Group's rejected status") + response.code should equal(200) + storedStatus(consent.consentId) should equal(ConsentStatus.rejected.toString) + } + + scenario(s"An authorised consent keeps its status, with SCA enabled - $ApiEndpoint2", ApiEndpoint2, VersionOfApi) { + setPropsValues("consents.sca.enabled" -> "true") + Given("An OBP consent of user1 that is ACCEPTED") + val consent = Consents.consentProvider.vend.createObpConsent(resourceUser1, "12345678", None, None) + .openOrThrowException("consent") + Consents.consentProvider.vend.updateConsentStatus(consent.consentId, ConsentStatus.ACCEPTED) + When("user1 sets it to REJECTED") + reject(consent.consentId).code should equal(200) + Then("Its status is unchanged: ending an authorised consent is a revocation, not a refusal") + storedStatus(consent.consentId) should equal(ConsentStatus.ACCEPTED.toString) + } + } } \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala index f9b5b2f8bf..4c8676b2cd 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala @@ -28,6 +28,7 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 import org.json4s._ +import code.accountholders.MapperAccountHolders import code.api.Constant import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createViewJsonV300 @@ -37,9 +38,11 @@ import code.api.util.ApiRole.CanDeleteAccountCascade import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired} import code.api.v3_1_0.CreateAccountResponseJsonV310 import code.api.v4_0_0.Http4s400.Implementations4_0_0 +import code.cardattribute.MappedCardAttribute import code.cards.MappedPhysicalCard import code.entitlement.{Entitlement, MappedEntitlement} import code.model.dataAccess.MappedBankAccount +import code.views.system.AccountAccess import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage} import com.openbankproject.commons.util.ApiVersion @@ -62,6 +65,8 @@ class DeleteAccountCascadeTest extends V400ServerSetup { object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.deleteAccountCascade)) + private val deleteAtFirstBank = "We delete the account at the first bank with the cascading endpoint" + lazy val bankId = randomBankId lazy val bankAccount = randomPrivateAccountViaEndpoint(bankId) lazy val addAccountJson = SwaggerDefinitionsJSON.createAccountRequestJsonV310.copy(user_id = resourceUser1.userId, balance = AmountOfMoneyJsonV121("EUR","0")) @@ -195,7 +200,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { val card = addCard(bankId) val otherBankCard = addCard(otherBankId) - When("We delete the account at the first bank with the cascading endpoint") + When(deleteAtFirstBank) Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value / "accounts" / accountId.value).DELETE <@ (user1) @@ -210,6 +215,65 @@ class DeleteAccountCascadeTest extends V400ServerSetup { } } + feature(s"test $ApiEndpoint1 version $VersionOfApi - card attributes") { + scenario("Deleting an account deletes the attributes of its cards and keeps those of the same ACCOUNT_ID at another bank", ApiEndpoint1, VersionOfApi) { + Given("Two banks that each have an account with the same ACCOUNT_ID, each with a card that has an attribute") + val bankId = BankId(randomString(10)) + val otherBankId = BankId(randomString(10)) + createBank(bankId.value) + createBank(otherBankId.value) + val accountId = AccountId(randomString(10)) + createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR") + createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR") + + def addCardWithAttribute(bank: BankId): String = { + val account = MappedBankAccount.find( + By(MappedBankAccount.bank, bank.value), + By(MappedBankAccount.theAccountId, accountId.value) + ).openOrThrowException("test account not found") + val cardId = MappedPhysicalCard.create + .mBankId(bank.value) + .mAccount(account.id.get) + .mBankCardNumber(randomString(16)) + .mSerialNumber(randomString(10)) + .saveMe() + .mCardId.get + MappedCardAttribute.create + .mBankId(bank.value) + .mCardId(cardId) + .mName("TAX_NUMBER") + .mType("STRING") + .mValue("123") + .saveMe() + cardId + } + def attributesOf(cardId: String) = MappedCardAttribute.findAll(By(MappedCardAttribute.mCardId, cardId)) + val card = addCardWithAttribute(bankId) + val otherBankCard = addCardWithAttribute(otherBankId) + // an attribute that another bank posted for this card id is not the deleted account's data + MappedCardAttribute.create + .mBankId(otherBankId.value) + .mCardId(card) + .mName("OTHER_BANK") + .mType("STRING") + .mValue("1") + .saveMe() + + When(deleteAtFirstBank) + Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value / + "accounts" / accountId.value).DELETE <@ (user1) + val response = makeDeleteRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The deleted account's card has no attributes left and the other bank's card keeps its attribute") + attributesOf(card).map(_.mBankId.get) should equal(List(otherBankId.value)) + attributesOf(otherBankCard) should have size 1 + } + } + feature(s"test $ApiEndpoint1 version $VersionOfApi - entitlements") { scenario("Deleting an account keeps the entitlements of users who only use an account with the same ACCOUNT_ID at another bank", ApiEndpoint1, VersionOfApi) { Given("Two banks that each have an account with the same ACCOUNT_ID, owned by different users") @@ -229,7 +293,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { ) otherOwnerEntitlements should equal(1L) - When("We delete the account at the first bank with the cascading endpoint") + When(deleteAtFirstBank) Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value / "accounts" / accountId.value).DELETE <@ (user1) @@ -243,4 +307,68 @@ class DeleteAccountCascadeTest extends V400ServerSetup { } } + private def accountHolders(bankId: String, accountId: String): List[MapperAccountHolders] = + MapperAccountHolders.findAll( + By(MapperAccountHolders.accountBankPermalink, bankId), + By(MapperAccountHolders.accountPermalink, accountId) + ) + + private def accountAccess(bankId: String, accountId: String): List[AccountAccess] = + AccountAccess.findAll( + By(AccountAccess.bank_id, bankId), + By(AccountAccess.account_id, accountId) + ) + + feature(s"test $ApiEndpoint1 version $VersionOfApi - account holders") { + scenario("Deleting an account removes its holders, so a later refresh of the former holder grants no views on it", ApiEndpoint1, VersionOfApi) { + Given("An account created through the API, which makes the user its holder and grants the user its system views") + val bankId = createBank(APIUtil.generateUUID()).bankId.value + val deletedAccountId = createAccountViaEndpoint(bankId, addAccountJson, user1).account_id + accountHolders(bankId, deletedAccountId) should not be empty + accountAccess(bankId, deletedAccountId) should not be empty + + When("We delete the account with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId / + "accounts" / deletedAccountId).DELETE <@ (user1) + makeDeleteRequest(request).code should equal(200) + + Then("The account's holders and account access are gone") + accountHolders(bankId, deletedAccountId) shouldBe empty + accountAccess(bankId, deletedAccountId) shouldBe empty + + When("The former holder reads the deleted account") + val getRequest = (v5_1_0_Request / "banks" / bankId / "accounts" / deletedAccountId / + Constant.SYSTEM_OWNER_VIEW_ID / "account").GET <@ (user1) + makeGetRequest(getRequest).code should equal(404) + + And("The former holder creates another account, which refreshes the views of every account the user holds") + createAccountViaEndpoint(bankId, addAccountJson, user1) + + Then("No account access is granted on the deleted account again") + accountAccess(bankId, deletedAccountId) shouldBe empty + } + + scenario("Deleting an account keeps the holders of an account with the same ACCOUNT_ID at another bank", ApiEndpoint1, VersionOfApi) { + Given("Two banks that each have an account with the same ACCOUNT_ID, held by the same user") + val bankId = BankId(randomString(10)) + val otherBankId = BankId(randomString(10)) + createBank(bankId.value) + createBank(otherBankId.value) + val accountId = AccountId(randomString(10)) + createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR") + createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR") + + When("We delete the first bank's account, keeping the other bank's one") + Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) + val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value / + "accounts" / accountId.value).DELETE <@ (user1) + makeDeleteRequest(request).code should equal(200) + + Then("The deleted account's holder is gone and the other bank's holder is kept") + accountHolders(bankId.value, accountId.value) shouldBe empty + accountHolders(otherBankId.value, accountId.value).size should equal(1) + } + } + } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala index 65b990002d..37a6435ca9 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala @@ -28,6 +28,10 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 import org.json4s._ +import code.DynamicData.{DynamicData, DynamicDataAccess, DynamicDataProvider} +import code.accountholders.MapperAccountHolders +import code.dynamicEntity.{DynamicEntity, DynamicEntityCommons, DynamicEntityProvider} +import net.liftweb.util.StringHelpers import code.api.Constant import java.util.concurrent.TimeUnit @@ -39,10 +43,11 @@ import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequ import code.api.util.{APIUtil, ApiRole} import code.api.v3_1_0.CreateAccountResponseJsonV310 import code.api.v4_0_0.Http4s400.Implementations4_0_0 -import code.entitlement.Entitlement +import code.entitlement.{Entitlement, MappedEntitlement} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.{AmountOfMoneyJsonV121, ErrorMessage} import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By import org.json4s.native.Serialization.write import org.scalatest.Tag @@ -177,7 +182,115 @@ class DeleteBankCascadeTest extends V400ServerSetup { transactionRequestsAfter shouldBe empty challengesAfter shouldBe empty } + scenario("We delete a bank that has dynamic entities, their records and a ZZ_BAK_ copy", ApiEndpoint1, VersionOfApi) { + val bankId = createBank(APIUtil.generateUUID()).bankId.value + val otherBankId = createBank(APIUtil.generateUUID()).bankId.value + val owner = "bank-cascade-owner" + val suffix = APIUtil.generateUUID().take(8).replace("-", "") + def defineWithRecord(space: String, name: String): Unit = { + DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(DynamicEntityCommons( + name, s"""{"$name":{"properties":{"${StringHelpers.snakify(name)}_id":{"type":"string"},"note":{"type":"string"}}}}""", + None, owner, Some(space), hasPersonalEntity = false)).openOrThrowException("definition") + DynamicDataProvider.connectorMethodProvider.vend.save(Some(space), name, + JObject(JField(StringHelpers.snakify(name) + "_id", JString(APIUtil.generateUUID())), JField("note", JString("kept?"))), + Some(owner), false).openOrThrowException("record") + } + defineWithRecord(bankId, s"Echo$suffix") + defineWithRecord(bankId, s"ZZ_BAK_Echo$suffix") + defineWithRecord(otherBankId, s"Echo$suffix") + def definitionsAt(space: String) = DynamicEntity.findAll(By(DynamicEntity.BankId, space)) + def recordsAt(space: String) = DynamicData.findAll(By(DynamicData.BankId, space)) + val readRole = s"CanGetDynamicEntityRecord_Echo$suffix" + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, readRole) + def readRoleGrantsAt(space: String) = MappedEntitlement.findAll(By(MappedEntitlement.mBankId, space), By(MappedEntitlement.mRoleName, readRole)) + definitionsAt(bankId) should have size 2 + recordsAt(bankId) should have size 2 + readRoleGrantsAt(bankId) should have size 1 + + When("We grant the role and delete the bank with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + makeDeleteRequest((v4_0_0_Request / "management" / "cascading" / "banks" / bankId).DELETE <@(user1)).code should equal(200) + + Then("The bank's definitions and records are gone, and the other bank keeps its own") + definitionsAt(bankId) shouldBe empty + recordsAt(bankId) shouldBe empty + readRoleGrantsAt(bankId) shouldBe empty + definitionsAt(otherBankId) should have size 1 + recordsAt(otherBankId) should have size 1 + } + + scenario("We delete a bank whose id is the one that marks the system space", ApiEndpoint1, VersionOfApi) { + val bankId = createBank(Constant.DYNAMIC_ENTITY_SYSTEM_LEVEL_BANK_ID).bankId.value + val name = s"System${APIUtil.generateUUID().take(8).replace("-", "")}" + // A system level definition and record, which are stored under that same marker. + DynamicEntityProvider.connectorMethodProvider.vend.createOrUpdate(DynamicEntityCommons( + name, s"""{"$name":{"properties":{"${StringHelpers.snakify(name)}_id":{"type":"string"},"note":{"type":"string"}}}}""", + None, "bank-cascade-owner", None, hasPersonalEntity = false)).openOrThrowException("definition") + DynamicDataProvider.connectorMethodProvider.vend.save(None, name, + JObject(JField(StringHelpers.snakify(name) + "_id", JString(APIUtil.generateUUID())), JField("note", JString("kept"))), + Some("bank-cascade-owner"), false).openOrThrowException("record") + def systemDefinitions = DynamicEntity.findAll(By(DynamicEntity.EntityName, name)) + def systemRecords = DynamicData.findAll(By(DynamicData.DynamicEntityName, name)) + systemDefinitions should have size 1 + systemRecords should have size 1 + + When("We delete that bank with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + makeDeleteRequest((v4_0_0_Request / "management" / "cascading" / "banks" / bankId).DELETE <@(user1)).code should equal(200) + + Then("The bank is gone, and the system level definition and its record are kept") + code.model.dataAccess.MappedBank.find(By(code.model.dataAccess.MappedBank.permalink, bankId)).isDefined should equal(false) + systemDefinitions should have size 1 + systemRecords should have size 1 + } + + scenario("We delete a bank that has records whose definition is already gone", ApiEndpoint1, VersionOfApi) { + val bankId = createBank(APIUtil.generateUUID()).bankId.value + val otherBankId = createBank(APIUtil.generateUUID()).bankId.value + // A definition can be deleted while personal records of it remain (its delete checks for community + // records only), which leaves records, and the access rows granted on them, with no definition. + val entityName = s"Gone${APIUtil.generateUUID().take(8).replace("-", "")}" + def orphanAt(space: String): Unit = { + val record = DynamicData.create.BankId(space).DynamicEntityName(entityName).DataJson("{}") + .UserId("bank-cascade-owner").IsPersonalEntity(true).saveMe() + DynamicDataAccess.create.BankId(space).EntityName(entityName).DynamicDataId(record.DynamicDataId.get) + .UserId(resourceUser2.userId).CanRead(true).GrantedBy("bank-cascade-owner").saveMe() + } + orphanAt(bankId) + orphanAt(otherBankId) + def recordsAt(space: String) = DynamicData.findAll(By(DynamicData.BankId, space), By(DynamicData.DynamicEntityName, entityName)) + def accessRowsAt(space: String) = DynamicDataAccess.findAll(By(DynamicDataAccess.BankId, space), By(DynamicDataAccess.EntityName, entityName)) + + When("We delete the bank with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + makeDeleteRequest((v4_0_0_Request / "management" / "cascading" / "banks" / bankId).DELETE <@(user1)).code should equal(200) + + Then("Those records and access rows go with the bank, and the other bank keeps its own") + recordsAt(bankId) shouldBe empty + accessRowsAt(bankId) shouldBe empty + recordsAt(otherBankId) should have size 1 + accessRowsAt(otherBankId) should have size 1 + } + + scenario("We delete a bank whose accounts have holders", ApiEndpoint1, VersionOfApi) { + val bankId = createBank(APIUtil.generateUUID()).bankId.value + val accountId = createAccountViaEndpoint(bankId, addAccountJson, user1).account_id + def accountHolders = MapperAccountHolders.findAll( + By(MapperAccountHolders.accountBankPermalink, bankId), + By(MapperAccountHolders.accountPermalink, accountId) + ) + accountHolders should not be empty + + When("We grant the role") + Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + And("We delete the bank with the cascading endpoint") + val deleteRequest400 = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId ).DELETE <@(user1) + makeDeleteRequest(deleteRequest400).code should equal(200) + + Then("The holders of its accounts are deleted") + accountHolders shouldBe empty + } } - + } diff --git a/obp-api/src/test/scala/code/api/v4_0_0/RateLimitingTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/RateLimitingTest.scala index c1b6a2b3f0..04ce823273 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/RateLimitingTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/RateLimitingTest.scala @@ -31,7 +31,6 @@ import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole.{CanUpdateRateLimits, canCreateDynamicEndpoint} import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired} import code.api.util.{ApiRole, ExampleValue, RateLimitingUtil} -import code.api.v3_0_0.Http4s300.Implementations3_0_0.getCurrentUser import code.api.v4_0_0.Http4s400.Implementations4_0_0 import code.entitlement.Entitlement import code.setup.PropsReset @@ -84,11 +83,11 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { per_week_call_limit = "-1", per_month_call_limit = "-1" ) - val callLimitJsonSecond = callLimitJsonInitial.copy(api_name = Some(nameOf(getCurrentUser)), per_second_call_limit = "1") - val callLimitJsonMinute = callLimitJsonInitial.copy(api_name = Some(nameOf(getCurrentUser)), per_minute_call_limit = "1") - val callLimitJsonHour = callLimitJsonInitial.copy(api_name = Some(nameOf(getCurrentUser)), per_hour_call_limit = "1") - val callLimitJsonWeek = callLimitJsonInitial.copy(api_name = Some(nameOf(getCurrentUser)), per_week_call_limit = "1") - val callLimitJsonMonth = callLimitJsonInitial.copy(api_name = Some(nameOf(getCurrentUser)), per_month_call_limit = "1") + val callLimitJsonSecond = callLimitJsonInitial.copy(per_second_call_limit = "1") + val callLimitJsonMinute = callLimitJsonInitial.copy(per_minute_call_limit = "1") + val callLimitJsonHour = callLimitJsonInitial.copy(per_hour_call_limit = "1") + val callLimitJsonWeek = callLimitJsonInitial.copy(per_week_call_limit = "1") + val callLimitJsonMonth = callLimitJsonInitial.copy(per_month_call_limit = "1") feature("Rate Limit - " + ApiCallsLimit + " - " + ApiVersion400) { @@ -119,33 +118,39 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { response400.code should equal(200) response400.body.extract[CallLimitJsonV400] } - scenario("We will set Rate Limiting per second for an Endpoint", ApiCallsLimit, ApiVersion400) { + scenario("We will set Rate Limiting per second for a Consumer", ApiCallsLimit, ApiVersion400) { When("We make a request v4.0.0 with a Role " + ApiRole.canUpdateRateLimits) - val response01 = setRateLimiting(user1, callLimitJsonSecond) + // Set by another consumer, so this consumer starts the period with no calls made. + val response01 = setRateLimiting2(user1, callLimitJsonSecond) Then("We should get a 200") response01.code should equal(200) org.scalameta.logger.elem(response01) When("We make the first call after update") + val secondStarted = System.nanoTime() val response02 = getCurrentUserEndpoint(user1) Then("We should get a 200") response02.code should equal(200) When("We make the second call after update") + // Redis rounds the counter's TTL to whole seconds, so the one-second window reads as expired about half a second + // after the first call: a slower run cannot tell whether the limit was applied. + assume((System.nanoTime() - secondStarted) / 1000000 < 400, "the first call took too long to test a per-second limit") val response03 = getCurrentUserEndpoint(user1) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state val response04 = setRateLimiting2(user1, callLimitJsonInitial) Then("We should get a 200") response04.code should equal(200) } - scenario("We will set Rate Limiting per minute for an Endpoint", ApiCallsLimit, ApiVersion400) { + scenario("We will set Rate Limiting per minute for a Consumer", ApiCallsLimit, ApiVersion400) { When("We make a request v4.0.0 with a Role " + ApiRole.canUpdateRateLimits) - val response01 = setRateLimiting(user1, callLimitJsonMinute) + // Set by another consumer, so this consumer starts the period with no calls made. + val response01 = setRateLimiting2(user1, callLimitJsonMinute) Then("We should get a 200") response01.code should equal(200) @@ -156,18 +161,19 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { When("We make the second call after update") val response03 = getCurrentUserEndpoint(user1) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state val response04 = setRateLimiting2(user1, callLimitJsonInitial) Then("We should get a 200") response04.code should equal(200) } - scenario("We will set Rate Limiting per hour for an Endpoint", ApiCallsLimit, ApiVersion400) { + scenario("We will set Rate Limiting per hour for a Consumer", ApiCallsLimit, ApiVersion400) { When("We make a request v4.0.0 with a Role " + ApiRole.canUpdateRateLimits) - val response01 = setRateLimiting(user1, callLimitJsonHour) + // Set by another consumer, so this consumer starts the period with no calls made. + val response01 = setRateLimiting2(user1, callLimitJsonHour) Then("We should get a 200") response01.code should equal(200) @@ -178,18 +184,19 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { When("We make the second call after update") val response03 = getCurrentUserEndpoint(user1) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state val response04 = setRateLimiting2(user1, callLimitJsonInitial) Then("We should get a 200") response04.code should equal(200) } - scenario("We will set Rate Limiting per week for an Endpoint", ApiCallsLimit, ApiVersion400) { + scenario("We will set Rate Limiting per week for a Consumer", ApiCallsLimit, ApiVersion400) { When("We make a request v4.0.0 with a Role " + ApiRole.canUpdateRateLimits) - val response01 = setRateLimiting(user1, callLimitJsonWeek) + // Set by another consumer, so this consumer starts the period with no calls made. + val response01 = setRateLimiting2(user1, callLimitJsonWeek) Then("We should get a 200") response01.code should equal(200) @@ -200,18 +207,19 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { When("We make the second call after update") val response03 = getCurrentUserEndpoint(user1) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state val response04 = setRateLimiting2(user1, callLimitJsonInitial) Then("We should get a 200") response04.code should equal(200) } - scenario("We will set Rate Limiting per month for an Endpoint", ApiCallsLimit, ApiVersion400) { + scenario("We will set Rate Limiting per month for a Consumer", ApiCallsLimit, ApiVersion400) { When("We make a request v4.0.0 with a Role " + ApiRole.canUpdateRateLimits) - val response01 = setRateLimiting(user1, callLimitJsonMonth) + // Set by another consumer, so this consumer starts the period with no calls made. + val response01 = setRateLimiting2(user1, callLimitJsonMonth) Then("We should get a 200") response01.code should equal(200) @@ -222,8 +230,8 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { When("We make the second call after update") val response03 = getCurrentUserEndpoint(user1) - Then("We should get a 200 since 1 hour caching") - response03.code should equal(200) + Then("We should get a 429, as the new limit applies at once") + response03.code should equal(429) // Revert to initial state val response04 = setRateLimiting2(user1, callLimitJsonInitial) @@ -245,11 +253,9 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { Then("We should get a 201") responseWithRole.code should equal(201) - // Set Rate Limiting in case of a Dynamic Endpoint - val operationId = "dynamicEndpoint_GET_accounts_ACCOUNT_ID" - val apiName = "dynamicEndpoint_GET_accounts_ACCOUNT_ID" - val apiVersion = ApiVersion.`dynamic-endpoint`.toString() - val response01 = setRateLimiting2(user1, callLimitJsonHour.copy(api_name = Some(apiName), api_version = Some(apiVersion))) + // Set Rate Limiting for the consumer, which a Dynamic Endpoint is held to like any other. Creating the endpoint + // was this consumer's first call this hour, so two calls an hour leave room for one more. + val response01 = setRateLimiting2(user1, callLimitJsonHour.copy(per_hour_call_limit = "2")) Then("We should get a 200") response01.code should equal(200) @@ -260,11 +266,11 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { makeGetRequest(requestDynamicEndpoint.GET <@(user1)).code should equal(200) // 2nd call exceeds rate limit When("We make the second call after update") - Then("We should get a 200 since 1 hour caching") - makeGetRequest(requestDynamicEndpoint.GET <@(user1)).code should equal(200) + Then("We should get a 429, as the new limit applies at once") + makeGetRequest(requestDynamicEndpoint.GET <@(user1)).code should equal(429) - // Revert Rate Limiting to initial state in case of a Dynamic Endpoint - val response02 = setRateLimiting2(user1, callLimitJsonInitial.copy(api_name = Some(apiName), api_version = Some(apiVersion))) + // Revert Rate Limiting to initial state + val response02 = setRateLimiting2(user1, callLimitJsonInitial) Then("We should get a 200") response02.code should equal(200) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala index 85650d823f..1c92b4258d 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/TransactionRequestsTest.scala @@ -47,6 +47,8 @@ import code.api.v4_0_0.Http4s400.Implementations4_0_0 import code.api.v5_0_0.PhysicalCardJsonV500 import code.api.v5_1_0.{AgentJsonV510, PutAgentJsonV510} import code.bankconnectors.Connector +import code.customer.CustomerX +import code.usercustomerlinks.UserCustomerLink import code.entitlement.Entitlement import code.fx.fx import code.model.BankAccountX @@ -699,6 +701,104 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers { } } + feature("a transaction request whose challenge cannot be sent") { + + if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { + ignore("is not left waiting for an answer", ApiEndpoint1) {} + } else { + scenario("is not left waiting for an answer", ApiEndpoint1) { + When("the challenge goes by SMS and no SMS provider is configured") + setPropsValues("ACCOUNT_OTP_INSTRUCTION_TRANSPORT" -> "SMS") + val helper = defaultSetup(ACCOUNT.toString) + val amt = "30000.00" + helper.setCurrencyAndAmt("AED", "AED", amt) + helper.bodyValue = AmountOfMoneyJsonV121("AED", amt) + helper.transactionRequestBody = helper.transactionRequestBody.copy(value = helper.bodyValue) + + Then("creating the transaction request answers 400") + val createTransactionRequestResponse = helper.makeCreateTransReqRequest + createTransactionRequestResponse.code should equal(400) + + And("the transaction request it saved is marked FAILED, not INITIATED") + val getTransReqResponse = helper.makeGetTransReqRequest + getTransReqResponse.code should equal(200) + (getTransReqResponse.body \ "transaction_requests_with_charges" \ "status").extract[List[String]] should equal( + List(TransactionRequestStatus.FAILED.toString)) + + And("the account balance is unchanged") + helper.checkBankAccountBalance(false) + } + + scenario("still goes to the signers who can be reached, when there are enough of them", ApiEndpoint1) { + When("two of three signers have an address and two answers are required") + val helper = setUpSignersByEmail(signers = List(resourceUser2, resourceUser3), reachable = List(resourceUser1, resourceUser2)) + + Then("the transaction request waits for the answers of the two who were sent a challenge") + val response = helper.makeCreateTransReqRequestCounterparty + response.code should equal(201) + val created = response.body.extract[TransactionRequestWithChargeJSON400] + created.status should equal(TransactionRequestStatus.INITIATED.toString) + created.challenges.map(_.user_id).toSet should equal(Set(resourceUser1.userId, resourceUser2.userId)) + } + + scenario("is marked FAILED when fewer signers than the answers required can be reached", ApiEndpoint1) { + When("one of three signers has an address and two answers are required") + val helper = setUpSignersByEmail(signers = List(resourceUser2, resourceUser3), reachable = List(resourceUser1)) + + Then("creating the transaction request answers 400 and the request is FAILED") + val response = helper.makeCreateTransReqRequestCounterparty + response.code should equal(400) + response.body.extract[ErrorMessage].message should include(extractErrorMessageCode(ChallengeRecipientNotFound)) + (helper.makeGetTransReqRequest.body \ "transaction_requests_with_charges" \ "status").extract[List[String]] should equal( + List(TransactionRequestStatus.FAILED.toString)) + } + } + } + + feature("a transaction request on an account that needs more answers than it has signers") { + if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { + ignore("is marked FAILED with the reason", ApiEndpoint1) {} + } else { + scenario("is marked FAILED with the reason", ApiEndpoint1) { + When("only the owner may answer, can be reached, and two answers are required") + val helper = setUpSignersByEmail(signers = Nil, reachable = List(resourceUser1)) + + Then("the answer says there are too few users who may answer, not that an address is missing") + val response = helper.makeCreateTransReqRequestCounterparty + response.code should equal(400) + response.body.extract[ErrorMessage].message should include("OBP-35060") + (helper.makeGetTransReqRequest.body \ "transaction_requests_with_charges" \ "status").extract[List[String]] should equal( + List(TransactionRequestStatus.FAILED.toString)) + } + } + } + + /** A COUNTERPARTY payment above the challenge threshold on an account whose owner and `signers` may answer, two + * answers required and challenges sent by e-mail; only the `reachable` users are linked to a customer with an + * e-mail address. */ + private def setUpSignersByEmail(signers: List[User], reachable: List[User]) = { + setPropsValues("COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT" -> "EMAIL", "mail.test.mode" -> "true") + val helper = defaultSetup(COUNTERPARTY.toString) + val amt = "30000.00" + helper.setCurrencyAndAmt("AED", "INR", amt) + helper.bodyValue = AmountOfMoneyJsonV121("AED", amt) + helper.transactionRequestBodyCounterparty = helper.transactionRequestBodyCounterparty.copy(value = helper.bodyValue) + createAccountAttributeViaEndpoint(helper.bankId.value, helper.accountId1.value, "REQUIRED_CHALLENGE_ANSWERS", "2", "INTEGER", Some(UUID.randomUUID().toString)) + signers.foreach { signer => + grantUserAccessToViewViaEndpoint(helper.bankId.value, helper.accountId1.value, signer.userId, user1, + PostViewJsonV400(view_id = Constant.SYSTEM_OWNER_VIEW_ID, is_system = true)) + } + reachable.foreach { user => + val customer = CustomerX.customerProvider.vend.addCustomer( + helper.bankId, APIUtil.generateUUID(), "Signer", "", s"${user.userId}@example.com", + CustomerFaceImage(new Date(), "http://example.com/face.png"), + new Date(), "single", 0, Nil, "Bachelor", "employed", true, new Date(), None, None, "", "", "" + ).openOrThrowException("expected the customer to be created") + UserCustomerLink.userCustomerLink.vend.getOCreateUserCustomerLink(user.userId, customer.customerId, new Date(), true) + } + helper + } + feature("we can create transaction requests -- FREE_FORM") { if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { diff --git a/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala b/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala index 37b24ad00e..f201b35d9f 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala @@ -468,6 +468,9 @@ trait V400ServerSetup extends ServerSetupWithTestData with DefaultUsers { * Returns (fromAccountId, toAccountId, transactionRequestIds). */ def createPaymentsForDeleteCascade(bankId: String): (String, String, List[String]) = { + // The challenge of the second payment is sent by a method the user can be reached by; with the default SMS + // and no linked customer there is no one to send it to. + setPropsValues("SANDBOX_TAN_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY") def addAccountJson = SwaggerDefinitionsJSON.createAccountRequestJsonV310 .copy(user_id = resourceUser1.userId, balance = AmountOfMoneyJsonV121("EUR","0"), account_routings = List(AccountRoutingJsonV121(Random.nextString(10), Random.nextString(10)))) diff --git a/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala b/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala index bbbe9c527d..e11cac9895 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala @@ -29,7 +29,7 @@ package code.api.v6_0_0 import org.json4s._ import code.api.util.APIUtil.OAuth._ -import code.api.util.ApiRole.{canCreateAbacRule, canExecuteAbacRule} +import code.api.util.ApiRole.{canCreateAbacRule, canExecuteAbacRule, canUpdateAbacRule} import code.api.util.ErrorMessages import code.api.util.ErrorMessages.UserHasMissingRoles import code.api.v6_0_0.Http4s600.Implementations6_0_0 @@ -59,6 +59,7 @@ class AbacRuleTests extends V600ServerSetup with DefaultUsers { object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.executeAbacRule)) object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.executeAbacPolicy)) object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.createAbacRule)) + object ApiEndpoint4 extends Tag(nameOf(Implementations6_0_0.updateAbacRule)) /** * Helper to create an ABAC rule via the API and return its ID. @@ -391,4 +392,55 @@ class AbacRuleTests extends V600ServerSetup with DefaultUsers { response.code should equal(201) } } + + // ==================== Error Codes of Rejected Rule Code ==================== + + feature(s"Creating or updating a rule with invalid code answers the error validate reports - $VersionOfApi") { + + // Each kind of invalid rule code, with the error the validate endpoint must classify it as. + val invalidRuleCodes = List( + ("field reference", "authenticatedUser.noSuchField == \"x\"", ErrorMessages.AbacRuleFieldReferenceError), + // The compiler reports an incomplete expression as a failed compilation, not by the word "syntax". + ("compilation", "authenticatedUser.emailAddress ==", ErrorMessages.AbacRuleCompilationFailed), + ("tautology", "true", ErrorMessages.AbacRuleTooPermissive) + ) + + def obpCode(message: String): String = "OBP-\\d+".r.findFirstIn(message).getOrElse(message) + + def validatedCode(ruleCode: String): String = { + val response = makePostRequest( + (v6_0_0_Request / "management" / "abac-rules" / "validate").POST <@ (user1), + write(ValidateAbacRuleJsonV600(rule_code = ruleCode))) + response.code should equal(200) + (response.body \ "valid").extract[Boolean] should equal(false) + obpCode((response.body \ "message").extract[String]) + } + + invalidRuleCodes.foreach { case (kind, ruleCode, expectedError) => + scenario(s"Creating a rule with a $kind error", ApiEndpoint3, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) + val expectedCode = validatedCode(ruleCode) + withClue(s"validate classifies the $kind error: ") { expectedCode should equal(obpCode(expectedError)) } + val response = makePostRequest( + (v6_0_0_Request / "management" / "abac-rules").POST <@ (user1), + write(CreateAbacRuleJsonV600(s"invalid-$kind", ruleCode, "Should be rejected", "account-access", is_active = true))) + Then(s"We should get a 400 with $expectedCode") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(expectedCode) + } + + scenario(s"Updating a rule with a $kind error", ApiEndpoint4, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateAbacRule.toString) + val ruleId = createAbacRuleViaApi(s"to-update-$kind", "false") + val expectedCode = validatedCode(ruleCode) + withClue(s"validate classifies the $kind error: ") { expectedCode should equal(obpCode(expectedError)) } + val response = makePutRequest( + (v6_0_0_Request / "management" / "abac-rules" / ruleId).PUT <@ (user1), + write(UpdateAbacRuleJsonV600(s"to-update-$kind", ruleCode, "Should be rejected", "account-access", is_active = true))) + Then(s"We should get a 400 with $expectedCode") + response.code should equal(400) + response.body.extract[ErrorMessage].message should startWith(expectedCode) + } + } + } } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/LegacyCallLimitsPutTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/LegacyCallLimitsPutTest.scala new file mode 100644 index 0000000000..d2a341bc41 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/LegacyCallLimitsPutTest.scala @@ -0,0 +1,150 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v6_0_0 + +import code.api.cache.Caching +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.{CanCreateRateLimits, CanGetRateLimits, CanUpdateRateLimits} +import code.api.util.ErrorMessages.RateLimitScopeNotSupported +import code.api.v3_1_0.CallLimitPostJson +import code.api.v4_0_0.CallLimitPostJsonV400 +import code.consumer.Consumers +import code.entitlement.Entitlement +import code.ratelimiting.RateLimiting +import code.setup.OBPReq +import com.openbankproject.commons.model.ErrorMessage +import net.liftweb.mapper.By +import org.json4s.native.Serialization.write + +import java.util.Date + +/** + * The v3.1.0 and v4.0.0 "Set Rate Limits" PUT keeps one rate limit per consumer: it changes the consumer's rate limit + * or creates it. Next to the records v6.0.0 creates, it must not pick one of several to overwrite. + */ +class LegacyCallLimitsPutTest extends V600ServerSetup { + + lazy val consumerId: String = Consumers.consumers.vend.getConsumerByConsumerKey(consumer3.key).map(_.consumerId.get).getOrElse("") + + private def v3_1_0_Request: OBPReq = baseRequest / "obp" / "v3.1.0" + private def callLimitsPath(base: OBPReq) = base / "management" / "consumers" / consumerId / "consumer" / "call-limits" + + private def from = new Date(System.currentTimeMillis() - 3600000L) + private def to = new Date(System.currentTimeMillis() + 86400000L) + + private def legacyBody(perHour: String) = CallLimitPostJson(from, to, "-1", "-1", perHour, "-1", "-1", "-1") + private def v400Body(perHour: String) = CallLimitPostJsonV400(from, to, None, None, None, "-1", "-1", perHour, "-1", "-1", "-1") + + /** A rate limit created through v6.0.0, which adds a record next to the others. */ + private def createV600Limit(perHour: String): String = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateRateLimits.toString) + val response = makePostRequest( + (v6_0_0_Request / "management" / "consumers" / consumerId / "consumer" / "rate-limits").POST <@ (user1), + write(CallLimitPostJsonV600(from, to, None, None, None, "-1", "-1", perHour, "-1", "-1", "-1"))) + response.code should equal(201) + response.body.extract[CallLimitJsonV600].rate_limiting_id + } + + private def storedPerHourLimits: List[Long] = + RateLimiting.findAll(By(RateLimiting.ConsumerId, consumerId)).map(_.perHourCallLimit).sorted + + private def activePerHourLimit: Long = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetRateLimits.toString) + val response = makeGetRequest((v6_0_0_Request / "management" / "consumers" / consumerId / "active-rate-limits").GET <@ (user1)) + response.code should equal(200) + response.body.extract[ActiveRateLimitsJsonV600].active_per_hour_rate_limit + } + + private def startClean(): Unit = { + RateLimiting.bulkDelete_!!(By(RateLimiting.ConsumerId, consumerId)) + Caching.invalidateRateLimitCache(consumerId) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateRateLimits.toString) + } + + feature("Set Rate Limits (v3.1.0 and v4.0.0) next to the rate limits v6.0.0 creates") { + + scenario("does not change any of a consumer's several rate limits") { + startClean() + createV600Limit("1000") + createV600Limit("5000") + + val v310 = makePutRequest(callLimitsPath(v3_1_0_Request).PUT <@ (user1), write(legacyBody("100"))) + val v400 = makePutRequest(callLimitsPath(v4_0_0_Request).PUT <@ (user1), write(v400Body("100"))) + + v310.code should equal(400) + v310.body.extract[ErrorMessage].message should startWith("OBP-30625") + v400.code should equal(400) + v400.body.extract[ErrorMessage].message should startWith("OBP-30625") + storedPerHourLimits should equal(List(1000L, 5000L)) + } + + scenario("changes a consumer's only rate limit, and creates one for a consumer that has none") { + startClean() + + makePutRequest(callLimitsPath(v3_1_0_Request).PUT <@ (user1), write(legacyBody("100"))).code should equal(200) + storedPerHourLimits should equal(List(100L)) + + makePutRequest(callLimitsPath(v3_1_0_Request).PUT <@ (user1), write(legacyBody("200"))).code should equal(200) + storedPerHourLimits should equal(List(200L)) + } + + scenario("changes a consumer's only rate limit that an older version stored with empty scope fields") { + startClean() + // As v4.0.0 stored a body that sent "" for api_name, api_version and bank_id. + RateLimiting.create.ConsumerId(consumerId).FromDate(from).ToDate(to).BankId("").ApiVersion("").ApiName("") + .PerSecondCallLimit(-1).PerMinuteCallLimit(-1).PerHourCallLimit(1000).PerDayCallLimit(-1) + .PerWeekCallLimit(-1).PerMonthCallLimit(-1).saveMe() + + makePutRequest(callLimitsPath(v3_1_0_Request).PUT <@ (user1), write(legacyBody("100"))).code should equal(200) + + storedPerHourLimits should equal(List(100L)) + } + + scenario("applies the change at once") { + startClean() + createV600Limit("1000") + activePerHourLimit should equal(1000L) + + makePutRequest(callLimitsPath(v3_1_0_Request).PUT <@ (user1), write(legacyBody("100"))).code should equal(200) + + activePerHourLimit should equal(100L) + } + + scenario("v4.0.0 refuses an api_name, api_version or bank_id, and takes an empty one as not set") { + startClean() + + val scoped = makePutRequest(callLimitsPath(v4_0_0_Request).PUT <@ (user1), write(v400Body("100").copy(bank_id = Some("some-bank")))) + scoped.code should equal(400) + scoped.body.extract[ErrorMessage].message should startWith(RateLimitScopeNotSupported) + storedPerHourLimits shouldBe empty + + val blank = makePutRequest(callLimitsPath(v4_0_0_Request).PUT <@ (user1), write(v400Body("100").copy(api_name = Some(" ")))) + blank.code should equal(200) + RateLimiting.findAll(By(RateLimiting.ConsumerId, consumerId)).map(_.apiName) should equal(List(None)) + } + } +} diff --git a/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala index 1b6a767421..1d1e41d7f9 100644 --- a/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala +++ b/obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala @@ -27,13 +27,15 @@ package code.api.v6_0_0 import org.json4s._ import code.api.util.APIUtil.OAuth._ +import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON import code.api.{Constant, JedisMethod} import code.api.cache.Redis import code.setup.OBPReq -import code.api.util.ApiRole.{CanCreateRateLimits, CanDeleteRateLimits, CanGetRateLimits} -import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired, TooManyRequests} +import code.api.util.ApiRole.{CanCreateRateLimits, CanDeleteRateLimits, CanGetRateLimits, CanUpdateRateLimits} +import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequired, TooManyRequests, RateLimitScopeNotSupported} import code.api.v6_0_0.Http4s600.Implementations6_0_0 import code.consumer.Consumers +import code.ratelimiting.RateLimitingDI import code.entitlement.Entitlement import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.ErrorMessage @@ -57,8 +59,8 @@ class RateLimitsTest extends V600ServerSetup { lazy val postCallLimitJsonV600 = CallLimitPostJsonV600( from_date = new Date(), to_date = new Date(System.currentTimeMillis() + 86400000L), // +1 day - api_version = Some("v6.0.0"), - api_name = Some("testEndpoint"), + api_version = None, + api_name = None, bank_id = None, per_second_call_limit = "10", per_minute_call_limit = "100", @@ -217,8 +219,8 @@ class RateLimitsTest extends V600ServerSetup { val rateLimit1 = CallLimitPostJsonV600( from_date = fromDate1, to_date = toDate1, - api_version = Some("v6.0.0"), - api_name = Some("testEndpoint1"), + api_version = None, + api_name = None, bank_id = None, per_second_call_limit = "10", per_minute_call_limit = "100", @@ -235,8 +237,8 @@ class RateLimitsTest extends V600ServerSetup { val rateLimit2 = CallLimitPostJsonV600( from_date = fromDate1, to_date = toDate1, - api_version = Some("v6.0.0"), - api_name = Some("testEndpoint2"), + api_version = None, + api_name = None, bank_id = None, per_second_call_limit = "5", per_minute_call_limit = "50", @@ -488,6 +490,116 @@ class RateLimitsTest extends V600ServerSetup { def requestFor(c: FallthroughCase): OBPReq = c.path.foldLeft(baseRequest / "obp" / c.requestVersion)(_ / _).GET <@ (user3) + feature("A rate limit applies to the consumer from its from_date to its to_date, as set") { + + scenario("A rate limit whose to_date has passed is not active, even within the same hour", ApiEndpoint4, VersionOfApi) { + // The row must end after the start of the current UTC hour, or it would not be active under the hour rule either. + val now = System.currentTimeMillis() + assume(now % 3600000L > 5000L, "the hour has only just begun") + val toDate = new Date(now - 2000L) + val id = createLimit(consumerId3, callLimitJson("0", "0", "0").copy(from_date = new Date(toDate.getTime - 3600000L), to_date = toDate)) + try { + Then("the rate limit is not considered and does not block the consumer") + activeLimitsNow(consumerId3).considered_rate_limit_ids shouldBe empty + callAsUser3().code should equal(200) + } finally deleteLimit(consumerId3, id) + } + + scenario("A rate limit whose from_date has not come yet is not active", ApiEndpoint4, VersionOfApi) { + val now = System.currentTimeMillis() + // A minute ahead, so a slow run cannot reach from_date before the checks; still within the current hour. + assume(now % 3600000L < 3600000L - 70000L, "the hour is about to end") + val id = createLimit(consumerId3, callLimitJson("0", "0", "0").copy(from_date = new Date(now + 60000L), to_date = new Date(now + 86400000L))) + try { + activeLimitsNow(consumerId3).considered_rate_limit_ids shouldBe empty + callAsUser3().code should equal(200) + } finally deleteLimit(consumerId3, id) + } + + scenario("The report for an hour still lists a limit that ended within that hour", ApiEndpoint3, VersionOfApi) { + val now = System.currentTimeMillis() + assume(now % 3600000L > 5000L, "the hour has only just begun") + val toDate = new Date(now - 2000L) + val id = createLimit(consumerId3, callLimitJson("0", "0", "0").copy(from_date = new Date(toDate.getTime - 3600000L), to_date = toDate)) + try { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetRateLimits.toString) + // The hour of `now`, the hour the assume above keeps to_date in. + val hour = java.time.Instant.ofEpochMilli(now).atZone(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH")) + val response = makeGetRequest((v6_0_0_Request / "management" / "consumers" / consumerId3 / "active-rate-limits" / hour).GET <@ (user1)) + response.code should equal(200) + response.body.extract[ActiveRateLimitsJsonV600].considered_rate_limit_ids should contain(id) + } finally deleteLimit(consumerId3, id) + } + + scenario("A rate limit that names an api_name, api_version or bank_id is refused, as those are not enforced", ApiEndpoint4, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateRateLimits.toString) + val scoped = Map( + "api_name" -> callLimitJson("0", "0", "0").copy(api_name = Some("getBank")), + "api_version" -> callLimitJson("0", "0", "0").copy(api_version = Some("v6.0.0")), + "bank_id" -> callLimitJson("0", "0", "0").copy(bank_id = Some("some-bank")) + ) + scoped.foreach { case (field, json) => + val response = makePostRequest((v6_0_0_Request / "management" / "consumers" / consumerId3 / "consumer" / "rate-limits").POST <@ (user1), write(json)) + withClue(s"a rate limit that names $field: ") { + response.code should equal(400) + response.body.extract[ErrorMessage].message should (startWith(RateLimitScopeNotSupported) and endWith(s"Remove: $field")) + } + } + And("an existing rate limit cannot be changed to name one either") + val id = createLimit(consumerId3, callLimitJson("-1", "-1", "-1")) + try { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateRateLimits.toString) + val update = makePutRequest( + (v6_0_0_Request / "management" / "consumers" / consumerId3 / "consumer" / "rate-limits" / id).PUT <@ (user1), + write(callLimitJson("-1", "-1", "-1").copy(api_name = Some("getBank")))) + update.code should equal(400) + update.body.extract[ErrorMessage].message should endWith("Remove: api_name") + } finally deleteLimit(consumerId3, id) + + And("none of them was stored, so the consumer is not blocked") + activeLimitsNow(consumerId3).considered_rate_limit_ids shouldBe empty + callAsUser3().code should equal(200) + } + + scenario("The example request body in the documentation is accepted", ApiEndpoint4, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateRateLimits.toString) + val created = makePostRequest( + (v6_0_0_Request / "management" / "consumers" / consumerId3 / "consumer" / "rate-limits").POST <@ (user1), + write(SwaggerDefinitionsJSON.callLimitPostJsonV600)) + try created.code should equal(201) + finally if (created.code == 201) deleteLimit(consumerId3, created.body.extract[CallLimitJsonV600].rate_limiting_id) + } + + scenario("Updating a rate limit that an API Product Subscription wrote keeps the subscription's bank_id", ApiEndpoint4, VersionOfApi) { + // As ApiProductSubscriptionEnforcer writes it. + val written = scala.concurrent.Await.result(RateLimitingDI.rateLimiting.vend.createConsumerCallLimits( + consumerId3, new Date(), new Date(System.currentTimeMillis() + 86400000L), None, None, Some("subscription-bank"), + Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")), scala.concurrent.duration.Duration(10, "seconds")) + .openOrThrowException("expected the rate limit to be written") + try { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanUpdateRateLimits.toString) + val update = makePutRequest( + (v6_0_0_Request / "management" / "consumers" / consumerId3 / "consumer" / "rate-limits" / written.rateLimitingId).PUT <@ (user1), + write(callLimitJson("-1", "-1", "-1"))) + update.code should equal(200) + val stored = scala.concurrent.Await.result(RateLimitingDI.rateLimiting.vend.getByRateLimitingId(written.rateLimitingId), + scala.concurrent.duration.Duration(10, "seconds")).openOrThrowException("expected the rate limit") + stored.bankId should equal(Some("subscription-bank")) + } finally deleteLimit(consumerId3, written.rateLimitingId) + } + + scenario("An empty api_name, api_version or bank_id names nothing, and is stored as not set", ApiEndpoint4, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanCreateRateLimits.toString) + val created = makePostRequest( + (v6_0_0_Request / "management" / "consumers" / consumerId3 / "consumer" / "rate-limits").POST <@ (user1), + write(callLimitJson("-1", "-1", "-1").copy(api_name = Some(""), api_version = Some(" "), bank_id = Some("")))) + created.code should equal(201) + val stored = created.body.extract[CallLimitJsonV600] + try (stored.api_name, stored.api_version, stored.bank_id) should equal((None, None, None)) + finally deleteLimit(consumerId3, stored.rate_limiting_id) + } + } + feature("Rate limiting counts one unit per request, whichever version prefix it arrives at and however many hops it crosses") { fallthroughCases.foreach { c => scenario(s"GET /obp/${c.requestVersion}/${c.path.mkString("/")} is served by ${c.servedBy}, and costs one unit", ApiEndpoint4, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/v7_0_0/CardAttributeDeleteTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/CardAttributeDeleteTest.scala new file mode 100644 index 0000000000..f08e6ffff1 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/CardAttributeDeleteTest.scala @@ -0,0 +1,174 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.api.v7_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, CardAttributeNotFound, NotImplemented, UserHasMissingRoles} +import code.cardattribute.MappedCardAttribute +import code.cards.MappedPhysicalCard +import code.entitlement.Entitlement +import code.methodrouting.{MethodRouting, MethodRoutingCommons, MethodRoutingProvider} +import code.model.dataAccess.MappedBankAccount +import code.setup.ServerSetupWithTestData +import com.openbankproject.commons.model.ErrorMessage +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import org.scalatest.Tag + +class CardAttributeDeleteTest extends ServerSetupWithTestData { + + object VersionOfApi extends Tag(ApiVersion.v7_0_0.toString) + object ApiEndpoint extends Tag("deleteCardAttribute") + + def v7 = baseRequest / "obp" / "v7.0.0" + + private def addCard(): String = { + val account = MappedBankAccount.find( + By(MappedBankAccount.bank, testBankId1.value), + By(MappedBankAccount.theAccountId, testAccountId1.value) + ).openOrThrowException("test account not found") + MappedPhysicalCard.create + .mBankId(testBankId1.value) + .mAccount(account.id.get) + .mBankCardNumber(randomString(16)) + .mSerialNumber(randomString(10)) + .saveMe() + .mCardId.get + } + + private def addAttribute(cardId: String, name: String, bankId: String = testBankId1.value): String = + MappedCardAttribute.create + .mBankId(bankId) + .mCardId(cardId) + .mName(name) + .mType("STRING") + .mValue("123") + .saveMe() + .mCardAttributeId.get + + private def attributeUrl(cardId: String, attributeId: String) = + v7 / "management" / "banks" / testBankId1.value / "cards" / cardId / "attributes" / attributeId + + private def attributeExists(attributeId: String): Boolean = + MappedCardAttribute.find(By(MappedCardAttribute.mCardAttributeId, attributeId)).isDefined + + feature("Delete Card Attribute") { + scenario("Without a logged-in user", ApiEndpoint, VersionOfApi) { + val response = makeDeleteRequest(attributeUrl("any-card-id", "any-attribute-id").DELETE) + response.code should equal(401) + response.body.extract[ErrorMessage].message should startWith(AuthenticatedUserIsRequired) + } + + scenario("Without the role", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + val attributeId = addAttribute(cardId, "TAX_NUMBER") + val response = makeDeleteRequest(attributeUrl(cardId, attributeId).DELETE <@ (user2)) + response.code should equal(403) + response.body.extract[ErrorMessage].message should (startWith(UserHasMissingRoles) and include(ApiRole.canDeleteCardsForBank.toString)) + attributeExists(attributeId) should equal(true) + } + + scenario("With the role the attribute is deleted and the card's other attributes are kept", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + val attributeId = addAttribute(cardId, "TAX_NUMBER") + val otherAttributeId = addAttribute(cardId, "SEGMENT") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + + val response = makeDeleteRequest(attributeUrl(cardId, attributeId).DELETE <@ (user1)) + + response.code should equal(204) + attributeExists(attributeId) should equal(false) + attributeExists(otherAttributeId) should equal(true) + } + + scenario("An unknown attribute answers 404", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + + val response = makeDeleteRequest(attributeUrl(cardId, "does-not-exist").DELETE <@ (user1)) + + response.code should equal(404) + response.body.extract[ErrorMessage].message should startWith(CardAttributeNotFound) + } + + scenario("An attribute of another card is not deleted through this card", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + val otherCardId = addCard() + val otherAttributeId = addAttribute(otherCardId, "TAX_NUMBER") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + + val response = makeDeleteRequest(attributeUrl(cardId, otherAttributeId).DELETE <@ (user1)) + + response.code should equal(404) + response.body.extract[ErrorMessage].message should startWith(CardAttributeNotFound) + attributeExists(otherAttributeId) should equal(true) + } + + scenario("An attribute held for another bank is not deleted through this bank", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + val attributeId = addAttribute(cardId, "TAX_NUMBER", bankId = testBankId2.value) + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + + val response = makeDeleteRequest(attributeUrl(cardId, attributeId).DELETE <@ (user1)) + + response.code should equal(404) + response.body.extract[ErrorMessage].message should startWith(CardAttributeNotFound) + attributeExists(attributeId) should equal(true) + } + + scenario("The role at another bank does not allow it", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + val attributeId = addAttribute(cardId, "TAX_NUMBER") + Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + + val response = makeDeleteRequest(attributeUrl(cardId, attributeId).DELETE <@ (user1)) + + response.code should equal(403) + response.body.extract[ErrorMessage].message should startWith(UserHasMissingRoles) + attributeExists(attributeId) should equal(true) + } + + scenario("The delete goes to the connector the method is routed to", ApiEndpoint, VersionOfApi) { + val cardId = addCard() + val attributeId = addAttribute(cardId, "TAX_NUMBER") + Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canDeleteCardsForBank.toString) + // A connector that does not delete card attributes: what it answers is what the caller gets. + val routing = MethodRoutingProvider.connectorMethodProvider.vend.createOrUpdate(MethodRoutingCommons( + "deleteCardAttribute", "cardano_vJun2025", isBankIdExactMatch = false, bankIdPattern = Some(MethodRouting.bankIdPatternMatchAny) + )).openOrThrowException("expected the method routing to be created") + try { + val response = makeDeleteRequest(attributeUrl(cardId, attributeId).DELETE <@ (user1)) + + response.code should equal(400) + response.body.extract[ErrorMessage].message should (include(NotImplemented.trim) and include("deleteCardAttribute")) + attributeExists(attributeId) should equal(true) + } finally MethodRoutingProvider.connectorMethodProvider.vend.delete(routing.methodRoutingId.get) + } + } +} diff --git a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala new file mode 100644 index 0000000000..cf665afe07 --- /dev/null +++ b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala @@ -0,0 +1,210 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package code.connector + +import code.api.util.CommonsEmailWrapper +import code.api.util.ErrorMessages.{ChallengeRecipientNotFound, EmailSendingFailed} +import code.bankconnectors.LocalMappedConnector +import code.customer.CustomerX +import code.model.dataAccess.AuthUser +import code.setup.{PropsReset, ServerSetupWithTestData} +import code.transactionChallenge.Challenges +import code.usercustomerlinks.UserCustomerLink +import code.model.UserX +import code.users.Users +import com.openbankproject.commons.model.{BankId, CustomerFaceImage, User} +import com.openbankproject.commons.model.enums.{ChallengeType, StrongCustomerAuthentication} +import net.liftweb.common.{Failure, Full} +import net.liftweb.util.Helpers.randomString + +import java.util.Date +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Where the passcode of an EMAIL challenge goes: to the customers linked to the user, else to the user's own + * address, and a challenge that no one could receive is not created. + */ +class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { + + private def createUser(email: String): User = + UserX.createResourceUser( + provider = "challenge-recipient-test", + providerId = Some(randomString(10)), + createdByConsentId = None, + name = Some(randomString(10)), + email = Some(email), + userId = None, + company = None + ).openOrThrowException("expected the user to be created") + + /** A user who signed up locally; `validated` says whether the address has been confirmed. */ + private def createLocalUser(email: String, validated: Boolean): User = { + val authUser = AuthUser.create.email(email).username(randomString(12)).validated(validated).saveMe() + Users.users.vend.getUserByResourceUserId(authUser.user.get).openOrThrowException("expected the resource user") + } + + private def linkCustomer(user: User, customerEmail: String, mobileNumber: String = "+49 123 456"): Unit = { + val customer = CustomerX.customerProvider.vend.addCustomer( + BankId(testBankId1.value), randomString(10), "Test Customer", mobileNumber, customerEmail, + CustomerFaceImage(new Date(), "http://example.com/face.png"), + new Date(), "single", 0, Nil, "Bachelor", "employed", true, new Date(), + None, None, "", "", "" + ).openOrThrowException("expected the customer to be created") + UserCustomerLink.userCustomerLink.vend.getOCreateUserCustomerLink(user.userId, customer.customerId, new Date(), true) + } + + private def createChallenge(user: User, method: StrongCustomerAuthentication.SCA, transactionRequestId: String = randomString(10)) = + Await.result( + LocalMappedConnector.createChallengesC3( + List(user.userId), ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE, Some(transactionRequestId), Some(method), + None, None, None, None, None + ), + 10.seconds + )._1 + + private def savedChallenges(transactionRequestId: String) = + Challenges.ChallengeProvider.vend.getChallengesByTransactionRequestId(transactionRequestId).openOr(Nil) + + private def failureMessage(result: net.liftweb.common.Box[_]): String = result match { + case failure: Failure => failure.msg + case other => fail(s"expected a Failure, got $other") + } + + private def challengeMails = CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge") + + private def startMailTestMode(): Unit = { + setPropsValues("mail.test.mode" -> "true") + CommonsEmailWrapper.clearTestModeSentMail() + } + + feature("The passcode of an EMAIL challenge") { + scenario("goes to the e-mail address of the linked customer") { + startMailTestMode() + val user = createUser("user-address@example.com") + linkCustomer(user, "customer-address@example.com") + + createChallenge(user, StrongCustomerAuthentication.EMAIL).map(_.size) should equal(Full(1)) + + challengeMails.map(_.to) should equal(List(List("customer-address@example.com"))) + } + + scenario("goes to the user's own address when no customer is linked") { + startMailTestMode() + val user = createLocalUser("user-address@example.com", validated = true) + + createChallenge(user, StrongCustomerAuthentication.EMAIL).map(_.size) should equal(Full(1)) + + challengeMails.map(_.to) should equal(List(List("user-address@example.com"))) + } + + scenario("goes to the user's own address when the linked customer has no e-mail address") { + startMailTestMode() + val user = createLocalUser("user-address@example.com", validated = true) + linkCustomer(user, null) + + createChallenge(user, StrongCustomerAuthentication.EMAIL).map(_.size) should equal(Full(1)) + + challengeMails.map(_.to) should equal(List(List("user-address@example.com"))) + } + + scenario("does not go to a user's own address that has not been validated") { + startMailTestMode() + val user = createLocalUser("unvalidated-address@example.com", validated = false) + + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL)) should startWith(ChallengeRecipientNotFound) + + challengeMails shouldBe empty + } + + scenario("does not go to the address of a user who did not sign up here") { + startMailTestMode() + val user = createUser("external-address@example.com") + + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL)) should startWith(ChallengeRecipientNotFound) + + challengeMails shouldBe empty + } + + scenario("is not saved when the e-mail cannot be sent") { + // An SMTP port nothing listens on: the send fails at once. + setPropsValues( + "mail.test.mode" -> "false", + "mail.smtp.host" -> "127.0.0.1", + "mail.smtp.port" -> "1", + "mail.smtp.timeout_millis" -> "2000" + ) + val user = createUser("user-address@example.com") + linkCustomer(user, "customer-address@example.com") + val transactionRequestId = randomString(10) + + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL, transactionRequestId)) should startWith(EmailSendingFailed) + + savedChallenges(transactionRequestId) shouldBe empty + } + + scenario("goes once to an address that two linked customers share") { + startMailTestMode() + val user = createUser("user-address@example.com") + linkCustomer(user, "shared-address@example.com") + linkCustomer(user, "shared-address@example.com") + + createChallenge(user, StrongCustomerAuthentication.EMAIL).map(_.size) should equal(Full(1)) + + challengeMails.map(_.to) should equal(List(List("shared-address@example.com"))) + } + + scenario("is not created when there is no address to send it to") { + startMailTestMode() + val user = createLocalUser("", validated = true) + + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL)) should startWith(ChallengeRecipientNotFound) + + challengeMails shouldBe empty + } + } + + feature("The passcode of an SMS challenge") { + scenario("is not created when no customer with a phone number is linked") { + val user = createUser("user-address@example.com") + + failureMessage(createChallenge(user, StrongCustomerAuthentication.SMS)) should startWith(ChallengeRecipientNotFound) + } + + scenario("is not saved when the message cannot be sent") { + // No SMS provider is configured in the tests, so the send fails. + val user = createUser("user-address@example.com") + linkCustomer(user, "customer-address@example.com", mobileNumber = "+49 170 0000001") + val transactionRequestId = randomString(10) + + // The caller may not own the phone number, so the answer must not show it. + failureMessage(createChallenge(user, StrongCustomerAuthentication.SMS, transactionRequestId)) should not include "+49 170 0000001" + + savedChallenges(transactionRequestId) shouldBe empty + } + } +} diff --git a/obp-api/src/test/scala/code/views/MappedViewsTest.scala b/obp-api/src/test/scala/code/views/MappedViewsTest.scala index 17b196756c..6c9c34734f 100644 --- a/obp-api/src/test/scala/code/views/MappedViewsTest.scala +++ b/obp-api/src/test/scala/code/views/MappedViewsTest.scala @@ -33,7 +33,7 @@ import code.model.ViewExtended import code.setup.{DefaultUsers, ServerSetup} import code.views.system.{ViewDefinition, ViewPermission} import com.openbankproject.commons.model.{AccountId, BankAccountCommons, BankId, BankIdAccountId, ViewId} -import net.liftweb.common.{Empty, Failure} +import net.liftweb.common.{Empty, Failure, Full} class MappedViewsTest extends ServerSetup with DefaultUsers{ @@ -313,4 +313,44 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{ } + + feature("the opt-in Berlin Group system views at boot") { + def permissionsOf(viewId: String): Option[Set[String]] = + ViewDefinition.findSystemView(viewId).toOption.map(_.allowed_actions.toSet) + + scenario("one that already exists is kept current although additional_system_views does not list it") { + Given("InitiatePaymentsBerlinGroup, created by a refresh of the mapped connector, carrying an older permission set") + val view = MapperViews.getOrCreateSystemView(Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) + .openOrThrowException("the view should be created") + ViewPermission.resetViewPermissions(view, Constant.SYSTEM_VIEW_PERMISSION_COMMON) + + When("boot keeps the opt-in views current, with additional_system_views not set") + MapperViews.ensureOptInSystemViewsUpToDate(Empty) + + Then("the view carries the permissions the code defines") + permissionsOf(Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) should equal( + Some(Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_PERMISSION.toSet)) + } + + scenario("one that already exists keeps its permissions when reconciling at boot is turned off") { + setPropsValues("system_views.reconcile_permissions_at_boot" -> "false") + val view = MapperViews.getOrCreateSystemView(Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) + .openOrThrowException("the view should be created") + ViewPermission.resetViewPermissions(view, Constant.SYSTEM_VIEW_PERMISSION_COMMON) + + MapperViews.ensureOptInSystemViewsUpToDate(Empty) + + permissionsOf(Constant.SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) should equal( + Some(Constant.SYSTEM_VIEW_PERMISSION_COMMON.toSet)) + } + + scenario("one that does not exist is created only when additional_system_views lists it") { + MapperViews.ensureOptInSystemViewsUpToDate(Empty) + permissionsOf(Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) should equal(None) + + MapperViews.ensureOptInSystemViewsUpToDate(Full(s" ${Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID} ")) + permissionsOf(Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID) should equal( + Some(Constant.SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_PERMISSION.toSet)) + } + } } diff --git a/obp-api/src/test/scala/deletion/DeletionUtilTest.scala b/obp-api/src/test/scala/deletion/DeletionUtilTest.scala new file mode 100644 index 0000000000..54286e1526 --- /dev/null +++ b/obp-api/src/test/scala/deletion/DeletionUtilTest.scala @@ -0,0 +1,119 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + +This product includes software developed at +TESOBE (http://www.tesobe.com/) + + */ +package deletion + +import org.scalatest.{FeatureSpec, Matchers} + +import java.lang.reflect.{InvocationHandler, Method, Proxy => JProxy} +import java.sql.{Connection, SQLException, SQLFeatureNotSupportedException, Savepoint} +import scala.collection.mutable.ListBuffer + +/** How a cascade uses the savepoint on its connection: DeletionUtil.withSavepoint against a recording connection. */ +class DeletionUtilTest extends FeatureSpec with Matchers { + + private val savepoint: Savepoint = new Savepoint { + def getSavepointId: Int = 1 + def getSavepointName: String = "cascade" + } + + /** A connection that records the savepoint calls it gets; `releaseFails` and `rollbackFails` make those calls throw. */ + private class RecordingConnection(releaseFails: Option[SQLException] = None, rollbackFails: Option[SQLException] = None) { + val calls = ListBuffer.empty[String] + val connection: Connection = JProxy.newProxyInstance( + classOf[Connection].getClassLoader, + Array(classOf[Connection]), + new InvocationHandler { + def invoke(proxy: Any, method: Method, args: Array[AnyRef]): AnyRef = method.getName match { + case "setSavepoint" => calls += "setSavepoint"; savepoint + case "rollback" if args != null && args.length == 1 => + calls += "rollback(savepoint)"; rollbackFails.foreach(e => throw e); null + case "releaseSavepoint" => + calls += "releaseSavepoint"; releaseFails.foreach(e => throw e); null + case other => calls += other; null + } + } + ).asInstanceOf[Connection] + } + + feature("A cascade's savepoint") { + + scenario("is released when the cascade succeeds") { + val recording = new RecordingConnection() + + DeletionUtil.withSavepoint(recording.connection)("deleted") should equal("deleted") + + recording.calls.toList should equal(List("setSavepoint", "releaseSavepoint")) + } + + scenario("is rolled back to when the cascade throws, and the cascade's exception goes on") { + val recording = new RecordingConnection() + val failure = new RuntimeException("cascade failed") + + the[RuntimeException] thrownBy DeletionUtil.withSavepoint(recording.connection)(throw failure) should be theSameInstanceAs failure + + recording.calls.toList should equal(List("setSavepoint", "rollback(savepoint)")) + } + + scenario("does not fail a cascade that succeeded on a database that cannot release a savepoint") { + // Oracle's and SQL Server's drivers throw on releaseSavepoint; the savepoint ends with the transaction anyway. + val recording = new RecordingConnection(releaseFails = Some(new SQLFeatureNotSupportedException("releaseSavepoint"))) + + DeletionUtil.withSavepoint(recording.connection)("deleted") should equal("deleted") + + recording.calls.toList should equal(List("setSavepoint", "releaseSavepoint")) + } + + scenario("that cannot be set stops the cascade before it deletes anything") { + val connection = JProxy.newProxyInstance( + classOf[Connection].getClassLoader, + Array(classOf[Connection]), + new InvocationHandler { + def invoke(proxy: Any, method: Method, args: Array[AnyRef]): AnyRef = method.getName match { + case "setSavepoint" => throw new SQLFeatureNotSupportedException("setSavepoint") + case _ => null + } + } + ).asInstanceOf[Connection] + var ran = false + + a[SQLFeatureNotSupportedException] should be thrownBy DeletionUtil.withSavepoint(connection) { ran = true } + + ran should equal(false) + } + + scenario("keeps the cascade's exception when rolling back to it fails too") { + val rollbackFailure = new SQLException("connection lost") + val recording = new RecordingConnection(rollbackFails = Some(rollbackFailure)) + val failure = new RuntimeException("cascade failed") + + val thrown = the[RuntimeException] thrownBy DeletionUtil.withSavepoint(recording.connection)(throw failure) + + thrown should be theSameInstanceAs failure + thrown.getSuppressed.toList should equal(List(rollbackFailure)) + } + } +} diff --git a/pom.xml b/pom.xml index ff08870465..1b9b208b9b 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ default `sbt clean publishM2`, which publishes only the default Scala version, so lift-persistence_2.13 did not exist however the tag was numbered. --> v1.0.5 - 0.23.30 + 0.23.35 UTF-8 ${project.build.sourceEncoding} diff --git a/release_notes.md b/release_notes.md index 6f5f276610..a90d3d7a48 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,14 @@ ### Most recent changes at top of file ``` Date Commit Action +03/11/2026 TBD CHANGED: the cascade-delete endpoints under /management/cascading are off by + default, behind the new prop allow_cascade_delete (default false). While it is + false they are not routed and not listed in the Resource Docs of any version, + so a request gets 404: DELETE /obp/v4.0.0/management/cascading/banks/BANK_ID, + .../accounts/ACCOUNT_ID, .../accounts/ACCOUNT_ID/transactions/TRANSACTION_ID, + .../products/PRODUCT_CODE and .../customers/CUSTOMER_ID. Set + allow_cascade_delete=true to keep using them. The prop is read at startup. The + Dynamic Entity cascade deletes are not affected. 01/11/2026 TBD CHANGED: with consents.allowed false (the default) consents are now refused when they are created, not only when they are used: every endpoint that creates a consent or a consent request (OBP v3.1.0, v5.0.0, v5.1.0 and v6.0.0, Berlin Group v1.3, UK Open Banking v3.1 and diff --git a/run_tests_parallel.sh b/run_tests_parallel.sh index 52cd43adfd..bf764c5198 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -214,6 +214,8 @@ run_shard() { # fallback, so DynamicUtilTest / ConnectorMethodTest / AbacRuleTests / # DynamicResourceDocTest / DynamicMessageDocTest / DynamicCodeKillSwitchTest's ON # scenarios need this set explicitly or they fail locally with OBP-50020. + # OBP_ALLOW_CASCADE_DELETE mirrors CI's allow_cascade_delete=true: the cascade-delete + # endpoints are off by default, and the Delete*CascadeTest suites get 404s without it. # OBP_BERLIN_GROUP_V1_3_ALIAS_PATH mirrors CI's berlin_group_v1_3_alias_path=0.6/v1 # (injected by both workflows' "Setup props" step): without it, OBP_BERLIN_GROUP_1_3_Alias # reports an empty ScannedApiVersion("","","") and ScannedApis.isAddressable filters it @@ -245,6 +247,7 @@ run_shard() { OBP_MAIL_TEST_MODE="true" \ OBP_DYNAMIC_CODE_SANDBOX_PERMISSIONS='[new java.net.NetPermission("specifyStreamHandler"), new java.lang.reflect.ReflectPermission("suppressAccessChecks"), new java.lang.RuntimePermission("getenv.*"), new java.lang.RuntimePermission("accessDeclaredMembers"), new java.lang.RuntimePermission("getClassLoader")]' \ OBP_ALLOW_USER_GENERATED_SCALA_CODE="true" \ + OBP_ALLOW_CASCADE_DELETE="true" \ OBP_BERLIN_GROUP_V1_3_ALIAS_PATH="0.6/v1" \ OBP_API_INSTANCE_ID="shard_${n}_${port}" \ "$TIMEOUT_BIN" 1200 mvn scalatest:test -pl obp-commons,obp-api -DfailIfNoTests=false \ @@ -449,7 +452,8 @@ fi # read_json_schema_validation_requires_role / read_authentication_type_validation_requires_role # forced true. That value is baked into Http4s400's ResourceDoc error list at object-init time, so # it needs its own JVM (pom.xml's default tagsToExclude skips this tag in every shard above, which -# all boot with the props unset i.e. false). Run sequentially, after the main shards, so it shares +# all boot with the props unset i.e. false). CascadeDeleteDisabledTest is the reverse: the shards above +# run with allow_cascade_delete=true, and this JVM runs with it false. Run sequentially, after the main shards, so it shares # no DB connection pool contention with them; its surefire XML lands in the same # obp-api/target/surefire-reports directory the audit below already scans, so no separate # reporting path is needed. @@ -484,8 +488,9 @@ else OBP_API_INSTANCE_ID="prop_gated_${PROP_PORT}" \ OBP_READ_JSON_SCHEMA_VALIDATION_REQUIRES_ROLE="true" \ OBP_READ_AUTHENTICATION_TYPE_VALIDATION_REQUIRES_ROLE="true" \ + OBP_ALLOW_CASCADE_DELETE="false" \ "$TIMEOUT_BIN" 300 mvn scalatest:test -pl obp-api -DfailIfNoTests=false \ - "-DwildcardSuites=code.api.v4_0_0.JsonSchemaValidationPublicPropTrueTest,code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest" \ + "-DwildcardSuites=code.api.v4_0_0.JsonSchemaValidationPublicPropTrueTest,code.api.v4_0_0.AuthenticationTypeValidationPublicPropTrueTest,code.api.v4_0_0.CascadeDeleteDisabledTest" \ -DtagsToInclude=PropGatedPublicEndpoint -Dtest.tagsToExclude= \ > test-results/parallel/prop_gated_public_endpoint.log 2>&1 PROP_RC=$? diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 40827e7e7b..cf0e026d2d 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -263,9 +263,9 @@ "version": "v4_0_0", "endpoint": "updateConsentStatus", "field": "description", - "reason": "Added that the consent must belong to the logged-in user, otherwise 404 ConsentNotFound", + "reason": "Added that the consent must belong to the logged-in user, otherwise 404 ConsentNotFound, and that a consent not yet authorised can be refused with REJECTED without SCA", "lift_digest": "72dc13245db8680613c4bfc56859b1b7ca4c10d35fb53f65b241c184ebf13827", - "http4s_digest": "2ab09de7128ff70488baf26b2fda2ab9f52df3565bf17644fedbc31b36e823ab" + "http4s_digest": "cfd8ec7dfc3ba41ba4ad5d3d710f8f04dff9a3690fc5216c504dee3741196ee1" }, { "version": "v4_0_0", @@ -1433,7 +1433,15 @@ "field": "description", "reason": "Documents the six-limit semantics (0=blocked, summed across records); same rate-limiting feature expansion as the other rate-limit endpoints below.", "lift_digest": "668028ad86321b68b01406fb570d1dafca545c3c45bc2a3e3c0d04dd1a5b69af", - "http4s_digest": "a70426ea3f7d3e3305f066057d548d5f810f4920a78ea5c1cc02fce5afd74208" + "http4s_digest": "20ef72c36c810c8f2e95d6d4fdae9c05438fb1a11c09b3c0d70e9d245883197f" + }, + { + "version": "v6_0_0", + "endpoint": "createCallLimits", + "field": "errorResponseBodies", + "reason": "Lists RateLimitScopeNotSupported, the 400 for a rate limit that names an api_name, api_version or bank_id.", + "lift_digest": "13a79b912b962f3b1adb4ccab12d47087a6ec04f0f76cf8989b4a0d84606dcff", + "http4s_digest": "05d96c06040c6e3c3f076a0b9916d59a4c6108a47f2e2907128a3e4235cd5577" }, { "version": "v6_0_0", @@ -1473,7 +1481,15 @@ "field": "description", "reason": "Same rate-limiting doc expansion as the other call-limit endpoints.", "lift_digest": "b4a18cf966d2dbdd3760c86771869b584cd10286dac49620d075a7cbfdca4996", - "http4s_digest": "229b587b60d0eec166b7f962703514c81e5d176ee65dd70de0c131efc221bd8c" + "http4s_digest": "e200763f8a95f7259cae9d370259d22140003a5ee3cf6eee4aba4d99429f3951" + }, + { + "version": "v6_0_0", + "endpoint": "updateRateLimits", + "field": "errorResponseBodies", + "reason": "Lists RateLimitScopeNotSupported, the 400 for a rate limit that names an api_name, api_version or bank_id.", + "lift_digest": "73a6aab6d272e734f76807465010e55db340ee57091b9590ee564de48566a42a", + "http4s_digest": "2d7e7df55e5ff6575b522f6dcf0716be5ea3f4f4ca6c1324596ae0be8b8398a0" }, { "version": "v1_4_0", @@ -2667,6 +2683,46 @@ "lift_digest": "ea8061d2189d61d4f84552f70fa4482c88aa1fb6d5f7692b7c3c6eae0b24f4e6", "http4s_digest": "181a990959c91843d2617852d9247a24ae916364b5b3afa58fab87ddd6692130" }, + { + "version": "v3_1_0", + "endpoint": "callsLimit", + "field": "description", + "reason": "Says the PUT changes or creates the consumer's one rate limit and refuses a consumer with several.", + "lift_digest": "c6539601da7002deff895c829197c1bf0b63f01af2fdae358505b2f545fbf249", + "http4s_digest": "19d2267453f1279b12e47e32cfe970976329d5b1182f641f3e25ece5cf00bd79" + }, + { + "version": "v3_1_0", + "endpoint": "callsLimit", + "field": "errorResponseBodies", + "reason": "Lists RateLimitNotUnique, the 400 for a consumer with several rate limits.", + "lift_digest": "73a6aab6d272e734f76807465010e55db340ee57091b9590ee564de48566a42a", + "http4s_digest": "f7222b11944092a114e928a045581b96e554836df8fa3f398b63dc089f4ef76a" + }, + { + "version": "v4_0_0", + "endpoint": "callsLimit", + "field": "description", + "reason": "Says the PUT changes or creates the consumer's one rate limit, refuses a consumer with several, and refuses the scope fields nothing enforces.", + "lift_digest": "b4a18cf966d2dbdd3760c86771869b584cd10286dac49620d075a7cbfdca4996", + "http4s_digest": "0df098589b7e775d57cf0384f69258b009b10d34db1cfe6dd3650a2ce3c8eacc" + }, + { + "version": "v4_0_0", + "endpoint": "callsLimit", + "field": "errorResponseBodies", + "reason": "Lists RateLimitScopeNotSupported and RateLimitNotUnique.", + "lift_digest": "73a6aab6d272e734f76807465010e55db340ee57091b9590ee564de48566a42a", + "http4s_digest": "50352369bb9cd70682e783614e177b83ddf75a136131a0c29837b045baa4a247" + }, + { + "version": "v4_0_0", + "endpoint": "deleteBankCascade", + "field": "description", + "reason": "Says that the bank's dynamic entities and all their records go with the bank and that no ZZ_BAK_ copy is made.", + "lift_digest": "2bb8a473e170e77029d901d2e12ee5ec802dc61a95eba7b0c745665613174948", + "http4s_digest": "c8e065559d6cad3b6f2fd37804cc949389ffbe460607a074038fd6139b53c45e" + }, { "version": "v4_0_0", "endpoint": "createUserCustomerLinks",