From 9d17eab134002fa2eb9f50ac9d0129af4499838c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:28:30 +0100 Subject: [PATCH 01/78] feat: delete account holders in the account and bank cascades DeleteAccountCascade now removes the MapperAccountHolders rows of the deleted bank_id/account_id, and DeleteBankCascade removes them for every account of the bank through it. A holder row that outlived its account was read back by AuthUser.refreshUser (for example when the former holder created another account) and the five system views were granted on the deleted account again. --- .../scala/deletion/DeleteAccountCascade.scala | 10 +++ .../api/v4_0_0/DeleteAccountCascadeTest.scala | 66 +++++++++++++++++++ .../api/v4_0_0/DeleteBankCascadeTest.scala | 22 ++++++- 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 0a1f1eb81f..10fe721b52 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -28,6 +28,7 @@ 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.ErrorMessages.CouldNotDeleteCascade @@ -63,6 +64,7 @@ object DeleteAccountCascade { deleteTransactions(bankId, accountId) :: deleteEntitlements(bankId, accountId) :: deleteAccountAccess(bankId, accountId) :: + deleteAccountHolders(bankId, accountId) :: deleteCustomViews(bankId, accountId) :: deleteAccountAttributes(bankId, accountId) :: deleteAccountWebhooks(bankId, accountId) :: @@ -145,6 +147,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), 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..307a00627b 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 @@ -40,6 +41,7 @@ import code.api.v4_0_0.Http4s400.Implementations4_0_0 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 @@ -243,4 +245,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 account at the first bank with the cascading endpoint") + 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..d89a503ac9 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,7 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 import org.json4s._ +import code.accountholders.MapperAccountHolders import code.api.Constant import java.util.concurrent.TimeUnit @@ -43,6 +44,7 @@ import code.entitlement.Entitlement 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 +179,25 @@ class DeleteBankCascadeTest extends V400ServerSetup { transactionRequestsAfter shouldBe empty challengesAfter shouldBe empty } + 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 make a delete cascade request v4.0.0") + 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 + } } - + } From 4e3d8a913e22da073a72ce021e201163e49dddd1 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:31:30 +0100 Subject: [PATCH 02/78] feat: delete consent challenges once they can no longer be used A consent's OBP_CONSENT_CHALLENGE rows are deleted when the PSU answers one correctly on POST .../consents/CONSENT_ID/authorise (the answered challenge and any other one started on the consent), and when the consent becomes REVOKED, REJECTED or EXPIRED through updateConsentStatus, revoke or the UK expiry sweep. A wrong answer keeps its row so the attempt counter still applies. Berlin Group consent challenges and transaction request challenges are left as they are, since they are read after answering. --- .../scala/code/api/util/ConsentUtil.scala | 29 ++++ .../scala/code/api/v5_1_0/Http4s510.scala | 6 + .../scala/code/consent/MappedConsent.scala | 6 +- .../code/scheduler/ConsentScheduler.scala | 6 +- .../ChallengeProvider.scala | 7 + .../MappedChallengeProvider.scala | 6 + .../UKOpenBankingV401AccountInfoTests.scala | 129 +++++++++++++++++- 7 files changed, 186 insertions(+), 3 deletions(-) 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 0b196fa554..69e4e36505 100644 --- a/obp-api/src/main/scala/code/api/util/ConsentUtil.scala +++ b/obp-api/src/main/scala/code/api/util/ConsentUtil.scala @@ -1048,6 +1048,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/v5_1_0/Http4s510.scala b/obp-api/src/main/scala/code/api/v5_1_0/Http4s510.scala index 3bcff4c290..25ab62a618 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/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/scheduler/ConsentScheduler.scala b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala index 82e893139b..be2f84bc4f 100644 --- a/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/ConsentScheduler.scala @@ -212,7 +212,8 @@ object ConsentScheduler extends MdcLoggable { case Success(_) => logger.debug("|---> Task executed successfully") } } - 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...") @@ -239,6 +240,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/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 96409f8b2e..313497facd 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) + } + } } From 2eead2895877452d0b8276b97b6dd40d8912035d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:36:10 +0100 Subject: [PATCH 03/78] feat(props): add allow_cascade_delete, default false --- .../src/main/resources/props/sample.props.template | 12 ++++++++++++ obp-api/src/main/scala/code/api/util/APIUtil.scala | 7 +++++++ 2 files changed, 19 insertions(+) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index e2b2ba4ef1..720407e789 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -631,6 +631,18 @@ 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 an 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 +## DELETE /obp/v6.0.0/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID +## DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_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. +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 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 83e2855fd6..973b8e1761 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2618,6 +2618,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 cascade-delete endpoints (the v4.0.0 + * /management/cascading/... ones and the Dynamic Entity .../cascade/DYNAMIC_ENTITY_ID ones in + * v6.0.0 and v7.0.0) 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. + */ + 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) From b6de8372b2b0ab82c764f2cb853c22ab0bf364e4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:36:51 +0100 Subject: [PATCH 04/78] feat(v4.0.0): register cascade-delete endpoints only when allow_cascade_delete is true --- .../scala/code/api/v4_0_0/Http4s400.scala | 204 +++++++++--------- 1 file changed, 108 insertions(+), 96 deletions(-) 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 f32ccf454d..cd1aac071e 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 @@ -7923,105 +7923,108 @@ object Http4s400 { http4sPartialFunction = Some(deleteUserCustomerLink) ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteTransactionCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID", - "Delete Transaction Cascade", - s"""Delete a Transaction Cascade specified by TRANSACTION_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), - List(apiTagTransaction), - Some(List(canDeleteTransactionCascade)), - http4sPartialFunction = Some(deleteTransactionCascade) - ) + // Cascade deletes are documented, and routed, only when allow_cascade_delete=true. + if (APIUtil.cascadeDeleteEnabled) { + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteTransactionCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID", + "Delete Transaction Cascade", + s"""Delete a Transaction Cascade specified by TRANSACTION_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagTransaction), + Some(List(canDeleteTransactionCascade)), + http4sPartialFunction = Some(deleteTransactionCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteAccountCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID", - "Delete Account Cascade", - s"""Delete an Account Cascade specified by ACCOUNT_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), - List(apiTagAccount), - Some(List(canDeleteAccountCascade)), - http4sPartialFunction = Some(deleteAccountCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteAccountCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID", + "Delete Account Cascade", + s"""Delete an Account Cascade specified by ACCOUNT_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagAccount), + Some(List(canDeleteAccountCascade)), + http4sPartialFunction = Some(deleteAccountCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteBankCascade), - "DELETE", - "/management/cascading/banks/BANK_ID", - "Delete Bank Cascade", - s"""Delete a Bank Cascade specified by BANK_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), - List(apiTagBank), - Some(List(canDeleteBankCascade)), - http4sPartialFunction = Some(deleteBankCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteBankCascade), + "DELETE", + "/management/cascading/banks/BANK_ID", + "Delete Bank Cascade", + s"""Delete a Bank Cascade specified by BANK_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagBank), + Some(List(canDeleteBankCascade)), + http4sPartialFunction = Some(deleteBankCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteProductCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/products/PRODUCT_CODE", - "Delete Product Cascade", - s"""Delete a Product Cascade specified by PRODUCT_CODE. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), - List(apiTagProduct), - Some(List(canDeleteProductCascade)), - http4sPartialFunction = Some(deleteProductCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteProductCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/products/PRODUCT_CODE", + "Delete Product Cascade", + s"""Delete a Product Cascade specified by PRODUCT_CODE. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canDeleteProductCascade)), + http4sPartialFunction = Some(deleteProductCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteCustomerCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/customers/CUSTOMER_ID", - "Delete Customer Cascade", - s"""Delete a Customer Cascade specified by CUSTOMER_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, CustomerNotFoundByCustomerId, UserHasMissingRoles, UnknownError), - List(apiTagCustomer), - Some(List(canDeleteCustomerCascade)), - http4sPartialFunction = Some(deleteCustomerCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteCustomerCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/customers/CUSTOMER_ID", + "Delete Customer Cascade", + s"""Delete a Customer Cascade specified by CUSTOMER_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, CustomerNotFoundByCustomerId, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canDeleteCustomerCascade)), + http4sPartialFunction = Some(deleteCustomerCascade) + ) + } staticResourceDocs += ResourceDoc( implementedInApiVersion, nameOf(deleteSystemLevelEndpointTag), "DELETE", @@ -11249,6 +11252,15 @@ object Http4s400 { // ─── allRoutes ──────────────────────────────────────────────────────────── + // Left out of routesInOrder unless allow_cascade_delete=true, like their ResourceDocs. + 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( @@ -11525,7 +11537,7 @@ object Http4s400 { createUserWithRoles, createUserWithAccountAccess, createUserInvitation - ) + ).filter(route => APIUtil.cascadeDeleteEnabled || !cascadeDeleteRoutes.contains(route)) lazy val orderedResourceDocs: ArrayBuffer[ResourceDoc] = ResourceDocMatcher.orderByRoutes(resourceDocs, routesInOrder) From 6a4449203266b1fe3771a4f4dfa96b11f318aded Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:42:01 +0100 Subject: [PATCH 05/78] feat(deletion): cascade account deletion to the challenges of consents bound to it DELETE /obp/v4.0.0/management/cascading/banks/{BANK_ID}/accounts/{ACCOUNT_ID} now also removes the SCA challenges of every consent whose JWT views name the account. The consents are read from the users who hold or can see the account, before their account access rows are deleted. The bank cascade deletes each account through the same code. --- .../scala/deletion/DeleteAccountCascade.scala | 36 ++++++++++++- .../api/v4_0_0/DeleteAccountCascadeTest.scala | 53 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 0a1f1eb81f..653841567a 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -28,11 +28,14 @@ TESOBE (http://www.tesobe.com/) package deletion import code.accountattribute.MappedAccountAttribute +import code.accountholders.AccountHolders 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.cards.MappedPhysicalCard +import code.consent.Consents import code.counterpartyattribute.CounterpartyAttribute import code.counterpartylimit.CounterpartyLimit import code.entitlement.MappedEntitlement @@ -43,11 +46,14 @@ 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.util.Helpers.tryo +import org.json4s.Formats import scala.collection.immutable.List @@ -55,9 +61,12 @@ object DeleteAccountCascade { def delete(bankId: BankId, accountId: AccountId): Boolean = { // 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. + // requests, so they must go before deleteTransactionRequests and deleteTransactions remove them; + // the consent challenges are found through the users who hold or can see the account, so they + // must go before deleteAccountAccess removes the rows that name those users. val doneTasks = - deleteTransactionRequestChallenges(bankId, accountId) :: + deleteConsentChallenges(bankId, accountId) :: + deleteTransactionRequestChallenges(bankId, accountId) :: deleteTransactionRequests(bankId, accountId) :: deleteDoubleEntryBookTransactions(bankId, accountId) :: deleteTransactions(bankId, accountId) :: @@ -210,6 +219,29 @@ object DeleteAccountCascade { } } + // A consent names its accounts only in the views of its JWT, so the consents of the users who hold + // or can see this account are read to find the ones bound to it. 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 holderIds = AccountHolders.accountHolders.vend.getAccountHolders(bankId, accountId).map(_.userId) + val viewerIds = AccountAccess.findAll( + By(AccountAccess.bank_id, bankId.value), + By(AccountAccess.account_id, accountId.value) + ).flatMap(_.user_fk.foreign.map(_.userId)) + val consentIds = (holderIds.toList ++ viewerIds).distinct + .flatMap(Consents.consentProvider.vend.getConsentsByUser) + .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) + .distinct + 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/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala index f9b5b2f8bf..046e07b598 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 @@ -38,8 +38,10 @@ import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequ import code.api.v3_1_0.CreateAccountResponseJsonV310 import code.api.v4_0_0.Http4s400.Implementations4_0_0 import code.cards.MappedPhysicalCard +import code.consent.Consents import code.entitlement.{Entitlement, MappedEntitlement} import code.model.dataAccess.MappedBankAccount +import code.transactionChallenge.MappedExpectedChallengeAnswer import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage} import com.openbankproject.commons.util.ApiVersion @@ -243,4 +245,55 @@ class DeleteAccountCascadeTest extends V400ServerSetup { } } + feature(s"test $ApiEndpoint1 version $VersionOfApi - consent challenges") { + scenario("Deleting an account deletes the SCA challenges of the consents bound to it", ApiEndpoint1, VersionOfApi) { + setPropsValues("suggested_default_sca_method" -> "DUMMY") + Given("Two banks that each have an account with the same ACCOUNT_ID, both 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") + + // A UK consent is lodged without a PSU and bound to the accounts the PSU selects when it + // answers the challenge, the way Portal authorises one. + def authorisedUKConsent(bank: BankId): String = { + val consentId = Consents.consentProvider.vend.saveUKConsent( + user = None, bankId = None, accountIds = None, consumerId = None, + permissions = List("ReadAccountsBasic"), + expirationDateTime = Some(new java.util.Date(System.currentTimeMillis() + 3600000L)), + transactionFromDateTime = None, transactionToDateTime = None, + apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") + ).openOrThrowException("consent creation failed").consentId + val consentPath = v5_1_0_Request / "banks" / bank.value / "consents" / consentId / "authorise" + val challenge = makePostRequest((consentPath / "challenge").POST <@ (user1), "") + challenge.code should equal(200) + val challengeId = (challenge.body \ "challenge_id").extract[String] + val authorised = makePostRequest(consentPath.POST <@ (user1), + s"""{"account_ids":["${accountId.value}"],"challenge_id":"$challengeId","answer":"123"}""") + authorised.code should equal(200) + consentId + } + def challengesOf(consentId: String): Int = + MappedExpectedChallengeAnswer.findAll(By(MappedExpectedChallengeAnswer.ConsentId, consentId)).size + val consent = authorisedUKConsent(bankId) + val otherBankConsent = authorisedUKConsent(otherBankId) + challengesOf(consent) should equal(1) + challengesOf(otherBankConsent) should equal(1) + + When("We delete the account at the first bank with the cascading endpoint") + 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 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 bank's account is kept") + challengesOf(otherBankConsent) should equal(1) + } + } + } From 7263721867f370f6e01fa1d3d9280517d0a316b7 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 14:34:01 +0100 Subject: [PATCH 06/78] test: name the repeated cascade step once in DeleteAccountCascadeTest --- .../scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 046e07b598..1a4b90abb0 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 @@ -64,6 +64,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")) @@ -197,7 +199,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) @@ -231,7 +233,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) @@ -283,7 +285,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { challengesOf(consent) should equal(1) challengesOf(otherBankConsent) should equal(1) - 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) From 5affdc14c3eb52c0bbedf2bde1b54740ea68b798 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 14:36:05 +0100 Subject: [PATCH 07/78] test: give the new cascade holder steps their own descriptions --- .../test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala | 2 +- .../src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 307a00627b..8d8e41078b 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 @@ -297,7 +297,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR") createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR") - When("We delete the account at the first bank with the cascading endpoint") + 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) 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 d89a503ac9..d876cdd017 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 @@ -190,7 +190,7 @@ class DeleteBankCascadeTest extends V400ServerSetup { When("We grant the role") Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) - And("We make a delete cascade request v4.0.0") + 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) From 36308526732b0eacb8d10246af499fe9ac6eadf0 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 15:30:38 +0100 Subject: [PATCH 08/78] fix(deletion): find the consent challenges to cascade through the challenge table The account cascade looked for consents through the users who hold or can see the account, which misses a Berlin Group consent whose first challenge is still unanswered: no PSU is bound to it yet. The consents are now read from the challenges that carry a consent id and kept when their JWT names the account. The test now uses such a Berlin Group consent instead of an authorised UK consent, whose challenge is deleted once answered. --- .../scala/deletion/DeleteAccountCascade.scala | 26 ++--- .../v1_3/BerlinGroupConsentFixtures.scala | 13 +-- ...teAccountCascadeConsentChallengeTest.scala | 97 +++++++++++++++++++ .../api/v4_0_0/DeleteAccountCascadeTest.scala | 53 ---------- 4 files changed, 114 insertions(+), 75 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/berlin/group/v1_3/DeleteAccountCascadeConsentChallengeTest.scala diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 653841567a..aeacade05f 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -28,7 +28,6 @@ TESOBE (http://www.tesobe.com/) package deletion import code.accountattribute.MappedAccountAttribute -import code.accountholders.AccountHolders import code.api.APIFailureNewStyle import code.api.util.APIUtil.fullBoxOrException import code.api.util.{ConsentJWT, CustomJsonFormats, JwtUtil} @@ -50,7 +49,7 @@ 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.mapper.{By, ByList, NotBy} import net.liftweb.util.DefaultConnectionIdentifier import net.liftweb.util.Helpers.tryo import org.json4s.Formats @@ -61,9 +60,7 @@ object DeleteAccountCascade { def delete(bankId: BankId, accountId: AccountId): Boolean = { // 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; - // the consent challenges are found through the users who hold or can see the account, so they - // must go before deleteAccountAccess removes the rows that name those users. + // requests, so they must go before deleteTransactionRequests and deleteTransactions remove them. val doneTasks = deleteConsentChallenges(bankId, accountId) :: deleteTransactionRequestChallenges(bankId, accountId) :: @@ -219,25 +216,22 @@ object DeleteAccountCascade { } } - // A consent names its accounts only in the views of its JWT, so the consents of the users who hold - // or can see this account are read to find the ones bound to it. A consent bound to other accounts - // as well loses its challenges too: they record the authorisation of the consent as a whole. + // 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 holderIds = AccountHolders.accountHolders.vend.getAccountHolders(bankId, accountId).map(_.userId) - val viewerIds = AccountAccess.findAll( - By(AccountAccess.bank_id, bankId.value), - By(AccountAccess.account_id, accountId.value) - ).flatMap(_.user_fk.foreign.map(_.userId)) - val consentIds = (holderIds.toList ++ viewerIds).distinct - .flatMap(Consents.consentProvider.vend.getConsentsByUser) + 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) - .distinct consentIds.isEmpty || MappedExpectedChallengeAnswer.bulkDelete_!!( ByList(MappedExpectedChallengeAnswer.ConsentId, consentIds) ) 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/v4_0_0/DeleteAccountCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala index 1a4b90abb0..1f85427746 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 @@ -38,10 +38,8 @@ import code.api.util.ErrorMessages.{UserHasMissingRoles, AuthenticatedUserIsRequ import code.api.v3_1_0.CreateAccountResponseJsonV310 import code.api.v4_0_0.Http4s400.Implementations4_0_0 import code.cards.MappedPhysicalCard -import code.consent.Consents import code.entitlement.{Entitlement, MappedEntitlement} import code.model.dataAccess.MappedBankAccount -import code.transactionChallenge.MappedExpectedChallengeAnswer import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage} import com.openbankproject.commons.util.ApiVersion @@ -247,55 +245,4 @@ class DeleteAccountCascadeTest extends V400ServerSetup { } } - feature(s"test $ApiEndpoint1 version $VersionOfApi - consent challenges") { - scenario("Deleting an account deletes the SCA challenges of the consents bound to it", ApiEndpoint1, VersionOfApi) { - setPropsValues("suggested_default_sca_method" -> "DUMMY") - Given("Two banks that each have an account with the same ACCOUNT_ID, both 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") - - // A UK consent is lodged without a PSU and bound to the accounts the PSU selects when it - // answers the challenge, the way Portal authorises one. - def authorisedUKConsent(bank: BankId): String = { - val consentId = Consents.consentProvider.vend.saveUKConsent( - user = None, bankId = None, accountIds = None, consumerId = None, - permissions = List("ReadAccountsBasic"), - expirationDateTime = Some(new java.util.Date(System.currentTimeMillis() + 3600000L)), - transactionFromDateTime = None, transactionToDateTime = None, - apiStandard = Some("UKOpenBanking"), apiVersion = Some("4.0.1") - ).openOrThrowException("consent creation failed").consentId - val consentPath = v5_1_0_Request / "banks" / bank.value / "consents" / consentId / "authorise" - val challenge = makePostRequest((consentPath / "challenge").POST <@ (user1), "") - challenge.code should equal(200) - val challengeId = (challenge.body \ "challenge_id").extract[String] - val authorised = makePostRequest(consentPath.POST <@ (user1), - s"""{"account_ids":["${accountId.value}"],"challenge_id":"$challengeId","answer":"123"}""") - authorised.code should equal(200) - consentId - } - def challengesOf(consentId: String): Int = - MappedExpectedChallengeAnswer.findAll(By(MappedExpectedChallengeAnswer.ConsentId, consentId)).size - val consent = authorisedUKConsent(bankId) - val otherBankConsent = authorisedUKConsent(otherBankId) - challengesOf(consent) should equal(1) - challengesOf(otherBankConsent) should equal(1) - - 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) - 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 bank's account is kept") - challengesOf(otherBankConsent) should equal(1) - } - } - } From 17eca475e4d32ff8284be08cdf353844890d08b9 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:38:51 +0100 Subject: [PATCH 09/78] test: enable allow_cascade_delete for tests and run the disabled case in shard 10 --- .github/workflows/build_container.yml | 8 +++++++- .github/workflows/build_pull_request.yml | 8 +++++++- CLAUDE.md | 4 ++-- obp-api/pom.xml | 4 +++- .../main/resources/props/test.default.props.template | 3 +++ run_tests_parallel.sh | 10 ++++++++-- 6 files changed, 30 insertions(+), 7 deletions(-) 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..3e0d68218f 100644 --- a/obp-api/pom.xml +++ b/obp-api/pom.xml @@ -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/test.default.props.template b/obp-api/src/main/resources/props/test.default.props.template index 4a9812f382..e1d761ad5f 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, DeleteCascadeCounterpartiesTest +# and the Dynamic Entity cascade tests 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/run_tests_parallel.sh b/run_tests_parallel.sh index 52cd43adfd..700d0caea6 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -214,6 +214,9 @@ 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 / Dynamic Entity cascade 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 +248,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 +453,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 +489,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=$? From 5492a7272f4749925e2c98c1454e5e236842e4f0 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:43:44 +0100 Subject: [PATCH 10/78] test: cover cascade-delete endpoints with allow_cascade_delete on and off --- .../v4_0_0/CascadeDeleteDisabledTest.scala | 84 +++++++++++++++++ .../v4_0_0/CascadeDeleteEndpointsTest.scala | 91 +++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteDisabledTest.scala create mode 100644 obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteEndpointsTest.scala 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..a56d3d832d --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteDisabledTest.scala @@ -0,0 +1,84 @@ +/** +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.found.foreach { case (where, names) => + withClue(where) { names should be(empty) } + } + } + + 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", + v6_0_0_Request / "management" / "system-dynamic-entities" / "cascade" / "DYNAMIC_ENTITY_ID", + v7_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / "cascade" / "DYNAMIC_ENTITY_ID", + // v7.0.0 hands paths it has no route for down to the older versions. + 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..880f42b974 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteEndpointsTest.scala @@ -0,0 +1,91 @@ +/** +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 +import code.api.util.APIUtil.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) { + CascadeDeleteEndpoints.enabled should be(true) + } + + scenario("Every cascade-delete endpoint is routed and listed in the Resource Docs", VersionOfApi) { + val found = CascadeDeleteEndpoints.found + found("v4.0.0 routed docs") should equal(CascadeDeleteEndpoints.v400Names.toSet) + found("v4.0.0 catalog") should equal(CascadeDeleteEndpoints.v400Names.toSet) + found("v6.0.0 routed docs") should equal(CascadeDeleteEndpoints.v600Names.toSet) + found("v6.0.0 catalog") should equal((CascadeDeleteEndpoints.v400Names ++ CascadeDeleteEndpoints.v600Names).toSet) + found("v7.0.0 routed docs") should equal(CascadeDeleteEndpoints.v700Names.toSet) + } + } +} + +/** The cascade-delete endpoints that allow_cascade_delete switches on and off. */ +object CascadeDeleteEndpoints { + + val v400Names: List[String] = List( + nameOf(Implementations4_0_0.deleteTransactionCascade), + nameOf(Implementations4_0_0.deleteAccountCascade), + nameOf(Implementations4_0_0.deleteBankCascade), + nameOf(Implementations4_0_0.deleteProductCascade), + nameOf(Implementations4_0_0.deleteCustomerCascade) + ) + val v600Names: List[String] = List(nameOf(Implementations6_0_0.deleteSystemDynamicEntityCascade)) + val v700Names: List[String] = List(nameOf(Http4s700DynamicEntityDefinitions.deleteDynamicEntityDefinitionCascade)) + + private def names(docs: Iterable[ResourceDoc]): Set[String] = docs.map(_.partialFunctionName).toSet + + /** The cascade-delete names in each list the endpoints are routed or documented from. */ + def found: Map[String, Set[String]] = Map( + "v4.0.0 routed docs" -> (names(Implementations4_0_0.orderedResourceDocs) intersect v400Names.toSet), + "v4.0.0 catalog" -> (names(Http4sResourceDocAggregation.v400) intersect v400Names.toSet), + "v6.0.0 routed docs" -> (names(Implementations6_0_0.orderedResourceDocs) intersect v600Names.toSet), + "v6.0.0 catalog" -> (names(Http4sResourceDocAggregation.v600) intersect (v400Names ++ v600Names).toSet), + "v7.0.0 routed docs" -> (names(Implementations7_0_0.orderedResourceDocs) intersect v700Names.toSet) + ) + + def enabled: Boolean = APIUtil.cascadeDeleteEnabled +} From fcb54d746c0b7e3e8a45c8ad9fb48770916f9c3b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 13:43:45 +0100 Subject: [PATCH 11/78] docs: note allow_cascade_delete in the glossary and release notes --- obp-api/src/main/scala/code/api/util/Glossary.scala | 6 +++--- release_notes.md | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) 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 8c59b4f8a7..a9c93b9fcd 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3862,7 +3862,7 @@ object Glossary extends MdcLoggable { |* UserId may be stored for audit purposes but is **ignored in queries** |* All authorized users see the same shared data |* Role-based authorization is **required** (e.g., CanGetDynamicEntity_FooBar) -|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation +|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation (when the prop `allow_cascade_delete` is true; it is false by default and the cascade endpoints do not exist then) | |**Summary table:** | @@ -3872,7 +3872,7 @@ object Glossary extends MdcLoggable { || UserId in queries | Yes (filters results) | No (ignored) | || 'my' endpoints | Generated | Not generated | || Authorization | User-scoped (no roles needed for 'my' endpoints) | Role-based | -|| Cascade delete | Blocked | Allowed | +|| Cascade delete | Blocked | Allowed (with `allow_cascade_delete=true`) | | |**For bank-level entities**, endpoints include the bank ID: | @@ -3928,7 +3928,7 @@ object Glossary extends MdcLoggable { |* PUT /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition (CanUpdateDynamicEntityDefinition) |* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID - Delete a definition that has no records (CanDeleteDynamicEntityDefinition) |* POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup - Copy a definition and its records to a `_BAK` entity (CanBackupDynamicEntityDefinition) -|* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID - Delete a definition and its records, after copying both to a `ZZ_BAK_` entity (CanDeleteCascadeDynamicEntityDefinition) +|* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID - Delete a definition and its records, after copying both to a `ZZ_BAK_` entity (CanDeleteCascadeDynamicEntityDefinition; only when `allow_cascade_delete=true`) | |**Record endpoints from v7.0.0:** | diff --git a/release_notes.md b/release_notes.md index 8381c983ea..3d2f99d498 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,15 @@ ### Most recent changes at top of file ``` Date Commit Action +03/11/2026 TBD CHANGED: the cascade-delete endpoints 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, + .../customers/CUSTOMER_ID, DELETE /obp/v6.0.0/management/system-dynamic-entities/ + cascade/DYNAMIC_ENTITY_ID and DELETE /obp/v7.0.0/management/banks/BANK_ID/ + dynamic-entities/cascade/DYNAMIC_ENTITY_ID. Set allow_cascade_delete=true to + keep using them. The prop is read at startup. 13/10/2026 TBD ADDED: Domain APIs have is_publicly_discoverable (new column domain_api.is_publicly_discoverable, false by default). The new public GET /obp/v7.0.0/banks/BANK_ID/domain-apis lists, without authentication, the From dab43ef661f7a317f969229c64c090a3606dd5b6 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 15:52:19 +0100 Subject: [PATCH 12/78] refactor: limit allow_cascade_delete to the /management/cascading endpoints The Dynamic Entity cascade deletes back up to a ZZ_BAK_ entity before deleting and are used by API Manager, so they stay available whatever the prop says. The tests check that they are still routed with the prop off. --- .../resources/props/sample.props.template | 7 ++-- .../props/test.default.props.template | 4 +-- .../main/scala/code/api/util/APIUtil.scala | 8 ++--- .../main/scala/code/api/util/Glossary.scala | 6 ++-- .../v4_0_0/CascadeDeleteDisabledTest.scala | 9 ++--- .../v4_0_0/CascadeDeleteEndpointsTest.scala | 36 ++++++++++--------- release_notes.md | 17 +++++---- run_tests_parallel.sh | 3 +- 8 files changed, 45 insertions(+), 45 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 720407e789..b8701a5b5b 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -631,16 +631,15 @@ 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 an object together with everything stored under it. +## 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 -## DELETE /obp/v6.0.0/management/system-dynamic-entities/cascade/DYNAMIC_ENTITY_ID -## DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_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. +## 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. allow_cascade_delete=false ## Secret key that allows access to api calls to get info about oauth tokens. You should change this 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 e1d761ad5f..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,8 +115,8 @@ 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, DeleteCascadeCounterpartiesTest -# and the Dynamic Entity cascade tests need them. CascadeDeleteDisabledTest runs with them off. +# 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 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 973b8e1761..9f9c9be423 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2619,10 +2619,10 @@ 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 cascade-delete endpoints (the v4.0.0 - * /management/cascading/... ones and the Dynamic Entity .../cascade/DYNAMIC_ENTITY_ID ones in - * v6.0.0 and v7.0.0) 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. + * 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) 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 a9c93b9fcd..8c59b4f8a7 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -3862,7 +3862,7 @@ object Glossary extends MdcLoggable { |* UserId may be stored for audit purposes but is **ignored in queries** |* All authorized users see the same shared data |* Role-based authorization is **required** (e.g., CanGetDynamicEntity_FooBar) -|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation (when the prop `allow_cascade_delete` is true; it is false by default and the cascade endpoints do not exist then) +|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation | |**Summary table:** | @@ -3872,7 +3872,7 @@ object Glossary extends MdcLoggable { || UserId in queries | Yes (filters results) | No (ignored) | || 'my' endpoints | Generated | Not generated | || Authorization | User-scoped (no roles needed for 'my' endpoints) | Role-based | -|| Cascade delete | Blocked | Allowed (with `allow_cascade_delete=true`) | +|| Cascade delete | Blocked | Allowed | | |**For bank-level entities**, endpoints include the bank ID: | @@ -3928,7 +3928,7 @@ object Glossary extends MdcLoggable { |* PUT /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition (CanUpdateDynamicEntityDefinition) |* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID - Delete a definition that has no records (CanDeleteDynamicEntityDefinition) |* POST /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/backup - Copy a definition and its records to a `_BAK` entity (CanBackupDynamicEntityDefinition) -|* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID - Delete a definition and its records, after copying both to a `ZZ_BAK_` entity (CanDeleteCascadeDynamicEntityDefinition; only when `allow_cascade_delete=true`) +|* DELETE /obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/cascade/DYNAMIC_ENTITY_ID - Delete a definition and its records, after copying both to a `ZZ_BAK_` entity (CanDeleteCascadeDynamicEntityDefinition) | |**Record endpoints from v7.0.0:** | 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 index a56d3d832d..7db5ce50d3 100644 --- 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 @@ -42,7 +42,6 @@ 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") { @@ -51,11 +50,15 @@ class CascadeDeleteDisabledTest extends V400ServerSetup { } scenario("No cascade-delete endpoint is routed or listed in the Resource Docs", PropGatedPublicEndpoint, VersionOfApi) { - CascadeDeleteEndpoints.found.foreach { case (where, names) => + CascadeDeleteEndpoints.gated.foreach { case (where, names) => withClue(where) { names 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( @@ -64,8 +67,6 @@ class CascadeDeleteDisabledTest extends V400ServerSetup { 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", - v6_0_0_Request / "management" / "system-dynamic-entities" / "cascade" / "DYNAMIC_ENTITY_ID", - v7_0_0_Request / "management" / "banks" / bankId / "dynamic-entities" / "cascade" / "DYNAMIC_ENTITY_ID", // v7.0.0 hands paths it has no route for down to the older versions. v7_0_0_Request / "management" / "cascading" / "banks" / bankId ) 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 index 880f42b974..1889240910 100644 --- 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 @@ -53,39 +53,41 @@ class CascadeDeleteEndpointsTest extends V400ServerSetup { } scenario("Every cascade-delete endpoint is routed and listed in the Resource Docs", VersionOfApi) { - val found = CascadeDeleteEndpoints.found - found("v4.0.0 routed docs") should equal(CascadeDeleteEndpoints.v400Names.toSet) - found("v4.0.0 catalog") should equal(CascadeDeleteEndpoints.v400Names.toSet) - found("v6.0.0 routed docs") should equal(CascadeDeleteEndpoints.v600Names.toSet) - found("v6.0.0 catalog") should equal((CascadeDeleteEndpoints.v400Names ++ CascadeDeleteEndpoints.v600Names).toSet) - found("v7.0.0 routed docs") should equal(CascadeDeleteEndpoints.v700Names.toSet) + CascadeDeleteEndpoints.gated.foreach { case (where, names) => + withClue(where) { names should equal(CascadeDeleteEndpoints.gatedNames.toSet) } + } + } + + 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. */ +/** The cascade-delete endpoints that allow_cascade_delete switches on and off, and the ones it leaves alone. */ object CascadeDeleteEndpoints { - val v400Names: List[String] = List( + val gatedNames: List[String] = List( nameOf(Implementations4_0_0.deleteTransactionCascade), nameOf(Implementations4_0_0.deleteAccountCascade), nameOf(Implementations4_0_0.deleteBankCascade), nameOf(Implementations4_0_0.deleteProductCascade), nameOf(Implementations4_0_0.deleteCustomerCascade) ) - val v600Names: List[String] = List(nameOf(Implementations6_0_0.deleteSystemDynamicEntityCascade)) - val v700Names: List[String] = List(nameOf(Http4s700DynamicEntityDefinitions.deleteDynamicEntityDefinitionCascade)) private def names(docs: Iterable[ResourceDoc]): Set[String] = docs.map(_.partialFunctionName).toSet - /** The cascade-delete names in each list the endpoints are routed or documented from. */ - def found: Map[String, Set[String]] = Map( - "v4.0.0 routed docs" -> (names(Implementations4_0_0.orderedResourceDocs) intersect v400Names.toSet), - "v4.0.0 catalog" -> (names(Http4sResourceDocAggregation.v400) intersect v400Names.toSet), - "v6.0.0 routed docs" -> (names(Implementations6_0_0.orderedResourceDocs) intersect v600Names.toSet), - "v6.0.0 catalog" -> (names(Http4sResourceDocAggregation.v600) intersect (v400Names ++ v600Names).toSet), - "v7.0.0 routed docs" -> (names(Implementations7_0_0.orderedResourceDocs) intersect v700Names.toSet) + /** The gated names in each list the endpoints are routed or documented from. */ + def gated: Map[String, Set[String]] = Map( + "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)) + def enabled: Boolean = APIUtil.cascadeDeleteEnabled } diff --git a/release_notes.md b/release_notes.md index 3d2f99d498..4ab9ffe416 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,15 +3,14 @@ ### Most recent changes at top of file ``` Date Commit Action -03/11/2026 TBD CHANGED: the cascade-delete endpoints 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, - .../customers/CUSTOMER_ID, DELETE /obp/v6.0.0/management/system-dynamic-entities/ - cascade/DYNAMIC_ENTITY_ID and DELETE /obp/v7.0.0/management/banks/BANK_ID/ - dynamic-entities/cascade/DYNAMIC_ENTITY_ID. Set allow_cascade_delete=true to - keep using them. The prop is read at startup. +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. 13/10/2026 TBD ADDED: Domain APIs have is_publicly_discoverable (new column domain_api.is_publicly_discoverable, false by default). The new public GET /obp/v7.0.0/banks/BANK_ID/domain-apis lists, without authentication, the diff --git a/run_tests_parallel.sh b/run_tests_parallel.sh index 700d0caea6..bf764c5198 100755 --- a/run_tests_parallel.sh +++ b/run_tests_parallel.sh @@ -215,8 +215,7 @@ run_shard() { # 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 / Dynamic Entity cascade suites - # get 404s without it. + # 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 From 3992ccc6e2ff9654d51e8f4f3e35f3c639c7bf59 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 16:37:47 +0100 Subject: [PATCH 13/78] feat(consent): let the PSU refuse a consent it has not authorised without SCA PUT /banks/BANK_ID/consents/CONSENT_ID changed nothing while consents.sca.enabled was on, yet answered 200, so Portal's Deny on a Berlin Group consent showed the consent as denied while it stayed received. Setting REJECTED on a consent that is INITIATED, received or AWAITINGAUTHORISATION now stores the refusal, as rejected for a Berlin Group consent; other changes keep their behaviour. --- .../scala/code/api/v4_0_0/Http4s400.scala | 23 ++++++-- .../scala/code/api/v4_0_0/ConsentTests.scala | 55 ++++++++++++++++++- 2 files changed, 73 insertions(+), 5 deletions(-) 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 f32ccf454d..d0ea8adef8 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 @@ -5382,18 +5382,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) => @@ -5574,6 +5585,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(", ")}. | 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 From 323d0be9fb49674bfaf90e032046fcc46053c646 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 16:45:42 +0100 Subject: [PATCH 14/78] chore(resource-docs): allowlist the refusal sentence in updateConsentStatus's description --- scripts/resource_doc_baseline/parity_allowlist.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 550670274d..b697816d8c 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", From d1e7230d57374badfa76b170369b940ef6e0955a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 16:55:07 +0100 Subject: [PATCH 15/78] test: check the cascade-delete route chain and every version prefix CascadeDeleteEndpoints.gated now also reads Implementations4_0_0.routesInOrder, so a route left in the chain without its doc is caught: the middleware never runs such a route, and the 404 requests alone cannot see it. The 404 check also goes through the v5.0.0, v5.1.0 and v6.0.0 prefixes, and the enabled suite says which prop is missing when it fails. --- .../v4_0_0/CascadeDeleteDisabledTest.scala | 6 ++++- .../v4_0_0/CascadeDeleteEndpointsTest.scala | 23 ++++++++++++------- 2 files changed, 20 insertions(+), 9 deletions(-) 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 index 7db5ce50d3..8c98763c97 100644 --- 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 @@ -42,6 +42,7 @@ 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") { @@ -67,7 +68,10 @@ class CascadeDeleteDisabledTest extends V400ServerSetup { 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", - // v7.0.0 hands paths it has no route for down to the older versions. + // 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 => 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 index 1889240910..15a7a40b87 100644 --- 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 @@ -28,7 +28,7 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 import code.api.util.APIUtil -import code.api.util.APIUtil.ResourceDoc +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 @@ -49,7 +49,10 @@ class CascadeDeleteEndpointsTest extends V400ServerSetup { feature("allow_cascade_delete=true") { scenario("The prop is on in this JVM", VersionOfApi) { - CascadeDeleteEndpoints.enabled should be(true) + 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) { @@ -67,18 +70,22 @@ class CascadeDeleteEndpointsTest extends V400ServerSetup { /** The cascade-delete endpoints that allow_cascade_delete switches on and off, and the ones it leaves alone. */ object CascadeDeleteEndpoints { - val gatedNames: List[String] = List( - nameOf(Implementations4_0_0.deleteTransactionCascade), - nameOf(Implementations4_0_0.deleteAccountCascade), - nameOf(Implementations4_0_0.deleteBankCascade), - nameOf(Implementations4_0_0.deleteProductCascade), - nameOf(Implementations4_0_0.deleteCustomerCascade) + 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) From de6a1b6fb85be5766a7c6e4f644e60177b63875d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 16:55:07 +0100 Subject: [PATCH 16/78] refactor(v4.0.0): gate cascade-delete docs and routes from one route set The five ResourceDocs are registered as before and removed again by cascadeDeleteRoutes when allow_cascade_delete is false, the same set that leaves their routes out of routesInOrder, so a cascade endpoint added later needs one entry. The prop is read once per JVM. --- .../scala/code/api/v4_0_0/Http4s400.scala | 206 +++++++++--------- 1 file changed, 106 insertions(+), 100 deletions(-) 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 cd1aac071e..6cb6b294e8 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 @@ -7923,108 +7923,110 @@ object Http4s400 { http4sPartialFunction = Some(deleteUserCustomerLink) ) - // Cascade deletes are documented, and routed, only when allow_cascade_delete=true. - if (APIUtil.cascadeDeleteEnabled) { - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteTransactionCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID", - "Delete Transaction Cascade", - s"""Delete a Transaction Cascade specified by TRANSACTION_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), - List(apiTagTransaction), - Some(List(canDeleteTransactionCascade)), - http4sPartialFunction = Some(deleteTransactionCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteTransactionCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID/transactions/TRANSACTION_ID", + "Delete Transaction Cascade", + s"""Delete a Transaction Cascade specified by TRANSACTION_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagTransaction), + Some(List(canDeleteTransactionCascade)), + http4sPartialFunction = Some(deleteTransactionCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteAccountCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID", - "Delete Account Cascade", - s"""Delete an Account Cascade specified by ACCOUNT_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), - List(apiTagAccount), - Some(List(canDeleteAccountCascade)), - http4sPartialFunction = Some(deleteAccountCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteAccountCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/accounts/ACCOUNT_ID", + "Delete Account Cascade", + s"""Delete an Account Cascade specified by ACCOUNT_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagAccount), + Some(List(canDeleteAccountCascade)), + http4sPartialFunction = Some(deleteAccountCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteBankCascade), - "DELETE", - "/management/cascading/banks/BANK_ID", - "Delete Bank Cascade", - s"""Delete a Bank Cascade specified by BANK_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), - List(apiTagBank), - Some(List(canDeleteBankCascade)), - http4sPartialFunction = Some(deleteBankCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteBankCascade), + "DELETE", + "/management/cascading/banks/BANK_ID", + "Delete Bank Cascade", + s"""Delete a Bank Cascade specified by BANK_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, UserHasMissingRoles, UnknownError), + List(apiTagBank), + Some(List(canDeleteBankCascade)), + http4sPartialFunction = Some(deleteBankCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteProductCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/products/PRODUCT_CODE", - "Delete Product Cascade", - s"""Delete a Product Cascade specified by PRODUCT_CODE. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), - List(apiTagProduct), - Some(List(canDeleteProductCascade)), - http4sPartialFunction = Some(deleteProductCascade) - ) + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteProductCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/products/PRODUCT_CODE", + "Delete Product Cascade", + s"""Delete a Product Cascade specified by PRODUCT_CODE. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UserHasMissingRoles, UnknownError), + List(apiTagProduct), + Some(List(canDeleteProductCascade)), + http4sPartialFunction = Some(deleteProductCascade) + ) - staticResourceDocs += ResourceDoc( - implementedInApiVersion, - nameOf(deleteCustomerCascade), - "DELETE", - "/management/cascading/banks/BANK_ID/customers/CUSTOMER_ID", - "Delete Customer Cascade", - s"""Delete a Customer Cascade specified by CUSTOMER_ID. - | - | - |${userAuthenticationMessage(true)} - | - |""", - EmptyBody, - EmptyBody, - List($AuthenticatedUserIsRequired, $BankNotFound, CustomerNotFoundByCustomerId, UserHasMissingRoles, UnknownError), - List(apiTagCustomer), - Some(List(canDeleteCustomerCascade)), - http4sPartialFunction = Some(deleteCustomerCascade) - ) - } + staticResourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(deleteCustomerCascade), + "DELETE", + "/management/cascading/banks/BANK_ID/customers/CUSTOMER_ID", + "Delete Customer Cascade", + s"""Delete a Customer Cascade specified by CUSTOMER_ID. + | + | + |${userAuthenticationMessage(true)} + | + |""", + EmptyBody, + EmptyBody, + List($AuthenticatedUserIsRequired, $BankNotFound, CustomerNotFoundByCustomerId, UserHasMissingRoles, UnknownError), + List(apiTagCustomer), + Some(List(canDeleteCustomerCascade)), + 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", @@ -11252,7 +11254,11 @@ object Http4s400 { // ─── allRoutes ──────────────────────────────────────────────────────────── - // Left out of routesInOrder unless allow_cascade_delete=true, like their ResourceDocs. + // 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, @@ -11537,7 +11543,7 @@ object Http4s400 { createUserWithRoles, createUserWithAccountAccess, createUserInvitation - ).filter(route => APIUtil.cascadeDeleteEnabled || !cascadeDeleteRoutes.contains(route)) + ).filter(route => cascadeDeleteEnabled || !cascadeDeleteRoutes.contains(route)) lazy val orderedResourceDocs: ArrayBuffer[ResourceDoc] = ResourceDocMatcher.orderByRoutes(resourceDocs, routesInOrder) From 3035bae6904b06d30c14a1bd21d0ea0a8329bc35 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 16:55:13 +0100 Subject: [PATCH 17/78] docs(props): note the Resource Docs cache next to allow_cascade_delete The Resource Docs responses are cached in Redis, so after the prop changes the catalog can show the old list until the cache TTL passes or the rd_static and rd_all namespaces are invalidated. --- obp-api/src/main/resources/props/sample.props.template | 3 +++ 1 file changed, 3 insertions(+) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index b8701a5b5b..ba34b3ee0a 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -640,6 +640,9 @@ allow_account_deletion=true ## 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 From 0e1ea923fbe44f01587fbda4e043dcf2cbe3a8ee Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 17:08:08 +0100 Subject: [PATCH 18/78] test: check whether the cascade-delete roles are offered With allow_cascade_delete on, the five CanDelete*Cascade roles are in ApiRole.availableRoles; with it off they are not. --- .../api/v4_0_0/CascadeDeleteDisabledTest.scala | 4 ++++ .../v4_0_0/CascadeDeleteEndpointsTest.scala | 18 +++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) 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 index 8c98763c97..2ae38adf83 100644 --- 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 @@ -56,6 +56,10 @@ class CascadeDeleteDisabledTest extends V400ServerSetup { } } + 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) } 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 index 15a7a40b87..f2c6656369 100644 --- 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 @@ -27,7 +27,7 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 -import code.api.util.APIUtil +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 @@ -61,6 +61,10 @@ class CascadeDeleteEndpointsTest extends V400ServerSetup { } } + 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) } @@ -96,5 +100,17 @@ object CascadeDeleteEndpoints { 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 } From 884a0b61f57f722b477881c89a53a5c6e2081dca Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 17:08:08 +0100 Subject: [PATCH 19/78] feat: stop offering the cascade-delete roles while allow_cascade_delete is false The five CanDelete*Cascade roles are checked only by the /management/cascading endpoints, so while those are left out ApiRole.availableRoles leaves the roles out too: GET /roles, the glossary, the role check of v3.0.0 entitlement creation and of consents. ApiRole.valueOf still resolves them, so entitlements granted earlier keep working. --- obp-api/src/main/scala/code/api/util/ApiRole.scala | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) 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 e45bd657bc..a97ecea878 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -1643,10 +1643,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 } } From 1fb881f4f06261ae477994c2e709ece0cd487ad5 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 3 Nov 2026 23:55:47 +0100 Subject: [PATCH 20/78] fix(deps): update http4s to 0.23.35 and BouncyCastle to 1.85 Trivy reports a CRITICAL vulnerability with a fix available in each: CVE-2026-69204 in http4s-ember-core 0.23.30 (fixed in 0.23.35) and CVE-2026-8763 in bcprov-jdk18on 1.84, which bcpg and bcpkix pull in (fixed in 1.85). --- obp-api/pom.xml | 4 ++-- pom.xml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/obp-api/pom.xml b/obp-api/pom.xml index 3e0d68218f..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 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} From 32f6c6a90ad3601bbdf01ad385e7f175ac9c0683 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:20:35 +0100 Subject: [PATCH 21/78] test: cover the Berlin Group payment initiation view and scaRedirect link The new scenarios fail on the current code: the holder gets no InitiatePaymentsBerlinGroup access on refresh, and scaRedirect repeats the payment id and is built from the missing-prop error text. --- .../JSONFactory_BERLIN_GROUP_1_3Test.scala | 26 +++++++++++ .../PaymentInitiationServicePISApiTest.scala | 43 ++++++++++++++++++- 2 files changed, 68 insertions(+), 1 deletion(-) 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..1a025cbcab 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,30 @@ 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("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..b5430edb07 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 @@ -36,7 +36,7 @@ import code.api.berlin.group.v1_3.Http4sBGv13PIS import code.api.util.APIUtil.OAuth._ import code.api.util.APIUtil.extractErrorMessageCode 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,6 +44,7 @@ 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} @@ -813,6 +814,46 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with By(MappedBankAccount.theAccountId, routing.accountId.value)) .map(_.balance).openOrThrowException("Can not be empty here") + private def initiatePaymentsAccessOf(account: BankAccountRouting) = + 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_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 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 From 4b75a472dceabc20289436953559026a100bbd30 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:22:27 +0100 Subject: [PATCH 22/78] fix: grant InitiatePaymentsBerlinGroup to account holders and build scaRedirect once The mapped connector now generates the payment initiation view with the other Berlin Group views, so a holder can initiate a payment after a refresh. The scaRedirect link carries the payment id once and is left out when psu_make_payment_sca_redirect_url is not set. Closes #110 --- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 16 ++++++++-------- .../bankconnectors/LocalMappedConnector.scala | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) 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..c8639f3d27 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.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/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 29e1ad1436..54e25bc2c4 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -672,7 +672,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)")} From ae9d210212c330e68ee03fe83054a6b9ea6a1879 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:25:26 +0100 Subject: [PATCH 23/78] fix: treat a blank scaRedirect URL as unset and document the view and link behaviour A blank psu_make_payment_sca_redirect_url no longer yields a relative link. The Boot comment and sample props now say that the mapped connector creates the payment initiation view on refresh and that the link is optional. Tests cover a second refresh and a user who holds no account. --- .../resources/props/sample.props.template | 1 + .../main/scala/bootstrap/liftweb/Boot.scala | 4 +++- .../v1_3/JSONFactory_BERLIN_GROUP_1_3.scala | 2 +- .../JSONFactory_BERLIN_GROUP_1_3Test.scala | 5 +++++ .../PaymentInitiationServicePISApiTest.scala | 20 ++++++++++++++++--- 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index ba34b3ee0a..bb746d37c0 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -1241,6 +1241,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/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 13f4aafc88..a546d14d20 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -384,7 +384,9 @@ 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 + // instance has them is still the operator's call. (The mapped connector is the exception for + // InitiatePaymentsBerlinGroup: it lists the view in viewsToGenerate, so a refresh creates it and + // grants it to the holders of their accounts whether or not it is listed here.) 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 { 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 c8639f3d27..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 @@ -802,7 +802,7 @@ object JSONFactory_BERLIN_GROUP_1_3 extends CustomJsonFormats with MdcLoggable{ //map OBP transactionRequestId to BerlinGroup PaymentId val paymentId = transactionRequest.id.value // 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.map { pattern => + val scaRedirectUrl = getPropsValue("psu_make_payment_sca_redirect_url").toOption.filter(_.trim.nonEmpty).map { pattern => if (pattern.contains("PLACEHOLDER")) pattern.replace("PLACEHOLDER", paymentId) else 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 1a025cbcab..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 @@ -136,6 +136,11 @@ class JSONFactory_BERLIN_GROUP_1_3Test extends FeatureSpec with Matchers with Gi 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( 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 b5430edb07..3b19741295 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 @@ -47,7 +47,7 @@ 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 com.openbankproject.commons.model.{SepaCreditTransfers, SepaCreditTransfersBerlinGroupV13, User, ViewId} import org.json4s.native.Serialization.write import net.liftweb.mapper.By import org.scalatest.Tag @@ -814,9 +814,9 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with By(MappedBankAccount.theAccountId, routing.accountId.value)) .map(_.balance).openOrThrowException("Can not be empty here") - private def initiatePaymentsAccessOf(account: BankAccountRouting) = + private def initiatePaymentsAccessOf(account: BankAccountRouting, user: User = resourceUser1) = AccountAccess.findAll( - By(AccountAccess.user_fk, resourceUser1.userPrimaryKey.value), + 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)) @@ -834,6 +834,20 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with 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 payment can be initiated after the refresh without granting the view by hand", BerlinGroupV1_3, PIS, initiatePayment) { val ibanFrom = ibanAccounts.head From 9227aa7aa4d51c6919cb30cef3a47a97f6fd215e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:28:35 +0100 Subject: [PATCH 24/78] test: cover deleting card attributes with the card, the account cascade and a new endpoint The scenarios fail on the current code: a deleted card and a cascaded account leave their MappedCardAttribute rows, and there is no endpoint that deletes one attribute. --- .../code/api/v3_1_0/CardAttributeTest.scala | 41 +++++- .../api/v4_0_0/DeleteAccountCascadeTest.scala | 52 ++++++++ .../api/v7_0_0/CardAttributeDeleteTest.scala | 122 ++++++++++++++++++ 3 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 obp-api/src/test/scala/code/api/v7_0_0/CardAttributeDeleteTest.scala 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/v4_0_0/DeleteAccountCascadeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteAccountCascadeTest.scala index bd470951e0..5cc5ccfde0 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 @@ -38,6 +38,7 @@ 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 @@ -214,6 +215,57 @@ 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) + + 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) shouldBe empty + 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") 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..3d33a26474 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/CardAttributeDeleteTest.scala @@ -0,0 +1,122 @@ +/** +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, UserHasMissingRoles} +import code.cardattribute.MappedCardAttribute +import code.cards.MappedPhysicalCard +import code.entitlement.Entitlement +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): String = + MappedCardAttribute.create + .mBankId(testBankId1.value) + .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 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) + } + } +} From 62655bcb99042ad14e2f665d5977f39f9add4fde Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:32:21 +0100 Subject: [PATCH 25/78] fix: delete a card's attributes with the card and add Delete Card Attribute Deleting a card or cascading its account now deletes the card's attributes, and DELETE /obp/v7.0.0/management/banks/BANK_ID/cards/CARD_ID/attributes/CARD_ATTRIBUTE_ID deletes one attribute of that card with CanDeleteCardsForBank. Closes #112 --- .../main/scala/code/api/util/NewStyle.scala | 7 ++++ .../scala/code/api/v7_0_0/Http4s700.scala | 33 +++++++++++++++++++ .../cardattribute/MappedCardAttribute.scala | 4 +++ .../scala/code/cards/MappedPhisicalCard.scala | 5 ++- .../scala/deletion/DeleteAccountCascade.scala | 12 ++++--- 5 files changed, 56 insertions(+), 5 deletions(-) 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 663edf15e6..10cdc8a1cc 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -28,6 +28,7 @@ TESOBE (http://www.tesobe.com/) package code.api.util +import code.cardattribute.CardAttributeX import org.apache.pekko.http.scaladsl.model.HttpMethod import code.DynamicEndpoint.{DynamicEndpointProvider, DynamicEndpointT} import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID} @@ -3229,6 +3230,12 @@ object NewStyle extends MdcLoggable{ i => (connectorEmptyResponse(i._1, callContext), i._2) } } + def deleteCardAttribute(cardAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { + CardAttributeX.cardAttributeProvider.vend.deleteCardAttribute(cardAttributeId) map { + i => (unboxFullOrFail(i, callContext, s"$UnknownError Could not delete CardAttribute($cardAttributeId)"), callContext) + } + } + def getCardAttributesFromProvider( cardId: String, callContext: Option[CallContext] 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 fed02802d0..8edb5a31e6 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 @@ -6931,6 +6931,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)) + _ <- 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/cardattribute/MappedCardAttribute.scala b/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala index 5c0e2d7254..f1d125f309 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 of the given cards, for when the cards themselves are deleted. */ + def deleteForCards(cardIds: List[String]): Boolean = + cardIds.isEmpty || bulkDelete_!!(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..5bc08fb625 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(List(cardId)) && card.delete_! + } } } diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index efb9cb9264..f0cce709a6 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -34,6 +34,7 @@ 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 @@ -116,10 +117,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(cardIds) && + MappedPhysicalCard.bulkDelete_!!( + By(MappedPhysicalCard.mAccount, account.id.get) + ) + } ) }.forall(_ == true) From 91acf884223374e0558c7f025070ab8eeee39a80 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:35:37 +0100 Subject: [PATCH 26/78] fix: delete card attributes by bank and answer 404 for an unknown attribute Only the attributes a bank holds for a deleted card go with it, so an attribute another bank posted for the same card id is kept. Delete Card Attribute answers 404 for an unknown CARD_ATTRIBUTE_ID, as for one that belongs to another card. --- obp-api/src/main/scala/code/api/util/NewStyle.scala | 4 ++-- obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala | 2 +- .../scala/code/cardattribute/MappedCardAttribute.scala | 6 +++--- .../src/main/scala/code/cards/MappedPhisicalCard.scala | 2 +- .../src/main/scala/deletion/DeleteAccountCascade.scala | 2 +- .../code/api/v4_0_0/DeleteAccountCascadeTest.scala | 10 +++++++++- .../code/api/v7_0_0/CardAttributeDeleteTest.scala | 10 ++++++++++ 7 files changed, 27 insertions(+), 9 deletions(-) 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 10cdc8a1cc..de769e1de0 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3203,9 +3203,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) } 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 8edb5a31e6..0d6efddf5f 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 @@ -6936,7 +6936,7 @@ object Http4s700 { case req @ DELETE -> `prefixPath` / "management" / "banks" / _ / "cards" / cardId / "attributes" / attributeId => EndpointHelpers.withUserAndBankDelete(req) { (_, bank, cc) => for { - (attribute, _) <- NewStyle.function.getCardAttributeById(attributeId, Some(cc)) + (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) } diff --git a/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala b/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala index f1d125f309..c46588b826 100644 --- a/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala +++ b/obp-api/src/main/scala/code/cardattribute/MappedCardAttribute.scala @@ -67,8 +67,8 @@ 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 of the given cards, for when the cards themselves are deleted. */ - def deleteForCards(cardIds: List[String]): Boolean = - cardIds.isEmpty || bulkDelete_!!(ByList(mCardId, cardIds)) + /** 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 5bc08fb625..05050a6d57 100644 --- a/obp-api/src/main/scala/code/cards/MappedPhisicalCard.scala +++ b/obp-api/src/main/scala/code/cards/MappedPhisicalCard.scala @@ -317,7 +317,7 @@ object MappedPhysicalCardProvider extends PhysicalCardProvider { By(MappedPhysicalCard.mBankId, bankId.value), By(MappedPhysicalCard.mCardId, cardId), ).map { card => - MappedCardAttribute.deleteForCards(List(cardId)) && card.delete_! + MappedCardAttribute.deleteForCards(bankId.value, List(cardId)) && card.delete_! } } diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index f0cce709a6..16fcf57e84 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -119,7 +119,7 @@ object DeleteAccountCascade { ) map ( account => { val cardIds = MappedPhysicalCard.findAll(By(MappedPhysicalCard.mAccount, account.id.get)).map(_.mCardId.get) - MappedCardAttribute.deleteForCards(cardIds) && + MappedCardAttribute.deleteForCards(bankId.value, cardIds) && MappedPhysicalCard.bulkDelete_!!( By(MappedPhysicalCard.mAccount, account.id.get) ) 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 5cc5ccfde0..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 @@ -250,6 +250,14 @@ class DeleteAccountCascadeTest extends V400ServerSetup { 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) @@ -261,7 +269,7 @@ class DeleteAccountCascadeTest extends V400ServerSetup { 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) shouldBe empty + attributesOf(card).map(_.mBankId.get) should equal(List(otherBankId.value)) attributesOf(otherBankCard) should have size 1 } } 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 index 3d33a26474..7e9bc41a34 100644 --- 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 @@ -106,6 +106,16 @@ class CardAttributeDeleteTest extends ServerSetupWithTestData { 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() From 699e90c0dfd5676534c73809f853059314b647c3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:36:04 +0100 Subject: [PATCH 27/78] test: cover where the passcode of an EMAIL or SMS challenge goes The scenarios without a linked customer fail on the current code: nothing is sent and the challenge is still created. --- .../connector/ChallengeRecipientTest.scala | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala 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..4313bb15ca --- /dev/null +++ b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala @@ -0,0 +1,125 @@ +/** +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.bankconnectors.LocalMappedConnector +import code.customer.CustomerX +import code.setup.{PropsReset, ServerSetupWithTestData} +import code.usercustomerlinks.UserCustomerLink +import code.model.UserX +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") + + private def linkCustomer(user: User, customerEmail: String): Unit = { + val customer = CustomerX.customerProvider.vend.addCustomer( + BankId(testBankId1.value), randomString(10), "Test Customer", "+49 123 456", 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) = + Await.result( + LocalMappedConnector.createChallengesC3( + List(user.userId), ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE, Some(randomString(10)), Some(method), + None, None, None, None, None + ), + 10.seconds + )._1 + + 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 = createUser("user-address@example.com") + + createChallenge(user, StrongCustomerAuthentication.EMAIL).map(_.size) should equal(Full(1)) + + challengeMails.map(_.to) should equal(List(List("user-address@example.com"))) + } + + scenario("is not created when there is no address to send it to") { + startMailTestMode() + val user = createUser("") + + createChallenge(user, StrongCustomerAuthentication.EMAIL) shouldBe a[Failure] + + challengeMails shouldBe empty + } + } + + feature("The passcode of an SMS challenge") { + scenario("is not created when no customer with a phone number is linked") { + startMailTestMode() + val user = createUser("user-address@example.com") + + createChallenge(user, StrongCustomerAuthentication.SMS) shouldBe a[Failure] + } + } +} From 3724208b70d7d3f5752dd9444f33a47c53aaeba3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:37:56 +0100 Subject: [PATCH 28/78] test: cover the bank cascade deleting the bank's dynamic entities and records The scenario fails on the current code: the definitions, their records and the ZZ_BAK_ copy stay when the bank is deleted. --- .../api/v4_0_0/DeleteBankCascadeTest.scala | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 d876cdd017..e50735bb14 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,7 +28,10 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 import org.json4s._ +import code.DynamicData.{DynamicData, 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 @@ -179,6 +182,38 @@ 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)) + definitionsAt(bankId) should have size 2 + recordsAt(bankId) should have size 2 + + 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 + definitionsAt(otherBankId) should have size 1 + recordsAt(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 From 41d80b0dfe945aa4ae161cc02747a21f15db8a6f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:43:19 +0100 Subject: [PATCH 29/78] fix: send challenge passcodes to the user when no customer is linked, and fail when no one can get one An EMAIL challenge goes to the addresses of the customers linked to the user, else to the user's own address; an EMAIL or SMS challenge with no one to send it to is not created, and a failed send is reported instead of dropped. Signing basket tests that relied on the silent SMS no-op now use the DUMMY method. Closes #114 --- .../scala/code/api/util/ErrorMessages.scala | 1 + .../bankconnectors/LocalMappedConnector.scala | 49 +++++++++++++------ .../v1_3/SigningBasketServiceSBSApiTest.scala | 3 ++ 3 files changed, 38 insertions(+), 15 deletions(-) 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 e2bb032945..2b17f44ee0 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -914,6 +914,7 @@ 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 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 give the user an 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/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 29e1ad1436..3436d4b420 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,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { challengeType = challengeType.toString, callContext ) - challengeId.toList + challengeId } - (Full(challenges.flatten), callContext) + challengesOrFailure(challenges, callContext) } + /** The challenges of all the users, or the first failure: a user who could not be sent a challenge is not skipped. */ + private def challengesOrFailure[T](results: List[Box[T]], callContext: Option[CallContext]): (Box[List[T]], Option[CallContext]) = + results.collectFirst { case failure: Failure => failure } match { + case Some(failure) => (failure, callContext) + case None => (Full(results.flatMap(_.toList)), callContext) + } + private def createChallengeInternal( userId: String, transactionRequestId: String, @@ -432,19 +439,31 @@ 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; the user's own address when there is none. + val linkedCustomerEmails = APIUtil.getEmailsByUserId(userId).map(_._2).filter(_.trim.nonEmpty) + val recipients = + if (linkedCustomerEmails.nonEmpty) linkedCustomerEmails + else Users.users.vend.getUserByUserId(userId).toList.map(_.emailAddress).filter(_.trim.nonEmpty) + if (recipients.isEmpty) { + (Failure(ChallengeRecipientNotFound), callContext) + } else { + val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() + val hashedPassword = createHashedPassword(challengeAnswer) + val sendingResult = recipients map { 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) + )) + } + val errorMessages = sendingResult.collect { case failure: Failure => failure.msg } + if (errorMessages.isEmpty) hashedPassword else (Failure(errorMessages.toSet.mkString(" <- ")), callContext) } - hashedPassword + // No linked customer with a phone number: there is no one to send the passcode to. + case Some(StrongCustomerAuthentication.SMS) | Some(StrongCustomerAuthentication.SMS_OTP) + if APIUtil.getPhoneNumbersByUserId(userId).forall(_._2.trim.isEmpty) => + (Failure(ChallengeRecipientNotFound), callContext) case Some(StrongCustomerAuthentication.SMS) | Some(StrongCustomerAuthentication.SMS_OTP) => val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() logger.debug(s"${scaMethod.toString} challengeAnswer is $challengeAnswer") 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) From bfe7bb65ec52a1930677c4c1fc0a6209f5fb37ca Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:44:25 +0100 Subject: [PATCH 30/78] fix: delete a bank's dynamic entities, records and access rows with the bank The bank cascade deletes the bank's dynamic entity definitions and their records, whoever owns them, through the same delete as the entity endpoint, so entitlements, roles, access rows, webhooks and the definition caches follow. The ZZ_BAK_ copies go with them. Other banks and the system space are untouched. Closes #115 --- .../main/scala/code/api/util/NewStyle.scala | 58 ++++++++++--------- .../scala/deletion/DeleteBankCascade.scala | 20 ++++++- 2 files changed, 51 insertions(+), 27 deletions(-) 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 663edf15e6..a53bf18915 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3652,34 +3652,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 } - // 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 + } 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 } + // 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/deletion/DeleteBankCascade.scala b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala index 75655c365b..70c5b1e3d1 100644 --- a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala @@ -31,7 +31,10 @@ import code.accountattribute.MappedAccountAttribute import code.api.APIFailureNewStyle import code.api.util.APIUtil.fullBoxOrException import code.api.util.ErrorMessages.CouldNotDeleteCascade +import code.DynamicData.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} @@ -60,7 +63,7 @@ object DeleteBankCascade { ) // Delete account DeleteAccountCascade.delete(i.bankId, i.accountId) - } && deleteBank(bankId) + } && deleteDynamicEntities(bankId) && deleteBank(bankId) } def atomicDelete(bankId: BankId): Box[Boolean] = databaseAtomicTask { @@ -73,6 +76,21 @@ object DeleteBankCascade { } } + /** + * 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.forall(id => DynamicDataProvider.connectorMethodProvider.vend.deleteCommunity(space, entity.entityName, id).openOr(false)) + } + recordsDeleted && entity.dynamicEntityId.forall(id => NewStyle.function.deleteDynamicEntityNow(space, id).openOr(false)) + } + } + private def deleteBank(bankId: BankId): Boolean = { MappedBank.bulkDelete_!!( By(MappedBank.permalink, bankId.value) From c46a12b182bdd9a8952f2655eeeeadb093b6ad8e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:46:28 +0100 Subject: [PATCH 31/78] fix: send an EMAIL passcode before saving its challenge and cover the payment authorisation path A failed send no longer leaves a saved challenge no one can answer. The payment authorisation tests check that the passcode goes to the user's own address when no customer is linked and that the authorisation is refused when there is none. --- .../bankconnectors/LocalMappedConnector.scala | 4 +-- .../PaymentInitiationServicePISApiTest.scala | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 3436d4b420..48b2471690 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -448,7 +448,6 @@ object LocalMappedConnector extends Connector with MdcLoggable { (Failure(ChallengeRecipientNotFound), callContext) } else { val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() - val hashedPassword = createHashedPassword(challengeAnswer) val sendingResult = recipients map { to => CommonsEmailWrapper.sendTextEmail(CommonsEmailWrapper.EmailContent( from = mailUsersUserinfoSenderAddress, @@ -458,7 +457,8 @@ object LocalMappedConnector extends Connector with MdcLoggable { )) } val errorMessages = sendingResult.collect { case failure: Failure => failure.msg } - if (errorMessages.isEmpty) hashedPassword else (Failure(errorMessages.toSet.mkString(" <- ")), callContext) + // The challenge is saved once its passcode has been sent, so a failed send leaves no challenge no one can answer. + if (errorMessages.isEmpty) createHashedPassword(challengeAnswer) else (Failure(errorMessages.toSet.mkString(" <- ")), callContext) } // No linked customer with a phone number: there is no one to send the passcode to. case Some(StrongCustomerAuthentication.SMS) | Some(StrongCustomerAuthentication.SMS_OTP) 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..aebc97bd03 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 @@ -35,6 +35,7 @@ import code.api.berlin.group.v1_3.model.{ScaStatusResponse, TransactionStatus, U 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.TokenType @@ -813,6 +814,36 @@ 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() + + startAuthorisationAsUser1().code should equal(201) + + CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge").map(_.to) should equal(List(List(resourceUser1.emailAddress))) + } + + scenario("is refused when the user has no address either", BerlinGroupV1_3, PIS, initiatePayment) { + setPropsValues("SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT" -> "EMAIL", "mail.test.mode" -> "true") + CommonsEmailWrapper.clearTestModeSentMail() + val address = resourceUser1.emailAddress + resourceUser1.email("").save + try { + val response = startAuthorisationAsUser1() + response.code should equal(400) + CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge") shouldBe empty + } finally resourceUser1.email(address).save + } + } + private def paymentUrl(paymentId: String) = V1_3_BG / PaymentServiceTypes.payments.toString / TransactionRequestTypes.SEPA_CREDIT_TRANSFERS.toString / paymentId From a59da908127635a9c865e9c7c0f168d5e2677051 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:47:02 +0100 Subject: [PATCH 32/78] test: cover a rate limit's to_date and the scope fields that are not enforced The scenarios fail on the current code: a limit stays active until the end of the hour of its to_date, and one that names an api_name, api_version or bank_id is stored although every call of the consumer is counted. --- .../code/api/v6_0_0/RateLimitsTest.scala | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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..aafc309707 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 @@ -488,6 +488,41 @@ 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 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 include(field) + } + } + 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) + } + } + 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) { From e2d9fc35b736150f0dd579637f11daf4730c511e Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:47:21 +0100 Subject: [PATCH 33/78] fix: require an id when the bank cascade deletes dynamic entities and check their entitlements A definition or record without an id now fails the cascade instead of being skipped, and the cascade test asserts that the entity's read entitlement is removed with it. --- obp-api/src/main/scala/deletion/DeleteBankCascade.scala | 4 ++-- .../test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala index 70c5b1e3d1..640bb45b61 100644 --- a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala @@ -85,9 +85,9 @@ object DeleteBankCascade { 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.forall(id => DynamicDataProvider.connectorMethodProvider.vend.deleteCommunity(space, entity.entityName, id).openOr(false)) + record.dynamicDataId.exists(id => DynamicDataProvider.connectorMethodProvider.vend.deleteCommunity(space, entity.entityName, id).openOr(false)) } - recordsDeleted && entity.dynamicEntityId.forall(id => NewStyle.function.deleteDynamicEntityNow(space, id).openOr(false)) + recordsDeleted && entity.dynamicEntityId.exists(id => NewStyle.function.deleteDynamicEntityNow(space, id).openOr(false)) } } 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 e50735bb14..6b6588a4f3 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 @@ -43,7 +43,7 @@ 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 @@ -200,8 +200,12 @@ class DeleteBankCascadeTest extends V400ServerSetup { 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) @@ -210,6 +214,7 @@ class DeleteBankCascadeTest extends V400ServerSetup { 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 } From b4d3dc4fb4baaa0a4620a79bcf94a7ceb9165cd8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:53:19 +0100 Subject: [PATCH 34/78] fix: hold a rate limit to its exact dates and refuse the scope fields nothing enforces A rate limit applies from its from_date to its to_date, not for every hour they touch; the report for an hour keeps the hour as its unit. Creating or updating a limit through v6.0.0 with an api_name, api_version or bank_id answers 400, as every call of the consumer is counted whatever they name. Closes #118 --- .../scala/code/api/util/ErrorMessages.scala | 1 + .../code/api/util/RateLimitingUtil.scala | 7 ++- .../scala/code/api/v6_0_0/Http4s600.scala | 20 ++++++- .../code/api/v6_0_0/RateLimitsTest.scala | 53 +++++++++++++++---- 4 files changed, 69 insertions(+), 12 deletions(-) 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 e2bb032945..d237bf7b9c 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -590,6 +590,7 @@ object ErrorMessages { val InvalidGlossarySource = "OBP-30578: Invalid source. Please specify all, static or dynamic." // Asset registry (OBP-30579 .. OBP-30581) + val RateLimitScopeNotSupported = "OBP-30582: 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 AssetNotFound = "OBP-30579: Asset not found. Please specify a valid value for ASSET_CODE." val AssetNotFoundByChainIdentity = "OBP-30580: No asset has this chain identity. Please specify a registered CHAIN_SCHEME and CHAIN_ASSET_ID." val InvalidAssetQueryParameter = "OBP-30581: Invalid query parameter for assets." 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..0e7a5f228e 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -125,10 +125,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/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 59f809a825..c42c44d6a9 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 @@ -6199,6 +6199,10 @@ object Http4s600 { } + // The fields of a rate limit that name something narrower than the consumer, which nothing enforces yet. + private 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(_)) => name } + // Route: POST /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits (201) lazy val createCallLimits: Http4sRoute = Http4sRoute { case req @ POST -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "rate-limits" => @@ -6210,6 +6214,10 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CallLimitPostJsonV600] } _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + scopeFields = 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, @@ -6232,6 +6240,10 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CallLimitPostJsonV400] } _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) + scopeFields = rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) + _ <- Helper.booleanToFuture(s"$RateLimitScopeNotSupported Remove: ${scopeFields.mkString(", ")}", 400, Some(cc)) { + scopeFields.isEmpty + } _ <- RateLimitingDI.rateLimiting.vend.updateConsumerCallLimits( rateLimitingId, postJson.from_date, postJson.to_date, postJson.api_version, postJson.api_name, postJson.bank_id, @@ -6283,7 +6295,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) } } @@ -14786,6 +14798,8 @@ 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. + | |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. | @@ -14801,6 +14815,7 @@ object Http4s600 { InvalidJsonFormat, InvalidConsumerId, ConsumerNotFoundByConsumerId, + RateLimitScopeNotSupported, UserHasMissingRoles, UnknownError ), @@ -14834,6 +14849,8 @@ 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. + | |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. | @@ -14849,6 +14866,7 @@ object Http4s600 { InvalidJsonFormat, InvalidConsumerId, ConsumerNotFoundByConsumerId, + RateLimitScopeNotSupported, UserHasMissingRoles, UpdateConsumerError, UnknownError 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 aafc309707..8074b11714 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 @@ -30,8 +30,8 @@ import code.api.util.APIUtil.OAuth._ 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.entitlement.Entitlement @@ -57,8 +57,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 +217,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 +235,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", @@ -503,6 +503,30 @@ class RateLimitsTest extends V600ServerSetup { } finally deleteLimit(consumerId3, id) } + scenario("A rate limit whose from_date has not come yet is not active", ApiEndpoint4, VersionOfApi) { + val now = System.currentTimeMillis() + assume(now % 3600000L < 3600000L - 10000L, "the hour is about to end") + val id = createLimit(consumerId3, callLimitJson("0", "0", "0").copy(from_date = new Date(now + 5000L), 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) + val hour = ZonedDateTime.now(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( @@ -514,9 +538,20 @@ class RateLimitsTest extends V600ServerSetup { 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 include(field) + 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) From d73d01ba61b47d057a43566755f0ec669c3ae4ad Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 00:56:40 +0100 Subject: [PATCH 35/78] chore: review the rate limit ResourceDoc differences in the parity allowlist The descriptions of Create and Set Rate Limits and their error lists now say that api_name, api_version and bank_id are refused. --- .../parity_allowlist.json | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index b697816d8c..2bed032d15 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -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": "373574cc7bc9c4b16c6d3250eecc3b65db651c16cd1457b5e4ddfa546e220aa3" + }, + { + "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": "41c77b86feb302cbc5130214b14e71a7dc5c09710eaad3819b4b126d6ae41412" + }, + { + "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", From 28b46f956c4803cc17ae571e4f09a771ec494904 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 01:02:15 +0100 Subject: [PATCH 36/78] test: give the tests that start an SCA a method the user can be reached by The consent authorisation and delete cascade tests started a challenge on the default SMS method with no linked customer, which only passed because nothing was sent. They now use the DUMMY method. --- .../group/v1_3/AccountInformationServiceAISApiTest.scala | 4 ++++ obp-api/src/test/scala/code/api/v4_0_0/V400ServerSetup.scala | 3 +++ 2 files changed, 7 insertions(+) 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 fd24972860..cdadb10483 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/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)))) From 54d062e775026c513e71910d7a9b64c7882c38ce Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:38:44 +0100 Subject: [PATCH 37/78] test: cover a failed passcode send, an unconfirmed own address and the transaction request left behind An EMAIL challenge whose mail cannot be handed to the SMTP server and an SMS challenge whose message cannot be sent must not be saved, the user's own address is used only when the user signed up here and confirmed it, and a transaction request whose challenge cannot be sent must not stay INITIATED. --- .../api/v4_0_0/TransactionRequestsTest.scala | 30 +++++++ .../connector/ChallengeRecipientTest.scala | 81 +++++++++++++++++-- 2 files changed, 105 insertions(+), 6 deletions(-) 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..f4905bb360 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 @@ -699,6 +699,36 @@ 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) + } + } + } + feature("we can create transaction requests -- FREE_FORM") { if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) { diff --git a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala index 4313bb15ca..00d3bc8378 100644 --- a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala +++ b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala @@ -29,9 +29,12 @@ package code.connector import code.api.util.CommonsEmailWrapper 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} @@ -58,9 +61,15 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { company = None ).openOrThrowException("expected the user to be created") - private def linkCustomer(user: User, customerEmail: String): Unit = { + /** 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", "+49 123 456", customerEmail, + 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, "", "", "" @@ -68,15 +77,18 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { UserCustomerLink.userCustomerLink.vend.getOCreateUserCustomerLink(user.userId, customer.customerId, new Date(), true) } - private def createChallenge(user: User, method: StrongCustomerAuthentication.SCA) = + 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(randomString(10)), Some(method), + 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 challengeMails = CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge") private def startMailTestMode(): Unit = { @@ -97,13 +109,58 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { scenario("goes to the user's own address when no customer is linked") { startMailTestMode() - val user = createUser("user-address@example.com") + 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) + + createChallenge(user, StrongCustomerAuthentication.EMAIL) shouldBe a[Failure] + + 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") + + createChallenge(user, StrongCustomerAuthentication.EMAIL) shouldBe a[Failure] + + 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) + + createChallenge(user, StrongCustomerAuthentication.EMAIL, transactionRequestId) shouldBe a[Failure] + + savedChallenges(transactionRequestId) shouldBe empty + } + scenario("is not created when there is no address to send it to") { startMailTestMode() val user = createUser("") @@ -116,10 +173,22 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { feature("The passcode of an SMS challenge") { scenario("is not created when no customer with a phone number is linked") { - startMailTestMode() val user = createUser("user-address@example.com") createChallenge(user, StrongCustomerAuthentication.SMS) shouldBe a[Failure] } + + 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) + + val result = createChallenge(user, StrongCustomerAuthentication.SMS, transactionRequestId) + + result shouldBe a[Failure] + result.asInstanceOf[Failure].msg should not include "+49 170 0000001" + savedChallenges(transactionRequestId) shouldBe empty + } } } From b99da0077919a4e946a9ef8feda93bd14aebb1b0 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:40:39 +0100 Subject: [PATCH 38/78] fix: save a passcode challenge only once it has been sent, and fail the transaction request it was for An EMAIL send that sendTextEmail answers with Empty now fails the challenge. An SMS challenge is saved after its messages are sent, the error no longer names the phone number, and blank or NULL numbers and addresses are skipped. The user's own address is used only for a user who signed up on this instance and confirmed it. A transaction request whose challenges cannot be created is marked FAILED instead of staying INITIATED. --- .../scala/code/api/util/ErrorMessages.scala | 2 +- .../bankconnectors/LocalMappedConnector.scala | 107 +++++++++++------- .../PaymentInitiationServicePISApiTest.scala | 12 +- 3 files changed, 72 insertions(+), 49 deletions(-) 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 2b17f44ee0..17d14e403a 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -914,7 +914,7 @@ 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 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 give the user an e-mail address." + 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/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index 48b2471690..de484499df 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -404,6 +404,18 @@ object LocalMappedConnector extends Connector with MdcLoggable { case None => (Full(results.flatMap(_.toList)), 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, @@ -439,64 +451,66 @@ object LocalMappedConnector extends Connector with MdcLoggable { case Some(StrongCustomerAuthentication.DUMMY) => createHashedPassword("123") case Some(StrongCustomerAuthentication.EMAIL) => - // The customers linked to the user; the user's own address when there is none. - val linkedCustomerEmails = APIUtil.getEmailsByUserId(userId).map(_._2).filter(_.trim.nonEmpty) + // 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)) val recipients = if (linkedCustomerEmails.nonEmpty) linkedCustomerEmails - else Users.users.vend.getUserByUserId(userId).toList.map(_.emailAddress).filter(_.trim.nonEmpty) + else nonBlank(validatedOwnEmail(userId).toList) if (recipients.isEmpty) { (Failure(ChallengeRecipientNotFound), callContext) } else { val challengeAnswer = SecureRandomUtil.csprng.nextInt(99999999).toString() - val sendingResult = recipients map { to => + // 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(to), subject = "Challenge", textContent = Some(s"Your OTP challenge : ${challengeAnswer}") - )) + )).isDefined } - val errorMessages = sendingResult.collect { case failure: Failure => failure.msg } // The challenge is saved once its passcode has been sent, so a failed send leaves no challenge no one can answer. - if (errorMessages.isEmpty) createHashedPassword(challengeAnswer) else (Failure(errorMessages.toSet.mkString(" <- ")), callContext) + if (sent) createHashedPassword(challengeAnswer) else (Failure(EmailSendingFailed), callContext) } - // No linked customer with a phone number: there is no one to send the passcode to. - case Some(StrongCustomerAuthentication.SMS) | Some(StrongCustomerAuthentication.SMS_OTP) - if APIUtil.getPhoneNumbersByUserId(userId).forall(_._2.trim.isEmpty) => - (Failure(ChallengeRecipientNotFound), callContext) 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)) + 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") } @@ -5253,8 +5267,13 @@ 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) => Future.successful((challenges, callContext)) + case (failure, callContext) => + // Without its challenges the request can never be answered. + 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/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 aebc97bd03..c6ff40c220 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 @@ -37,7 +37,7 @@ 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 @@ -825,10 +825,14 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with 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) - startAuthorisationAsUser1().code should equal(201) - - CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge").map(_.to) should equal(List(List(resourceUser1.emailAddress))) + 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 address either", BerlinGroupV1_3, PIS, initiatePayment) { From d58c8aacf01a6415e9853debfb8afc911f114f05 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:43:37 +0100 Subject: [PATCH 39/78] test: accept the documented rate limit example and empty scope fields, with room for a slow run The example request body of Create Rate Limits must be accepted as it is, an empty api_name, api_version or bank_id names nothing, and the from_date of the not-yet-active limit is a minute ahead so a slow run does not reach it. --- .../code/api/v6_0_0/RateLimitsTest.scala | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) 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 8074b11714..34baa11669 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,6 +27,7 @@ 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 @@ -505,8 +506,9 @@ class RateLimitsTest extends V600ServerSetup { scenario("A rate limit whose from_date has not come yet is not active", ApiEndpoint4, VersionOfApi) { val now = System.currentTimeMillis() - assume(now % 3600000L < 3600000L - 10000L, "the hour is about to end") - val id = createLimit(consumerId3, callLimitJson("0", "0", "0").copy(from_date = new Date(now + 5000L), to_date = new Date(now + 86400000L))) + // 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) @@ -556,6 +558,26 @@ class RateLimitsTest extends V600ServerSetup { 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("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") { From 3e39c0372846781efc06faf93653be4453a96b50 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:45:07 +0100 Subject: [PATCH 40/78] fix: accept the documented rate limit example and treat an empty scope field as not set The Create Rate Limits example body no longer names api_version and api_name, a blank api_name, api_version or bank_id is accepted and stored as not set, the doc says that a subscription's record shows its bank_id for reference only, and OBP-30582 sits with its own heading after the asset registry codes. --- .../SwaggerDefinitionsJSON.scala | 4 ++-- .../scala/code/api/util/ErrorMessages.scala | 4 +++- .../main/scala/code/api/v6_0_0/Http4s600.scala | 18 +++++++++++------- .../parity_allowlist.json | 4 ++-- 4 files changed, 18 insertions(+), 12 deletions(-) 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 d30800a8ab..6d0a508d85 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/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index d237bf7b9c..279ff9e497 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -590,11 +590,13 @@ object ErrorMessages { val InvalidGlossarySource = "OBP-30578: Invalid source. Please specify all, static or dynamic." // Asset registry (OBP-30579 .. OBP-30581) - val RateLimitScopeNotSupported = "OBP-30582: 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 AssetNotFound = "OBP-30579: Asset not found. Please specify a valid value for ASSET_CODE." val AssetNotFoundByChainIdentity = "OBP-30580: No asset has this chain identity. Please specify a registered CHAIN_SCHEME and CHAIN_ASSET_ID." val InvalidAssetQueryParameter = "OBP-30581: Invalid query parameter for assets." + // Rate limits (OBP-30582) + val RateLimitScopeNotSupported = "OBP-30582: 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 OrganisationNotFound = "OBP-30506: Organisation not found. Please specify a valid value for ORGANISATION_ID." val OrganisationAlreadyExists = "OBP-30507: Organisation already exists. Please specify a different value for ORGANISATION_ID." val InvalidOrganisationIdFormat = "OBP-30508: Invalid Organisation Id. The ORGANISATION_ID should only contain 0-9/a-z/A-Z/'-'/'.'/'_', and be between 2 and 64 characters in length." 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 c42c44d6a9..9e84c1357b 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 @@ -6200,8 +6200,10 @@ object Http4s600 { // The fields of a rate limit that name something narrower than the consumer, which nothing enforces yet. + // An empty value names nothing. private 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(_)) => name } + List("api_name" -> apiName, "api_version" -> apiVersion, "bank_id" -> bankId) + .collect { case (name, Some(value)) if value.trim.nonEmpty => name } // Route: POST /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits (201) lazy val createCallLimits: Http4sRoute = Http4sRoute { @@ -6220,7 +6222,7 @@ object Http4s600 { } 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)) @@ -6246,7 +6248,7 @@ object Http4s600 { } _ <- RateLimitingDI.rateLimiting.vend.updateConsumerCallLimits( rateLimitingId, 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)) @@ -14798,10 +14800,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. + |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. | @@ -14849,10 +14852,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. + |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. | diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index 2bed032d15..a5d87ce71a 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -1433,7 +1433,7 @@ "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": "373574cc7bc9c4b16c6d3250eecc3b65db651c16cd1457b5e4ddfa546e220aa3" + "http4s_digest": "20ef72c36c810c8f2e95d6d4fdae9c05438fb1a11c09b3c0d70e9d245883197f" }, { "version": "v6_0_0", @@ -1481,7 +1481,7 @@ "field": "description", "reason": "Same rate-limiting doc expansion as the other call-limit endpoints.", "lift_digest": "b4a18cf966d2dbdd3760c86771869b584cd10286dac49620d075a7cbfdca4996", - "http4s_digest": "41c77b86feb302cbc5130214b14e71a7dc5c09710eaad3819b4b126d6ae41412" + "http4s_digest": "e200763f8a95f7259cae9d370259d22140003a5ee3cf6eee4aba4d99429f3951" }, { "version": "v6_0_0", From d34630956937712844245a23956072cca2268e08 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:47:54 +0100 Subject: [PATCH 41/78] test: cover the bank cascade deleting records whose definition is already gone A definition can be deleted while personal records of it remain, which leaves records and their access rows with no definition; the bank cascade must delete those of its bank and keep those of other banks. --- .../api/v4_0_0/DeleteBankCascadeTest.scala | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) 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 6b6588a4f3..1464f18a23 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,7 +28,7 @@ TESOBE (http://www.tesobe.com/) package code.api.v4_0_0 import org.json4s._ -import code.DynamicData.{DynamicData, DynamicDataProvider} +import code.DynamicData.{DynamicData, DynamicDataAccess, DynamicDataProvider} import code.accountholders.MapperAccountHolders import code.dynamicEntity.{DynamicEntity, DynamicEntityCommons, DynamicEntityProvider} import net.liftweb.util.StringHelpers @@ -219,6 +219,34 @@ class DeleteBankCascadeTest extends V400ServerSetup { recordsAt(otherBankId) 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 From d22c383df97f5e5e47163783b2a9fa6691c57963 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:49:10 +0100 Subject: [PATCH 42/78] fix: delete the bank's records that have no definition left, and say what the bank cascade removes After the bank's dynamic entities are deleted, the cascade also removes the remaining DynamicData and DynamicDataAccess rows of that bank, which a definition deleted while personal records of it remained leaves behind. The Delete Bank Cascade ResourceDoc now says the bank's dynamic entities and all their records go with it, with no ZZ_BAK_ copy. --- .../src/main/scala/code/api/v4_0_0/Http4s400.scala | 2 ++ .../main/scala/deletion/DeleteBankCascade.scala | 14 ++++++++++++-- .../resource_doc_baseline/parity_allowlist.json | 8 ++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) 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 8e467fe1b5..c7f55adb39 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 @@ -7986,6 +7986,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)} | diff --git a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala index 640bb45b61..2117de5a74 100644 --- a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala @@ -31,7 +31,7 @@ import code.accountattribute.MappedAccountAttribute import code.api.APIFailureNewStyle import code.api.util.APIUtil.fullBoxOrException import code.api.util.ErrorMessages.CouldNotDeleteCascade -import code.DynamicData.DynamicDataProvider +import code.DynamicData.{DynamicData, DynamicDataAccess, DynamicDataProvider} import code.api.util.NewStyle import code.customer.CustomerX import code.dynamicEntity.DynamicEntityProvider @@ -88,7 +88,17 @@ object DeleteBankCascade { 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 = { diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index b697816d8c..ed5de6703d 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -2666,6 +2666,14 @@ "reason": "Added ApiCollectionNotFound: 404 when the collection does not exist or belongs to another user, so only its owner can update it", "lift_digest": "ea8061d2189d61d4f84552f70fa4482c88aa1fb6d5f7692b7c3c6eae0b24f4e6", "http4s_digest": "181a990959c91843d2617852d9247a24ae916364b5b3afa58fab87ddd6692130" + }, + { + "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" } ] } From c055ab29b9baad3bd8738060820eb00a59fd6832 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:51:30 +0100 Subject: [PATCH 43/78] test: cover several signers of whom one cannot be reached, a shared address and the exact refusal With two answers required and three signers, the two who can be reached get their challenges and the request waits for them, and with only one reachable the request is FAILED. Two customers sharing an address get one mail, and the failure scenarios check the error they answer. --- .../PaymentInitiationServicePISApiTest.scala | 17 ++++--- .../api/v4_0_0/TransactionRequestsTest.scala | 51 +++++++++++++++++++ .../connector/ChallengeRecipientTest.scala | 33 ++++++++---- 3 files changed, 84 insertions(+), 17 deletions(-) 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 c6ff40c220..f04c07fc82 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 @@ -835,16 +835,17 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with } finally AuthUser.bulkDelete_!!(By(AuthUser.id, authUser.id.get)) } - scenario("is refused when the user has no address either", BerlinGroupV1_3, PIS, initiatePayment) { + 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() - val address = resourceUser1.emailAddress - resourceUser1.email("").save - try { - val response = startAuthorisationAsUser1() - response.code should equal(400) - CommonsEmailWrapper.testModeSentMail.filter(_.subject == "Challenge") shouldBe empty - } finally resourceUser1.email(address).save + 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 } } 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 f4905bb360..d8194ebe74 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 @@ -726,7 +728,56 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers { 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 = setUpThreeSignersByEmail(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 = setUpThreeSignersByEmail(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)) + } + } + } + + /** A COUNTERPARTY payment above the challenge threshold on an account with three signers, 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 setUpThreeSignersByEmail(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)) + List(resourceUser2, resourceUser3).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") { diff --git a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala index 00d3bc8378..c3aacc0be6 100644 --- a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala +++ b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala @@ -27,6 +27,7 @@ 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 @@ -89,6 +90,11 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { 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 = { @@ -130,7 +136,7 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { startMailTestMode() val user = createLocalUser("unvalidated-address@example.com", validated = false) - createChallenge(user, StrongCustomerAuthentication.EMAIL) shouldBe a[Failure] + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL)) should startWith(ChallengeRecipientNotFound) challengeMails shouldBe empty } @@ -139,7 +145,7 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { startMailTestMode() val user = createUser("external-address@example.com") - createChallenge(user, StrongCustomerAuthentication.EMAIL) shouldBe a[Failure] + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL)) should startWith(ChallengeRecipientNotFound) challengeMails shouldBe empty } @@ -156,16 +162,27 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { linkCustomer(user, "customer-address@example.com") val transactionRequestId = randomString(10) - createChallenge(user, StrongCustomerAuthentication.EMAIL, transactionRequestId) shouldBe a[Failure] + 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 = createUser("") + val user = createLocalUser("", validated = true) - createChallenge(user, StrongCustomerAuthentication.EMAIL) shouldBe a[Failure] + failureMessage(createChallenge(user, StrongCustomerAuthentication.EMAIL)) should startWith(ChallengeRecipientNotFound) challengeMails shouldBe empty } @@ -175,7 +192,7 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { scenario("is not created when no customer with a phone number is linked") { val user = createUser("user-address@example.com") - createChallenge(user, StrongCustomerAuthentication.SMS) shouldBe a[Failure] + failureMessage(createChallenge(user, StrongCustomerAuthentication.SMS)) should startWith(ChallengeRecipientNotFound) } scenario("is not saved when the message cannot be sent") { @@ -184,10 +201,8 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { linkCustomer(user, "customer-address@example.com", mobileNumber = "+49 170 0000001") val transactionRequestId = randomString(10) - val result = createChallenge(user, StrongCustomerAuthentication.SMS, transactionRequestId) + createChallenge(user, StrongCustomerAuthentication.SMS, transactionRequestId) shouldBe a[Failure] - result shouldBe a[Failure] - result.asInstanceOf[Failure].msg should not include "+49 170 0000001" savedChallenges(transactionRequestId) shouldBe empty } } From 0b111e2f2a618d4d27183a5d6485fdd9c0798a2f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:51:30 +0100 Subject: [PATCH 44/78] fix: keep the challenges of the signers who can be reached and require as many as the answers needed createChallenges* return the challenges created and fail only when none could be, a transaction request is marked FAILED when it got fewer challenges than REQUIRED_CHALLENGE_ANSWERS, and an address or phone number shared by linked customers is sent the passcode once. --- .../bankconnectors/LocalMappedConnector.scala | 38 ++++++++++++++----- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index de484499df..a019ad4ce0 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -397,12 +397,20 @@ object LocalMappedConnector extends Connector with MdcLoggable { challengesOrFailure(challenges, callContext) } - /** The challenges of all the users, or the first failure: a user who could not be sent a challenge is not skipped. */ - private def challengesOrFailure[T](results: List[Box[T]], callContext: Option[CallContext]): (Box[List[T]], Option[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) => (failure, callContext) - case None => (Full(results.flatMap(_.toList)), callContext) + 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] = @@ -452,7 +460,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { createHashedPassword("123") case Some(StrongCustomerAuthentication.EMAIL) => // 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)) + val linkedCustomerEmails = nonBlank(APIUtil.getEmailsByUserId(userId).map(_._2)).distinct val recipients = if (linkedCustomerEmails.nonEmpty) linkedCustomerEmails else nonBlank(validatedOwnEmail(userId).toList) @@ -473,7 +481,7 @@ object LocalMappedConnector extends Connector with MdcLoggable { if (sent) createHashedPassword(challengeAnswer) else (Failure(EmailSendingFailed), callContext) } case Some(StrongCustomerAuthentication.SMS) | Some(StrongCustomerAuthentication.SMS_OTP) => - val phoneNumbers = nonBlank(APIUtil.getPhoneNumbersByUserId(userId).map(_._2)) + 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) @@ -5248,6 +5256,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. @@ -5256,6 +5270,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( @@ -5268,9 +5283,14 @@ object LocalMappedConnector extends Connector with MdcLoggable { authenticationMethodId = None, callContext = callContext ) flatMap { - case (Full(challenges), callContext) => Future.successful((challenges, callContext)) - case (failure, callContext) => - // Without its challenges the request can never be answered. + 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 failure: Box[List[ChallengeTrait]] = result match { + 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) } From a3b4acb65618c393e7b4fa4677803debb84b9f5f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:54:42 +0100 Subject: [PATCH 45/78] test: keep a subscription's bank_id across an update, and read the reported hour from the test's own time Updating through v6.0.0 a rate limit that an API Product Subscription wrote must keep its bank_id, and the hourly report scenario formats the hour from the same instant its assume checked. --- .../code/api/v6_0_0/RateLimitsTest.scala | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) 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 34baa11669..1c7ca0a538 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 @@ -35,6 +35,7 @@ import code.api.util.ApiRole.{CanCreateRateLimits, CanDeleteRateLimits, CanGetRa 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 @@ -522,7 +523,8 @@ class RateLimitsTest extends V600ServerSetup { 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) - val hour = ZonedDateTime.now(ZoneOffset.UTC).format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH")) + // The hour of `now`, which the assume above keeps clear of the next hour. + 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) @@ -568,6 +570,24 @@ class RateLimitsTest extends V600ServerSetup { 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( From f0a0257c0269a03015b00313adb965b0dab6b8e4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:55:40 +0100 Subject: [PATCH 46/78] test: keep the system space when the bank cascade deletes a bank whose id is its marker System level dynamic entities and records are stored under the bank id SYS; deleting a bank with that id must not delete them. --- .../api/v4_0_0/DeleteBankCascadeTest.scala | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 1464f18a23..e5aa82477b 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 @@ -219,6 +219,30 @@ class DeleteBankCascadeTest extends V400ServerSetup { 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 system level definition and its record are kept") + 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 From a45c318bf767bdc5deadc23b7b30828c5c01309c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:57:16 +0100 Subject: [PATCH 47/78] fix: keep a subscription's bank_id when its rate limit is updated, and describe the exact date rule Update Rate Limits (v6.0.0) keeps the bank_id an API Product Subscription wrote on the record, and the Rate Limiting glossary says a request is held to the records whose dates enclose it, while the hour stays the unit of caching. --- obp-api/src/main/scala/code/api/util/Glossary.scala | 2 +- obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) 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 8c59b4f8a7..1becea78ca 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/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 9e84c1357b..159ce0ea2a 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 @@ -6246,9 +6246,12 @@ object Http4s600 { _ <- 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, - None, None, None, // rateLimitScopeFields has refused any value that is not blank + // 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)) From 49ca7bb7f2c6b566773418afdd8dbc5049bf8057 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:57:17 +0100 Subject: [PATCH 48/78] refactor: move the boot reconciliation of the opt-in system views into Views Boot now calls ensureOptInSystemViewsUpToDate with the additional_system_views prop; behaviour is unchanged. --- .../src/main/scala/bootstrap/liftweb/Boot.scala | 16 +--------------- .../src/main/scala/code/views/MapperViews.scala | 7 +++++++ obp-api/src/main/scala/code/views/Views.scala | 6 ++++++ 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index a546d14d20..41d8afca26 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -389,21 +389,7 @@ class Boot extends MdcLoggable { // grants it to the holders of their accounts whether or not it is listed here.) 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 - } + Views.views.vend.ensureOptInSystemViewsUpToDate(APIUtil.getPropsValue("additional_system_views")) } diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 6e3ecdc5cf..6a453b76db 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -727,6 +727,13 @@ object MapperViews extends Views with MdcLoggable { } } + def ensureOptInSystemViewsUpToDate(additionalSystemViews: Box[String]) : List[Box[View]] = { + val listed = additionalSystemViews.toList.flatMap(_.split(",").map(_.trim)) + List(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) + .filter(listed.contains) + .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 From 07417b903d742126694e64b6885132cffd33e885 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:57:17 +0100 Subject: [PATCH 49/78] test: cover the payment initiation view on held accounts and the opt-in views at boot A holder who opens another account must also get InitiatePaymentsBerlinGroup on the accounts already held, and an opt-in view that already exists must be kept current at boot even when additional_system_views does not list it. --- .../PaymentInitiationServicePISApiTest.scala | 23 +++++++++++++- .../scala/code/views/MappedViewsTest.scala | 30 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) 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 3b19741295..f37c95f554 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 @@ -47,7 +47,9 @@ 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, User, 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 @@ -848,6 +850,25 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with 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 payment can be initiated after the refresh without granting the view by hand", BerlinGroupV1_3, PIS, initiatePayment) { val ibanFrom = ibanAccounts.head diff --git a/obp-api/src/test/scala/code/views/MappedViewsTest.scala b/obp-api/src/test/scala/code/views/MappedViewsTest.scala index 17b196756c..c5f8f686ae 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,32 @@ 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 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)) + } + } } From 32764d75e99f2b76eea32a0ce89ee10c7dc007c8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:57:17 +0100 Subject: [PATCH 50/78] test: cover Delete Card Attribute across banks and through the routed connector An attribute held for another bank is not deleted, the role at another bank does not allow it, and the delete goes to the connector the method is routed to. --- .../api/v7_0_0/CardAttributeDeleteTest.scala | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) 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 index 7e9bc41a34..109911114a 100644 --- 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 @@ -32,6 +32,7 @@ import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, CardAttributeNo 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 @@ -61,9 +62,9 @@ class CardAttributeDeleteTest extends ServerSetupWithTestData { .mCardId.get } - private def addAttribute(cardId: String, name: String): String = + private def addAttribute(cardId: String, name: String, bankId: String = testBankId1.value): String = MappedCardAttribute.create - .mBankId(testBankId1.value) + .mBankId(bankId) .mCardId(cardId) .mName(name) .mType("STRING") @@ -128,5 +129,45 @@ class CardAttributeDeleteTest extends ServerSetupWithTestData { 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 not equal 204 + attributeExists(attributeId) should equal(true) + } finally MethodRoutingProvider.connectorMethodProvider.vend.delete(routing.methodRoutingId.get) + } } } From b800faa8cf0cfad8d0b49ef379828142bb33b1bf Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 10:57:17 +0100 Subject: [PATCH 51/78] fix: leave the system space alone when the cascaded bank's id is its marker A bank whose id is SYS, under which system level dynamic entities are stored, has no dynamic entities of its own, so the cascade skips that step for it. --- obp-api/src/main/scala/deletion/DeleteBankCascade.scala | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala index 2117de5a74..420df8f030 100644 --- a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala @@ -29,6 +29,7 @@ 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} @@ -63,8 +64,14 @@ object DeleteBankCascade { ) // Delete account DeleteAccountCascade.delete(i.bankId, i.accountId) - } && deleteDynamicEntities(bankId) && 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 { From 07764da76b3d544ad6bce4bd0753d220caaeb8f8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:04:36 +0100 Subject: [PATCH 52/78] test: tell too few users who may answer apart from a missing address An account that needs more answers than it has users who may answer gets OBP-35060, and the SMS failure must not show the phone number. --- .../api/v4_0_0/TransactionRequestsTest.scala | 31 +++++++++++++++---- .../connector/ChallengeRecipientTest.scala | 3 +- 2 files changed, 27 insertions(+), 7 deletions(-) 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 d8194ebe74..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 @@ -731,7 +731,7 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers { 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 = setUpThreeSignersByEmail(reachable = List(resourceUser1, resourceUser2)) + 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 @@ -743,7 +743,7 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers { 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 = setUpThreeSignersByEmail(reachable = List(resourceUser1)) + 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 @@ -755,9 +755,28 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers { } } - /** A COUNTERPARTY payment above the challenge threshold on an account with three signers, 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 setUpThreeSignersByEmail(reachable: List[User]) = { + 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" @@ -765,7 +784,7 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers { 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)) - List(resourceUser2, resourceUser3).foreach { signer => + signers.foreach { signer => grantUserAccessToViewViaEndpoint(helper.bankId.value, helper.accountId1.value, signer.userId, user1, PostViewJsonV400(view_id = Constant.SYSTEM_OWNER_VIEW_ID, is_system = true)) } diff --git a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala index c3aacc0be6..cf665afe07 100644 --- a/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala +++ b/obp-api/src/test/scala/code/connector/ChallengeRecipientTest.scala @@ -201,7 +201,8 @@ class ChallengeRecipientTest extends ServerSetupWithTestData with PropsReset { linkCustomer(user, "customer-address@example.com", mobileNumber = "+49 170 0000001") val transactionRequestId = randomString(10) - createChallenge(user, StrongCustomerAuthentication.SMS, transactionRequestId) shouldBe a[Failure] + // 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 } From 8525c5315d5bd0ea599b7547a0d370874a9b8adb Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:04:36 +0100 Subject: [PATCH 53/78] fix: grant the payment initiation view on held accounts also when accounts change, and keep existing opt-in views current A refresh now compares the views of every account the user still holds, also when other accounts were added or removed in the same refresh, so a holder who opens an account also gets InitiatePaymentsBerlinGroup on the accounts held before. Boot keeps an opt-in system view current wherever it exists, listed in additional_system_views or not. --- .../main/scala/bootstrap/liftweb/Boot.scala | 6 +- .../code/model/dataAccess/AuthUser.scala | 77 +++++++++---------- .../main/scala/code/views/MapperViews.scala | 4 +- 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 41d8afca26..996221db7c 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -386,9 +386,9 @@ 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. (The mapped connector is the exception for // InitiatePaymentsBerlinGroup: it lists the view in viewsToGenerate, so a refresh creates it and - // grants it to the holders of their accounts whether or not it is listed here.) 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. + // grants it to the holders of their accounts whether or not it is listed here.) Their permission + // sets ARE code-defined though, so wherever they exist, listed or not, they are kept current the + // same way -- 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/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index 503bbf6b79..8044dd1f2d 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,46 @@ 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. + 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){ + 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/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 6a453b76db..9f9fbe21d7 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -729,8 +729,10 @@ 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: a refresh of the mapped + // connector creates InitiatePaymentsBerlinGroup for the holders of its accounts. List(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID, SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_VIEW_ID) - .filter(listed.contains) + .filter(viewId => listed.contains(viewId) || ViewDefinition.findSystemView(viewId).isDefined) .map(ensureSystemViewUpToDate) } From 11c32ccee3954cb932de026cad81913ae567652f Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:04:36 +0100 Subject: [PATCH 54/78] fix: delete a card attribute through the connector Delete Card Attribute (v7.0.0) now goes through a new Connector.deleteCardAttribute, which the mapped connector answers from its card attribute table, so the attribute it reads and the one it deletes come from the same connector and a connector that cannot delete card attributes answers with an error. --- obp-api/src/main/scala/code/api/util/NewStyle.scala | 5 ++--- obp-api/src/main/scala/code/bankconnectors/Connector.scala | 2 ++ .../scala/code/bankconnectors/LocalMappedConnector.scala | 5 +++++ 3 files changed, 9 insertions(+), 3 deletions(-) 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 de769e1de0..4eb0c9bf91 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -28,7 +28,6 @@ TESOBE (http://www.tesobe.com/) package code.api.util -import code.cardattribute.CardAttributeX import org.apache.pekko.http.scaladsl.model.HttpMethod import code.DynamicEndpoint.{DynamicEndpointProvider, DynamicEndpointT} import code.api.Constant.{SYSTEM_READ_ACCOUNTS_BERLIN_GROUP_VIEW_ID, SYSTEM_READ_BALANCES_BERLIN_GROUP_VIEW_ID} @@ -3231,8 +3230,8 @@ object NewStyle extends MdcLoggable{ } } def deleteCardAttribute(cardAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { - CardAttributeX.cardAttributeProvider.vend.deleteCardAttribute(cardAttributeId) map { - i => (unboxFullOrFail(i, callContext, s"$UnknownError Could not delete CardAttribute($cardAttributeId)"), callContext) + Connector.connector.vend.deleteCardAttribute(cardAttributeId, callContext) map { + i => (unboxFullOrFail(i._1, callContext, s"$UnknownError Could not delete CardAttribute($cardAttributeId)"), i._2) } } 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 54e25bc2c4..da90116454 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -2020,6 +2020,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], From c55e161c5e695ed8fd73c9e831c6c7df5f0b40c7 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:05:52 +0100 Subject: [PATCH 55/78] fix: answer OBP-35060 when fewer users may answer than the account requires A transaction request whose account asks for more answers than it has users who may answer is still marked FAILED, now with an error that names that reason instead of a missing address. --- obp-api/src/main/scala/code/api/util/ErrorMessages.scala | 1 + .../main/scala/code/bankconnectors/LocalMappedConnector.scala | 3 +++ 2 files changed, 4 insertions(+) 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 17d14e403a..25a8d978b1 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -914,6 +914,7 @@ 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. " diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala index a019ad4ce0..74b384395b 100644 --- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala +++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala @@ -5286,7 +5286,10 @@ object LocalMappedConnector extends Connector with MdcLoggable { 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 From 0b679978540a5ec12821ccf9910446dd752190aa Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:05:52 +0100 Subject: [PATCH 56/78] test: say which hour the assume keeps to_date in --- obp-api/src/test/scala/code/api/v6_0_0/RateLimitsTest.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 1c7ca0a538..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 @@ -523,7 +523,7 @@ class RateLimitsTest extends V600ServerSetup { 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`, which the assume above keeps clear of the next hour. + // 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) From a80442a39886d5118c44839e15093505c23990a8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:05:52 +0100 Subject: [PATCH 57/78] test: check the SYS bank itself is deleted --- .../src/test/scala/code/api/v4_0_0/DeleteBankCascadeTest.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e5aa82477b..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 @@ -238,7 +238,8 @@ class DeleteBankCascadeTest extends V400ServerSetup { 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 system level definition and its record are kept") + 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 } From 6d2d84bee6956d09153db1cae26a1ee9b1fdd923 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:10:51 +0100 Subject: [PATCH 58/78] test: keep a holder's other views when an account is opened, and honour the boot reconcile switch A view the connector does not list, given to the holder on a held account, must survive the refresh that follows opening another account, and an existing opt-in view keeps its permissions when system_views.reconcile_permissions_at_boot is false. --- .../PaymentInitiationServicePISApiTest.scala | 25 ++++++++++++++++++- .../scala/code/views/MappedViewsTest.scala | 12 +++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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 f37c95f554..1e644e38d3 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,7 +29,7 @@ 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 @@ -869,6 +869,29 @@ class PaymentInitiationServicePISApiTest extends BerlinGroupServerSetupV1_3 with 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 diff --git a/obp-api/src/test/scala/code/views/MappedViewsTest.scala b/obp-api/src/test/scala/code/views/MappedViewsTest.scala index c5f8f686ae..6c9c34734f 100644 --- a/obp-api/src/test/scala/code/views/MappedViewsTest.scala +++ b/obp-api/src/test/scala/code/views/MappedViewsTest.scala @@ -332,6 +332,18 @@ class MappedViewsTest extends ServerSetup with DefaultUsers{ 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) From a0d901f4a3bdb5bd2209cba08d1f99087b44cb91 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:12:23 +0100 Subject: [PATCH 59/78] fix: only add views on held accounts when accounts change, and respect the boot reconcile switch When a refresh also adds or removes accounts, the per-account comparison grants the missing listed views but no longer revokes the views the connector does not list, so a view given to the holder some other way survives opening an account. An existing opt-in system view that additional_system_views does not list is reconciled at boot only while system_views.reconcile_permissions_at_boot is true. --- obp-api/src/main/scala/bootstrap/liftweb/Boot.scala | 10 +++++----- .../main/scala/code/model/dataAccess/AuthUser.scala | 7 +++++-- obp-api/src/main/scala/code/views/MapperViews.scala | 8 +++++--- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 996221db7c..f726898980 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -384,11 +384,11 @@ 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. (The mapped connector is the exception for - // InitiatePaymentsBerlinGroup: it lists the view in viewsToGenerate, so a refresh creates it and - // grants it to the holders of their accounts whether or not it is listed here.) Their permission - // sets ARE code-defined though, so wherever they exist, listed or not, they are kept current the - // same way -- the prop decides existence, not whether the code is authoritative about what a view grants. + // 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/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index 8044dd1f2d..75c4394eeb 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -1184,7 +1184,10 @@ def restoreSomeSessions(): Unit = { } //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. + //when other accounts were added or removed above. Views are taken away only on a refresh that leaves the accounts + //as they were: opening an account must not cost the holder the views it was given on its other accounts some + //other way (a VRP mandate's view, an auditor view), which the connector does not list. + 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. @@ -1197,7 +1200,7 @@ def restoreSomeSessions(): Unit = { //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){ + _ = 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)) diff --git a/obp-api/src/main/scala/code/views/MapperViews.scala b/obp-api/src/main/scala/code/views/MapperViews.scala index 9f9fbe21d7..204f86023f 100644 --- a/obp-api/src/main/scala/code/views/MapperViews.scala +++ b/obp-api/src/main/scala/code/views/MapperViews.scala @@ -729,10 +729,12 @@ 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: a refresh of the mapped - // connector creates InitiatePaymentsBerlinGroup for the holders of its accounts. + // 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) || ViewDefinition.findSystemView(viewId).isDefined) + .filter(viewId => listed.contains(viewId) || (reconcileExisting && ViewDefinition.findSystemView(viewId).isDefined)) .map(ensureSystemViewUpToDate) } From fd99d40c61ad98cdb120ee659255ace41d7b9e89 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:12:23 +0100 Subject: [PATCH 60/78] fix: report a connector that cannot delete a card attribute as the other attribute deletes do Delete Card Attribute answers a connector failure through connectorEmptyResponse, like the other attribute deletes, and its test checks for the Not Implemented error of the routed connector. --- obp-api/src/main/scala/code/api/util/NewStyle.scala | 2 +- .../test/scala/code/api/v7_0_0/CardAttributeDeleteTest.scala | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) 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 4eb0c9bf91..8863a1ad5e 100644 --- a/obp-api/src/main/scala/code/api/util/NewStyle.scala +++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala @@ -3231,7 +3231,7 @@ object NewStyle extends MdcLoggable{ } def deleteCardAttribute(cardAttributeId: String, callContext: Option[CallContext]): OBPReturnType[Boolean] = { Connector.connector.vend.deleteCardAttribute(cardAttributeId, callContext) map { - i => (unboxFullOrFail(i._1, callContext, s"$UnknownError Could not delete CardAttribute($cardAttributeId)"), i._2) + i => (connectorEmptyResponse(i._1, callContext), i._2) } } 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 index 109911114a..f08e6ffff1 100644 --- 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 @@ -28,7 +28,7 @@ package code.api.v7_0_0 import code.api.util.APIUtil.OAuth._ import code.api.util.ApiRole -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, CardAttributeNotFound, UserHasMissingRoles} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, CardAttributeNotFound, NotImplemented, UserHasMissingRoles} import code.cardattribute.MappedCardAttribute import code.cards.MappedPhysicalCard import code.entitlement.Entitlement @@ -165,7 +165,8 @@ class CardAttributeDeleteTest extends ServerSetupWithTestData { try { val response = makeDeleteRequest(attributeUrl(cardId, attributeId).DELETE <@ (user1)) - response.code should not equal 204 + 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) } From e384f6ed3060ba46b93c049fe25ade8dc2aed7a4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:15:24 +0100 Subject: [PATCH 61/78] docs: say which refresh still takes away the views the connector does not list --- obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) 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 75c4394eeb..dc37353f2e 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -1184,9 +1184,8 @@ def restoreSomeSessions(): Unit = { } //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 are taken away only on a refresh that leaves the accounts - //as they were: opening an account must not cost the holder the views it was given on its other accounts some - //other way (a VRP mandate's view, an auditor view), which the connector does not list. + //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 From 5eedcd60a00989160f6ee01f3e784262e4878cfe Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:36:47 +0100 Subject: [PATCH 62/78] test: cover cascading deletes that fail part way An account or bank cascade whose step fails, and an account cascade stopped by a database error, must leave the account, the bank and the access rows they had already deleted in place. --- .../v4_0_0/CascadeDeleteAtomicityTest.scala | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteAtomicityTest.scala 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..4b4904c028 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/CascadeDeleteAtomicityTest.scala @@ -0,0 +1,147 @@ +/** +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. + 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") + } + } +} From c6f60e9369e503e894b019b25e2a63526c14462c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:38:42 +0100 Subject: [PATCH 63/78] fix: roll a cascading delete back to a savepoint when it fails Within a request the connection ignores rollback and is committed when the request ends, whatever the response, so a cascade that failed part way kept what it had deleted. DeletionUtil.databaseAtomicTask now takes a savepoint and rolls back to it when the cascade throws, the request connection passes rollback(Savepoint) through to the real connection, and the DB.rollback calls the cascades made, which did nothing there, are gone. --- .../util/http4s/RequestScopeConnection.scala | 6 ++++-- .../scala/deletion/DeleteAccountCascade.scala | 4 +--- .../scala/deletion/DeleteBankCascade.scala | 4 +--- .../deletion/DeleteCustomerCascade.scala | 4 +--- .../scala/deletion/DeleteProductCascade.scala | 4 +--- .../deletion/DeleteTransactionCascade.scala | 4 +--- .../main/scala/deletion/DeletionUtil.scala | 21 +++++++++++++++++-- 7 files changed, 28 insertions(+), 19 deletions(-) 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/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 16fcf57e84..fe6c8a3750 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -50,9 +50,7 @@ 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, NotBy} -import net.liftweb.util.DefaultConnectionIdentifier import net.liftweb.util.Helpers.tryo import org.json4s.Formats @@ -90,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)) } } diff --git a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala index 75655c365b..fcfc69d590 100644 --- a/obp-api/src/main/scala/deletion/DeleteBankCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteBankCascade.scala @@ -37,9 +37,7 @@ 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 { @@ -68,7 +66,7 @@ object DeleteBankCascade { 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/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..ae816cafc3 100644 --- a/obp-api/src/main/scala/deletion/DeletionUtil.scala +++ b/obp-api/src/main/scala/deletion/DeletionUtil.scala @@ -31,9 +31,26 @@ import net.liftweb.db.DB import net.liftweb.util.DefaultConnectionIdentifier 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 => + val savepoint = conn.connection.setSavepoint() + val result = + try blockOfCode + catch { + case e: Throwable => + try conn.connection.rollback(savepoint) + catch { case rollbackFailure: Throwable => e.addSuppressed(rollbackFailure) } + throw e + } + conn.connection.releaseSavepoint(savepoint) + result } } } From 4fabc1b01fa0ecea60ef0466a3d47d894deaae68 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:43:19 +0100 Subject: [PATCH 64/78] refactor: run the cascade savepoint through DeletionUtil.withSavepoint The savepoint handling of databaseAtomicTask moves into withSavepoint, which takes the connection, so it can be tested on its own; behaviour is unchanged. --- .../main/scala/deletion/DeletionUtil.scala | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/obp-api/src/main/scala/deletion/DeletionUtil.scala b/obp-api/src/main/scala/deletion/DeletionUtil.scala index ae816cafc3..82ea9c5d38 100644 --- a/obp-api/src/main/scala/deletion/DeletionUtil.scala +++ b/obp-api/src/main/scala/deletion/DeletionUtil.scala @@ -30,6 +30,8 @@ package deletion import net.liftweb.db.DB import net.liftweb.util.DefaultConnectionIdentifier +import java.sql.Connection + object DeletionUtil { /** * Runs a cascade as one unit: when it throws, every row it wrote is restored before the exception goes on. @@ -40,17 +42,22 @@ object DeletionUtil { */ def databaseAtomicTask[R](blockOfCode: => R): R = { DB.use(DefaultConnectionIdentifier){ conn => - val savepoint = conn.connection.setSavepoint() - val result = - try blockOfCode - catch { - case e: Throwable => - try conn.connection.rollback(savepoint) - catch { case rollbackFailure: Throwable => e.addSuppressed(rollbackFailure) } - throw e - } - conn.connection.releaseSavepoint(savepoint) - result + 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 + } + connection.releaseSavepoint(savepoint) + result + } } From 30697474e7824a93f3774a1d74b610ff2568db7b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:44:11 +0100 Subject: [PATCH 65/78] test: cover the cascade savepoint on its own and through the request connection A savepoint is released on success and rolled back to on failure, a database that cannot release a savepoint does not fail a cascade that succeeded, a failed rollback keeps the cascade's exception, and rollback(Savepoint) on the request connection reaches the real one. --- .../http4s/RequestScopeConnectionTest.scala | 16 +++ .../scala/deletion/DeletionUtilTest.scala | 99 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 obp-api/src/test/scala/deletion/DeletionUtilTest.scala 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..43f319837b 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,22 @@ 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 tracked real connection wrapped in a proxy") + val t = new ConnectionTracker + val proxy = RequestScopeConnection.makeProxy(trackingConn(t)) + val savepoint = new java.sql.Savepoint { + def getSavepointId: Int = 1 + def getSavepointName: String = "cascade" + } + + When("rollback(savepoint) is called on the proxy, as a cascade that failed does") + proxy.rollback(savepoint) + + Then("The real connection rolls back to it, so the cascade can undo its own writes") + t.rollbackCount shouldBe 1 + } + 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/deletion/DeletionUtilTest.scala b/obp-api/src/test/scala/deletion/DeletionUtilTest.scala new file mode 100644 index 0000000000..b25ec4749b --- /dev/null +++ b/obp-api/src/test/scala/deletion/DeletionUtilTest.scala @@ -0,0 +1,99 @@ +/** +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") + } + + 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)) + } + } +} From 38748d454b97093f130ae4c0f761b08a41f2bf62 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:44:58 +0100 Subject: [PATCH 66/78] fix: do not fail a cascade on a database that cannot release a savepoint The Oracle and SQL Server drivers do not support releaseSavepoint; releasing it is now best effort, and the savepoint ends with the transaction. --- obp-api/src/main/scala/deletion/DeletionUtil.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/deletion/DeletionUtil.scala b/obp-api/src/main/scala/deletion/DeletionUtil.scala index 82ea9c5d38..27c63f5bc8 100644 --- a/obp-api/src/main/scala/deletion/DeletionUtil.scala +++ b/obp-api/src/main/scala/deletion/DeletionUtil.scala @@ -31,6 +31,7 @@ import net.liftweb.db.DB import net.liftweb.util.DefaultConnectionIdentifier import java.sql.Connection +import scala.util.control.NonFatal object DeletionUtil { /** @@ -57,7 +58,9 @@ object DeletionUtil { catch { case rollbackFailure: Throwable => e.addSuppressed(rollbackFailure) } throw e } - connection.releaseSavepoint(savepoint) + // 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 } } From f33dfcdb76187ae6c0381d10a5d1f88291ca440a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:44:58 +0100 Subject: [PATCH 67/78] test: say which databases the foreign key scenario tells apart --- .../scala/code/api/v4_0_0/CascadeDeleteAtomicityTest.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 4b4904c028..074c99927a 100644 --- 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 @@ -129,7 +129,8 @@ class CascadeDeleteAtomicityTest extends V400ServerSetup { 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. + // 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 { From 898a473148dafdfdd5ef0fd9bd1e8ed6529238ad Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 11:47:16 +0100 Subject: [PATCH 68/78] test: check the savepoint itself reaches the real connection, and a savepoint that cannot be set --- .../http4s/RequestScopeConnectionTest.scala | 22 ++++++++++++++----- .../scala/deletion/DeletionUtilTest.scala | 20 +++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) 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 43f319837b..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 @@ -132,19 +132,29 @@ class RequestScopeConnectionTest extends FeatureSpec with Matchers with GivenWhe } scenario("rollback to a savepoint on the proxy reaches the real connection") { - Given("A tracked real connection wrapped in a proxy") - val t = new ConnectionTracker - val proxy = RequestScopeConnection.makeProxy(trackingConn(t)) + 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") - proxy.rollback(savepoint) + RequestScopeConnection.makeProxy(real).rollback(savepoint) - Then("The real connection rolls back to it, so the cascade can undo its own writes") - t.rollbackCount shouldBe 1 + 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") { diff --git a/obp-api/src/test/scala/deletion/DeletionUtilTest.scala b/obp-api/src/test/scala/deletion/DeletionUtilTest.scala index b25ec4749b..54286e1526 100644 --- a/obp-api/src/test/scala/deletion/DeletionUtilTest.scala +++ b/obp-api/src/test/scala/deletion/DeletionUtilTest.scala @@ -83,6 +83,26 @@ class DeletionUtilTest extends FeatureSpec with Matchers { 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") { From f638fa4c396d4135ea6fe743e73df8d10138173d Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 12:17:20 +0100 Subject: [PATCH 69/78] test: cover the v3.1.0 and v4.0.0 Set Rate Limits next to v6.0.0 rate limits With several rate limits the legacy PUT must change none of them, its change must apply at once, the consumer's only rate limit is still changed or created, and v4.0.0 refuses a scope field and takes an empty one as not set. --- .../api/v6_0_0/LegacyCallLimitsPutTest.scala | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/LegacyCallLimitsPutTest.scala 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..81a78d2f87 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/LegacyCallLimitsPutTest.scala @@ -0,0 +1,138 @@ +/** +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-30583") + v400.code should equal(400) + v400.body.extract[ErrorMessage].message should startWith("OBP-30583") + 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("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)) + } + } +} From ae6d33bf6a49b330deaef3bef7b087ccbb88c885 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 12:23:04 +0100 Subject: [PATCH 70/78] fix: refuse the legacy Set Rate Limits for a consumer with several rate limits, and apply its change at once PUT /management/consumers/CONSUMER_ID/consumer/call-limits (v3.1.0 and v4.0.0) changes the consumer's rate limit or creates it; with more than one rate limit, which v6.0.0 allows and sums, it now answers 400 OBP-30583 instead of overwriting the most recent one. Its change clears the rate limit cache as the other writes do, so it applies at once, and v4.0.0 refuses api_name, api_version and bank_id like v6.0.0. The existing rate limit tests expected the old cached behaviour and an endpoint scope nothing enforced; they now set the limit from another consumer and expect 429 on the call that goes over it. --- .../scala/code/api/util/ErrorMessages.scala | 3 +- .../code/api/util/RateLimitingUtil.scala | 6 ++ .../scala/code/api/v3_1_0/Http4s310.scala | 8 ++- .../scala/code/api/v4_0_0/Http4s400.scala | 19 ++++- .../scala/code/api/v6_0_0/Http4s600.scala | 10 +-- .../ratelimiting/MappedRateLimiting.scala | 29 +++++--- .../code/ratelimiting/RateLimiting.scala | 2 + .../scala/code/api/v3_1_0/RateLimitTest.scala | 63 +++++++++++----- .../code/api/v4_0_0/RateLimitingTest.scala | 72 ++++++++++--------- .../parity_allowlist.json | 32 +++++++++ 10 files changed, 169 insertions(+), 75 deletions(-) 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 279ff9e497..321bce3571 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -594,8 +594,9 @@ object ErrorMessages { val AssetNotFoundByChainIdentity = "OBP-30580: No asset has this chain identity. Please specify a registered CHAIN_SCHEME and CHAIN_ASSET_ID." val InvalidAssetQueryParameter = "OBP-30581: Invalid query parameter for assets." - // Rate limits (OBP-30582) + // Rate limits (OBP-30582 .. OBP-30583) val RateLimitScopeNotSupported = "OBP-30582: 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-30583: 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." val OrganisationNotFound = "OBP-30506: Organisation not found. Please specify a valid value for ORGANISATION_ID." val OrganisationAlreadyExists = "OBP-30507: Organisation already exists. Please specify a different value for ORGANISATION_ID." 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 0e7a5f228e..8b18665a5b 100644 --- a/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala +++ b/obp-api/src/main/scala/code/api/util/RateLimitingUtil.scala @@ -117,6 +117,12 @@ object RateLimitingUtil extends MdcLoggable { * @param consumerId The consumer ID * @return RateLimit with system property defaults (default to -1 if not set) */ + /** 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 } + /** THE SINGLE SOURCE OF TRUTH for active rate limits. * This is the ONLY function that should be called to get active rate limits. * Used by BOTH enforcement (AfterApiAuth) and API reporting (Http4s600). 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 43b5f639ba..2c3158b34f 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 @@ -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 8e467fe1b5..a0cde874f3 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) 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 159ce0ea2a..224c04b8de 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 @@ -6199,12 +6199,6 @@ object Http4s600 { } - // The fields of a rate limit that name something narrower than the consumer, which nothing enforces yet. - // An empty value names nothing. - private 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 } - // Route: POST /obp/v6.0.0/management/consumers/CONSUMER_ID/consumer/rate-limits (201) lazy val createCallLimits: Http4sRoute = Http4sRoute { case req @ POST -> `prefixPath` / "management" / "consumers" / consumerId / "consumer" / "rate-limits" => @@ -6216,7 +6210,7 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CallLimitPostJsonV600] } _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) - scopeFields = rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) + scopeFields = RateLimitingUtil.rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) _ <- Helper.booleanToFuture(s"$RateLimitScopeNotSupported Remove: ${scopeFields.mkString(", ")}", 400, Some(cc)) { scopeFields.isEmpty } @@ -6242,7 +6236,7 @@ object Http4s600 { com.openbankproject.commons.util.JsonAliases.parse(rawBody).extract[CallLimitPostJsonV400] } _ <- NewStyle.function.getConsumerByConsumerId(consumerId, Some(cc)) - scopeFields = rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) + scopeFields = RateLimitingUtil.rateLimitScopeFields(postJson.api_name, postJson.api_version, postJson.bank_id) _ <- Helper.booleanToFuture(s"$RateLimitScopeNotSupported Remove: ${scopeFields.mkString(", ")}", 400, Some(cc)) { scopeFields.isEmpty } diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index 0f279125e5..0e1639b58a 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -148,18 +148,29 @@ 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)) - + apiName: Option[String]): Option[RateLimiting] = RateLimiting.findAll( - byConsumerParam, byBankParam, byApiVersionParam, byApiNameParam, - OrderBy(RateLimiting.updatedAt, Descending) + sameScope(consumerId, bankId, apiVersion, apiName) :+ OrderBy(RateLimiting.updatedAt, Descending): _* ).headOption + + def countConsumerCallLimits(consumerId: String, + bankId: Option[String], + apiVersion: Option[String], + apiName: Option[String]): Future[Int] = Future { + RateLimiting.count(sameScope(consumerId, bankId, apiVersion, apiName): _*).toInt } + /** The consumer's rows with exactly this bank_id, api_version and api_name; None matches a row that does not set it. */ + private def sameScope(consumerId: String, + bankId: Option[String], + apiVersion: Option[String], + apiName: Option[String]): List[QueryParam[RateLimiting]] = List( + By(RateLimiting.ConsumerId, consumerId), + bankId.map(v => By(RateLimiting.BankId, v)).getOrElse(NullRef(RateLimiting.BankId)), + apiVersion.map(v => By(RateLimiting.ApiVersion, v)).getOrElse(NullRef(RateLimiting.ApiVersion)), + apiName.map(v => By(RateLimiting.ApiName, v)).getOrElse(NullRef(RateLimiting.ApiName)) + ) + def createConsumerCallLimits(consumerId: String, fromDate: Date, toDate: Date, @@ -240,7 +251,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/test/scala/code/api/v3_1_0/RateLimitTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala index 4d22cea4b6..f9cef58bbe 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,12 @@ import org.scalatest.Tag class RateLimitTest extends V310ServerSetup with PropsReset { + /** 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,7 +195,9 @@ 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)) + // Set by another consumer, so this consumer starts the period with no calls made. + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitSecondJson)) Then("We should get a 200") response01.code should equal(200) @@ -197,11 +208,12 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitSecondJson)) - 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 minute for a Consumer", ApiEndpoint, VersionOfApi) { @@ -211,7 +223,9 @@ 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(callLimitMinuteJson)) + // Set by another consumer, so this consumer starts the period with no calls made. + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitMinuteJson)) Then("We should get a 200") response01.code should equal(200) @@ -222,11 +236,12 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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) + 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 hour for a Consumer", ApiEndpoint, VersionOfApi) { @@ -236,7 +251,9 @@ 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(callLimitHourJson)) + // Set by another consumer, so this consumer starts the period with no calls made. + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitHourJson)) Then("We should get a 200") response01.code should equal(200) @@ -247,11 +264,12 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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) { @@ -261,7 +279,9 @@ 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(callLimitDayJson)) + // Set by another consumer, so this consumer starts the period with no calls made. + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitDayJson)) Then("We should get a 200") response01.code should equal(200) @@ -272,11 +292,12 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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) + 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 week for a Consumer", ApiEndpoint, VersionOfApi) { @@ -286,7 +307,9 @@ 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(callLimitWeekJson)) + // Set by another consumer, so this consumer starts the period with no calls made. + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitWeekJson)) Then("We should get a 200") response01.code should equal(200) @@ -297,11 +320,12 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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) + 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 month for a Consumer", ApiEndpoint, VersionOfApi) { @@ -311,7 +335,9 @@ 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(callLimitMonthJson)) + // Set by another consumer, so this consumer starts the period with no calls made. + Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) + val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitMonthJson)) Then("We should get a 200") response01.code should equal(200) @@ -322,11 +348,12 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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) + 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/RateLimitingTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/RateLimitingTest.scala index c1b6a2b3f0..1f1bf3632e 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,10 +118,11 @@ 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) @@ -134,18 +134,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 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 +157,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 +180,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 +203,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 +226,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 +249,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 +262,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/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json index a5d87ce71a..a88b899c2c 100644 --- a/scripts/resource_doc_baseline/parity_allowlist.json +++ b/scripts/resource_doc_baseline/parity_allowlist.json @@ -2682,6 +2682,38 @@ "reason": "Added ApiCollectionNotFound: 404 when the collection does not exist or belongs to another user, so only its owner can update it", "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" } ] } From 13c8c9e34f5caf3fd07eec9792adf90838de9f27 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 12:23:04 +0100 Subject: [PATCH 71/78] test: change a legacy rate limit stored with empty scope fields A row an older v4.0.0 stored with "" for api_name, api_version and bank_id is the consumer's only rate limit and must be the one the PUT changes. --- .../code/api/v6_0_0/LegacyCallLimitsPutTest.scala | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 81a78d2f87..dd0d2b6b0a 100644 --- 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 @@ -112,6 +112,18 @@ class LegacyCallLimitsPutTest extends V600ServerSetup { 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") From 1e69c8251d214d6ebe4eaa1cd793d5f56ade4764 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 12:23:54 +0100 Subject: [PATCH 72/78] fix: recognise a legacy rate limit stored with empty scope fields The legacy PUT finds and counts the consumer's rate limits through the getters, which take an empty bank_id, api_version or api_name as not set, so a row an older version stored with "" is changed instead of getting a second row next to it. The scaladoc of rateLimitScopeFields is no longer stacked on another function's. --- .../code/api/util/RateLimitingUtil.scala | 13 ++++++------ .../ratelimiting/MappedRateLimiting.scala | 20 +++++++++---------- 2 files changed, 16 insertions(+), 17 deletions(-) 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 8b18665a5b..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 */ @@ -117,12 +124,6 @@ object RateLimitingUtil extends MdcLoggable { * @param consumerId The consumer ID * @return RateLimit with system property defaults (default to -1 if not set) */ - /** 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 } - /** THE SINGLE SOURCE OF TRUTH for active rate limits. * This is the ONLY function that should be called to get active rate limits. * Used by BOTH enforcement (AfterApiAuth) and API reporting (Http4s600). diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index 0e1639b58a..a89707320f 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -149,27 +149,25 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait with Logger bankId: Option[String], apiVersion: Option[String], apiName: Option[String]): Option[RateLimiting] = - RateLimiting.findAll( - sameScope(consumerId, bankId, apiVersion, apiName) :+ OrderBy(RateLimiting.updatedAt, Descending): _* - ).headOption + 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 { - RateLimiting.count(sameScope(consumerId, bankId, apiVersion, apiName): _*).toInt + sameScope(consumerId, bankId, apiVersion, apiName).size } - /** The consumer's rows with exactly this bank_id, api_version and api_name; None matches a row that does not set it. */ + /** + * 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[QueryParam[RateLimiting]] = List( - By(RateLimiting.ConsumerId, consumerId), - bankId.map(v => By(RateLimiting.BankId, v)).getOrElse(NullRef(RateLimiting.BankId)), - apiVersion.map(v => By(RateLimiting.ApiVersion, v)).getOrElse(NullRef(RateLimiting.ApiVersion)), - apiName.map(v => By(RateLimiting.ApiName, v)).getOrElse(NullRef(RateLimiting.ApiName)) - ) + 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, From aa514c069a37aa51f814887f90c6b60043cde35b Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 12:27:37 +0100 Subject: [PATCH 73/78] test: cancel the per-second scenarios when the first call is too slow to tell Redis rounds the counter's TTL to whole seconds, so a one-second window reads as expired about half a second after the first call; a run slower than that cannot show the limit applied. --- obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala | 4 ++++ obp-api/src/test/scala/code/api/v4_0_0/RateLimitingTest.scala | 4 ++++ 2 files changed, 8 insertions(+) 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 f9cef58bbe..5e092b424e 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 @@ -202,11 +202,15 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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 429, as the new limit applies at once") response03.code should equal(429) 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 1f1bf3632e..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 @@ -128,11 +128,15 @@ class RateLimitingTest extends V400ServerSetup with PropsReset { 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 429, as the new limit applies at once") response03.code should equal(429) From 8aa64db07ac45df25cfb0e306991e82e78727ace Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 12:30:49 +0100 Subject: [PATCH 74/78] fix(cards): declare CanDeleteCardsForBank on the delete card resource doc The middleware enforces a resource doc's roles before the handler runs, so listing CanCreateCardsForBank made deleting a card need both roles. --- .../scala/code/api/v3_1_0/Http4s310.scala | 2 +- .../test/scala/code/api/v3_1_0/CardTest.scala | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) 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 43b5f639ba..263b6f58fe 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) ) 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) + } + } + +} From c7dca73d43f9bbe57439d75cb3e741f363d7e6e9 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 13:23:46 +0100 Subject: [PATCH 75/78] test: set the v3.1.0 call limits from another consumer through one helper --- .../scala/code/api/v3_1_0/RateLimitTest.scala | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) 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 5e092b424e..0eebdf5122 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 @@ -50,6 +50,12 @@ 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)) @@ -195,9 +201,7 @@ 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) - // Set by another consumer, so this consumer starts the period with no calls made. - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) - val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitSecondJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitSecondJson) Then("We should get a 200") response01.code should equal(200) @@ -227,9 +231,7 @@ 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) - // Set by another consumer, so this consumer starts the period with no calls made. - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) - val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitMinuteJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitMinuteJson) Then("We should get a 200") response01.code should equal(200) @@ -255,9 +257,7 @@ 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) - // Set by another consumer, so this consumer starts the period with no calls made. - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) - val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitHourJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitHourJson) Then("We should get a 200") response01.code should equal(200) @@ -283,9 +283,7 @@ 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) - // Set by another consumer, so this consumer starts the period with no calls made. - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) - val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitDayJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitDayJson) Then("We should get a 200") response01.code should equal(200) @@ -311,9 +309,7 @@ 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) - // Set by another consumer, so this consumer starts the period with no calls made. - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) - val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitWeekJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitWeekJson) Then("We should get a 200") response01.code should equal(200) @@ -339,9 +335,7 @@ 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) - // Set by another consumer, so this consumer starts the period with no calls made. - Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, ApiRole.CanUpdateRateLimits.toString) - val response01 = makePutRequest((v3_1_0_Request / "management" / "consumers" / consumerId / "consumer" / "call-limits").PUT <@(user2), write(callLimitMonthJson)) + val response01 = setLimitAsAnotherConsumer(consumerId, callLimitMonthJson) Then("We should get a 200") response01.code should equal(200) From 0a33db31230b24e42a10fa5738a7052b25fc1abf Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 13:26:36 +0100 Subject: [PATCH 76/78] fix(abac): answer rejected rule code with the error validate reports Create and update passed the failed validation to unboxFullOrFail with a message lacking an OBP code, so every invalid rule answered OBP-50005. --- .../scala/code/api/v6_0_0/Http4s600.scala | 69 +++++++++++-------- .../scala/code/api/v6_0_0/AbacRuleTests.scala | 50 +++++++++++++- 2 files changed, 88 insertions(+), 31 deletions(-) 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 59f809a825..355856b78b 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 @@ -5052,37 +5052,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 => @@ -5983,8 +5992,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, @@ -6045,8 +6054,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( 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..2f2cfcdf58 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 @@ -391,4 +391,52 @@ 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") { + + val invalidRuleCodes = List( + "field reference" -> "authenticatedUser.noSuchField == \"x\"", + "syntax" -> "authenticatedUser.emailAddress ==", + "tautology" -> "true" + ) + + 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) => + scenario(s"Creating a rule with a $kind error", ApiEndpoint3, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) + val expectedCode = validatedCode(ruleCode) + expectedCode should not equal obpCode(ErrorMessages.UnknownError) + 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", ApiEndpoint3, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canUpdateAbacRule.toString) + val ruleId = createAbacRuleViaApi(s"to-update-$kind", "false") + val expectedCode = validatedCode(ruleCode) + 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) + } + } + } } From 90c1c41d381bdcb4b8bf7a68f4e078cdc7318af4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 13:28:29 +0100 Subject: [PATCH 77/78] test: run the v3.1.0 call limit scenarios for the longer periods from one table --- .../scala/code/api/v3_1_0/RateLimitTest.scala | 124 ++---------------- 1 file changed, 14 insertions(+), 110 deletions(-) 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 0eebdf5122..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 @@ -223,135 +223,39 @@ class RateLimitTest extends V310ServerSetup with PropsReset { 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 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 = setLimitAsAnotherConsumer(consumerId, 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 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 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 = setLimitAsAnotherConsumer(consumerId, 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 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 = setLimitAsAnotherConsumer(consumerId, 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 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 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 = setLimitAsAnotherConsumer(consumerId, 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 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 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 = setLimitAsAnotherConsumer(consumerId, 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)) + 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) + } } } From bbfa120b7d50581f0b3ee5c175bb29f22cdb23db Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 4 Nov 2026 15:06:05 +0100 Subject: [PATCH 78/78] test: assert the error each invalid ABAC rule code is classified as The create and update scenarios now check that validate reports OBP-38005 for a field reference, OBP-38002 for an incomplete expression (the compiler reports it as a failed compilation) and OBP-38010 for a tautology, before checking that create and update answer the same; the update scenarios carry the updateAbacRule tag. --- .../scala/code/api/v6_0_0/AbacRuleTests.scala | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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 2f2cfcdf58..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 @@ -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. @@ -396,10 +397,12 @@ class AbacRuleTests extends V600ServerSetup with DefaultUsers { 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\"", - "syntax" -> "authenticatedUser.emailAddress ==", - "tautology" -> "true" + ("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) @@ -413,11 +416,11 @@ class AbacRuleTests extends V600ServerSetup with DefaultUsers { obpCode((response.body \ "message").extract[String]) } - invalidRuleCodes.foreach { case (kind, ruleCode) => + 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) - expectedCode should not equal obpCode(ErrorMessages.UnknownError) + 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))) @@ -426,10 +429,11 @@ class AbacRuleTests extends V600ServerSetup with DefaultUsers { response.body.extract[ErrorMessage].message should startWith(expectedCode) } - scenario(s"Updating a rule with a $kind error", ApiEndpoint3, VersionOfApi) { + 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)))