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",