From 94d1290ced2789b3ba3c119aec3ab4a37b7847a3 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 30 Oct 2026 16:03:15 +0100 Subject: [PATCH 1/7] test: cover counterparties in account and bank cascade deletes Cascade-deleting an account or a bank must remove the account's own counterparties with their bespoke fields, metadata, attributes and limits, and must not touch counterparties of an account with the same ACCOUNT_ID at another bank or of another account at the same bank. --- .../DeleteCascadeCounterpartiesTest.scala | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala new file mode 100644 index 0000000000..51abbb33b6 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala @@ -0,0 +1,191 @@ +/** +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.util.APIUtil.OAuth._ +import code.api.util.ApiRole +import code.api.util.ErrorMessages.attemptedToOpenAnEmptyBox +import code.api.v4_0_0.Http4s400.Implementations4_0_0 +import code.counterpartyattribute.{CounterpartyAttribute, CounterpartyAttributeX} +import code.counterpartylimit.{CounterpartyLimit, CounterpartyLimitProvider} +import code.entitlement.Entitlement +import code.metadata.counterparties.{Counterparties, MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata} +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.model.enums.CounterpartyAttributeType +import com.openbankproject.commons.model.{AccountId, BankId, CounterpartyBespoke, CounterpartyId, CounterpartyTrait} +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString +import org.scalatest.Tag + +import scala.concurrent.Await +import scala.concurrent.duration._ + +class DeleteCascadeCounterpartiesTest extends V400ServerSetup { + + object VersionOfApi extends Tag(ApiVersion.v4_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.deleteAccountCascade)) + object ApiEndpoint2 extends Tag(nameOf(Implementations4_0_0.deleteBankCascade)) + + private val ownerView = Constant.SYSTEM_OWNER_VIEW_ID + + /** A counterparty on the given account, with one row in every table that hangs off it. */ + private def createCounterpartyWithChildren(bankId: BankId, accountId: AccountId): CounterpartyTrait = { + val counterparty = Counterparties.counterparties.vend.createCounterparty( + createdByUserId = resourceUser1.userId, + thisBankId = bankId.value, + thisAccountId = accountId.value, + thisViewId = ownerView, + name = randomString(20), + otherAccountRoutingScheme = "IBAN", + otherAccountRoutingAddress = "DE89370400440532013000", + otherBankRoutingScheme = "BIC", + otherBankRoutingAddress = "COBADEFFXXX", + otherBranchRoutingScheme = "OBP", + otherBranchRoutingAddress = "Berlin", + isBeneficiary = true, + otherAccountSecondaryRoutingScheme = "IBAN", + otherAccountSecondaryRoutingAddress = "DE89370400440532013000", + description = "cascade test payee", + currency = "EUR", + bespoke = List(CounterpartyBespoke("key", "value")) + ).openOrThrowException(attemptedToOpenAnEmptyBox) + + Counterparties.counterparties.vend + .getOrCreateMetadata(bankId, accountId, counterparty.counterpartyId, counterparty.name) + .openOrThrowException(attemptedToOpenAnEmptyBox) + Await.result( + CounterpartyAttributeX.counterpartyAttributeProvider.vend.createOrUpdateCounterpartyAttribute( + CounterpartyId(counterparty.counterpartyId), None, "TAX_NUMBER", CounterpartyAttributeType.STRING, "123", Some(true)), + 10.seconds + ).openOrThrowException(attemptedToOpenAnEmptyBox) + Await.result( + CounterpartyLimitProvider.counterpartyLimit.vend.createOrUpdateCounterpartyLimit( + bankId.value, accountId.value, ownerView, counterparty.counterpartyId, + "EUR", BigDecimal(100), BigDecimal(1000), 10, BigDecimal(10000), 100, BigDecimal(100000), 1000), + 10.seconds + ).openOrThrowException(attemptedToOpenAnEmptyBox) + + counterparty + } + + /** Rows left in each counterparty table for one counterparty. */ + private def remainingRows(bankId: BankId, accountId: AccountId, counterparty: CounterpartyTrait): Map[String, Long] = { + val counterpartyId = counterparty.counterpartyId + val primaryKey = counterparty.asInstanceOf[MappedCounterparty].id.get + Map( + "counterparty" -> MappedCounterparty.count(By(MappedCounterparty.mCounterPartyId, counterpartyId)), + "bespoke" -> MappedCounterpartyBespoke.count(By(MappedCounterpartyBespoke.mCounterparty, primaryKey)), + "metadata" -> MappedCounterpartyMetadata.count( + By(MappedCounterpartyMetadata.thisBankId, bankId.value), + By(MappedCounterpartyMetadata.thisAccountId, accountId.value), + By(MappedCounterpartyMetadata.counterpartyId, counterpartyId)), + "attribute" -> CounterpartyAttribute.count(By(CounterpartyAttribute.CounterpartyId_, counterpartyId)), + "limit" -> CounterpartyLimit.count(By(CounterpartyLimit.CounterpartyId, counterpartyId)) + ) + } + + private def allDeleted(rows: Map[String, Long]) = rows.values.forall(_ == 0L) + private def allPresent(rows: Map[String, Long]) = rows.values.forall(_ == 1L) + + feature(s"test $ApiEndpoint1 version $VersionOfApi - counterparties") { + scenario("Deleting an account deletes its own counterparties and leaves other accounts' counterparties alone", ApiEndpoint1, VersionOfApi) { + Given("Two banks that each have an account with the same ACCOUNT_ID, plus a second account in the first bank") + val bankId = BankId(randomString(10)) + val otherBankId = BankId(randomString(10)) + createBank(bankId.value) + createBank(otherBankId.value) + val accountId = AccountId(randomString(10)) + val siblingAccountId = AccountId(randomString(10)) + createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR") + createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR") + createAccountRelevantResource(Some(resourceUser1), bankId, siblingAccountId, "EUR") + + And("A counterparty with bespoke fields, metadata, an attribute and a limit on each account") + val target = createCounterpartyWithChildren(bankId, accountId) + val sameIdOtherBank = createCounterpartyWithChildren(otherBankId, accountId) + val sibling = createCounterpartyWithChildren(bankId, siblingAccountId) + allPresent(remainingRows(bankId, accountId, target)) should be(true) + + When("We delete the first account 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) + val response = makeDeleteRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The account's counterparty and every row that hangs off it are gone") + remainingRows(bankId, accountId, target) should equal( + Map("counterparty" -> 0L, "bespoke" -> 0L, "metadata" -> 0L, "attribute" -> 0L, "limit" -> 0L)) + + And("The counterparty of the same ACCOUNT_ID at another bank is untouched") + allPresent(remainingRows(otherBankId, accountId, sameIdOtherBank)) should be(true) + + And("The counterparty of the other account at the same bank is untouched") + allPresent(remainingRows(bankId, siblingAccountId, sibling)) should be(true) + } + } + + feature(s"test $ApiEndpoint2 version $VersionOfApi - counterparties") { + scenario("Deleting a bank deletes the counterparties of all its accounts and leaves other banks alone", ApiEndpoint2, VersionOfApi) { + Given("A bank with two accounts, and another bank with an account that has the same ACCOUNT_ID") + val bankId = BankId(randomString(10)) + val otherBankId = BankId(randomString(10)) + createBank(bankId.value) + createBank(otherBankId.value) + val accountId = AccountId(randomString(10)) + val secondAccountId = AccountId(randomString(10)) + createAccountRelevantResource(Some(resourceUser1), bankId, accountId, "EUR") + createAccountRelevantResource(Some(resourceUser1), bankId, secondAccountId, "EUR") + createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR") + + val first = createCounterpartyWithChildren(bankId, accountId) + val second = createCounterpartyWithChildren(bankId, secondAccountId) + val otherBank = createCounterpartyWithChildren(otherBankId, accountId) + + When("We delete the bank with the cascading endpoint") + Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteBankCascade.toString) + val request = (v4_0_0_Request / "management" / "cascading" / "banks" / bankId.value).DELETE <@ (user1) + val response = makeDeleteRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The counterparties of both accounts and their child rows are gone") + allDeleted(remainingRows(bankId, accountId, first)) should be(true) + allDeleted(remainingRows(bankId, secondAccountId, second)) should be(true) + + And("The counterparty at the other bank is untouched") + allPresent(remainingRows(otherBankId, accountId, otherBank)) should be(true) + } + } + +} From 6cf56fe54fa89cbf9a5b5e77ab30c7868a495a26 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 30 Oct 2026 16:09:20 +0100 Subject: [PATCH 2/7] test: check that cascade deletes also remove counterparty metadata locations --- .../DeleteCascadeCounterpartiesTest.scala | 50 +++++++++++-------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala index 51abbb33b6..f2cee8514f 100644 --- a/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala +++ b/obp-api/src/test/scala/code/api/v4_0_0/DeleteCascadeCounterpartiesTest.scala @@ -35,15 +35,16 @@ import code.api.v4_0_0.Http4s400.Implementations4_0_0 import code.counterpartyattribute.{CounterpartyAttribute, CounterpartyAttributeX} import code.counterpartylimit.{CounterpartyLimit, CounterpartyLimitProvider} import code.entitlement.Entitlement -import code.metadata.counterparties.{Counterparties, MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata} +import code.metadata.counterparties.{Counterparties, MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata, MappedCounterpartyWhereTag} import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.enums.CounterpartyAttributeType import com.openbankproject.commons.model.{AccountId, BankId, CounterpartyBespoke, CounterpartyId, CounterpartyTrait} import com.openbankproject.commons.util.ApiVersion -import net.liftweb.mapper.By +import net.liftweb.mapper.{By, ByList} import net.liftweb.util.Helpers.randomString import org.scalatest.Tag +import java.util.Date import scala.concurrent.Await import scala.concurrent.duration._ @@ -55,8 +56,10 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { private val ownerView = Constant.SYSTEM_OWNER_VIEW_ID - /** A counterparty on the given account, with one row in every table that hangs off it. */ - private def createCounterpartyWithChildren(bankId: BankId, accountId: AccountId): CounterpartyTrait = { + private case class Seeded(bankId: BankId, accountId: AccountId, counterparty: CounterpartyTrait, locationIds: List[Long]) + + /** A counterparty on the given account, with rows in every table that hangs off it. */ + private def createCounterpartyWithChildren(bankId: BankId, accountId: AccountId): Seeded = { val counterparty = Counterparties.counterparties.vend.createCounterparty( createdByUserId = resourceUser1.userId, thisBankId = bankId.value, @@ -77,9 +80,11 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { bespoke = List(CounterpartyBespoke("key", "value")) ).openOrThrowException(attemptedToOpenAnEmptyBox) - Counterparties.counterparties.vend + val metadata = Counterparties.counterparties.vend .getOrCreateMetadata(bankId, accountId, counterparty.counterpartyId, counterparty.name) .openOrThrowException(attemptedToOpenAnEmptyBox) + metadata.addPhysicalLocation(resourceUser1.userPrimaryKey, new Date(), 13.4, 52.5) should be(true) + metadata.addCorporateLocation(resourceUser1.userPrimaryKey, new Date(), 11.6, 48.1) should be(true) Await.result( CounterpartyAttributeX.counterpartyAttributeProvider.vend.createOrUpdateCounterpartyAttribute( CounterpartyId(counterparty.counterpartyId), None, "TAX_NUMBER", CounterpartyAttributeType.STRING, "123", Some(true)), @@ -92,11 +97,14 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { 10.seconds ).openOrThrowException(attemptedToOpenAnEmptyBox) - counterparty + val storedMetadata = MappedCounterpartyMetadata.find(By(MappedCounterpartyMetadata.counterpartyId, counterparty.counterpartyId)) + .openOrThrowException(attemptedToOpenAnEmptyBox) + Seeded(bankId, accountId, counterparty, List(storedMetadata.physicalLocation.get, storedMetadata.corporateLocation.get)) } - /** Rows left in each counterparty table for one counterparty. */ - private def remainingRows(bankId: BankId, accountId: AccountId, counterparty: CounterpartyTrait): Map[String, Long] = { + /** Rows left in each counterparty table for one seeded counterparty. */ + private def remainingRows(seeded: Seeded): Map[String, Long] = { + val Seeded(bankId, accountId, counterparty, locationIds) = seeded val counterpartyId = counterparty.counterpartyId val primaryKey = counterparty.asInstanceOf[MappedCounterparty].id.get Map( @@ -107,12 +115,15 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { By(MappedCounterpartyMetadata.thisAccountId, accountId.value), By(MappedCounterpartyMetadata.counterpartyId, counterpartyId)), "attribute" -> CounterpartyAttribute.count(By(CounterpartyAttribute.CounterpartyId_, counterpartyId)), - "limit" -> CounterpartyLimit.count(By(CounterpartyLimit.CounterpartyId, counterpartyId)) + "limit" -> CounterpartyLimit.count(By(CounterpartyLimit.CounterpartyId, counterpartyId)), + "location" -> MappedCounterpartyWhereTag.count(ByList(MappedCounterpartyWhereTag.id, locationIds)) ) } - private def allDeleted(rows: Map[String, Long]) = rows.values.forall(_ == 0L) - private def allPresent(rows: Map[String, Long]) = rows.values.forall(_ == 1L) + private val nothingLeft = + Map("counterparty" -> 0L, "bespoke" -> 0L, "metadata" -> 0L, "attribute" -> 0L, "limit" -> 0L, "location" -> 0L) + private val untouched = + Map("counterparty" -> 1L, "bespoke" -> 1L, "metadata" -> 1L, "attribute" -> 1L, "limit" -> 1L, "location" -> 2L) feature(s"test $ApiEndpoint1 version $VersionOfApi - counterparties") { scenario("Deleting an account deletes its own counterparties and leaves other accounts' counterparties alone", ApiEndpoint1, VersionOfApi) { @@ -127,11 +138,11 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { createAccountRelevantResource(Some(resourceUser1), otherBankId, accountId, "EUR") createAccountRelevantResource(Some(resourceUser1), bankId, siblingAccountId, "EUR") - And("A counterparty with bespoke fields, metadata, an attribute and a limit on each account") + And("A counterparty with bespoke fields, metadata with two locations, an attribute and a limit on each account") val target = createCounterpartyWithChildren(bankId, accountId) val sameIdOtherBank = createCounterpartyWithChildren(otherBankId, accountId) val sibling = createCounterpartyWithChildren(bankId, siblingAccountId) - allPresent(remainingRows(bankId, accountId, target)) should be(true) + remainingRows(target) should equal(untouched) When("We delete the first account with the cascading endpoint") Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser1.userId, ApiRole.canDeleteAccountCascade.toString) @@ -143,14 +154,13 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { response.code should equal(200) And("The account's counterparty and every row that hangs off it are gone") - remainingRows(bankId, accountId, target) should equal( - Map("counterparty" -> 0L, "bespoke" -> 0L, "metadata" -> 0L, "attribute" -> 0L, "limit" -> 0L)) + remainingRows(target) should equal(nothingLeft) And("The counterparty of the same ACCOUNT_ID at another bank is untouched") - allPresent(remainingRows(otherBankId, accountId, sameIdOtherBank)) should be(true) + remainingRows(sameIdOtherBank) should equal(untouched) And("The counterparty of the other account at the same bank is untouched") - allPresent(remainingRows(bankId, siblingAccountId, sibling)) should be(true) + remainingRows(sibling) should equal(untouched) } } @@ -180,11 +190,11 @@ class DeleteCascadeCounterpartiesTest extends V400ServerSetup { response.code should equal(200) And("The counterparties of both accounts and their child rows are gone") - allDeleted(remainingRows(bankId, accountId, first)) should be(true) - allDeleted(remainingRows(bankId, secondAccountId, second)) should be(true) + remainingRows(first) should equal(nothingLeft) + remainingRows(second) should equal(nothingLeft) And("The counterparty at the other bank is untouched") - allPresent(remainingRows(otherBankId, accountId, otherBank)) should be(true) + remainingRows(otherBank) should equal(untouched) } } From 34763e39477524d63a37a956cba8187d261946c4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Fri, 30 Oct 2026 16:09:20 +0100 Subject: [PATCH 3/7] fix: delete an account's counterparties in the account cascade DeleteAccountCascade now removes the counterparties the account created, with their bespoke fields, attributes and limits, and the account's counterparty metadata with its locations. Rows are selected by both BANK_ID and ACCOUNT_ID, so another bank's account with the same ACCOUNT_ID keeps its counterparties. Bank and product cascades use the same code and get the same behaviour. --- .../scala/deletion/DeleteAccountCascade.scala | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index f3756634ea..0261f44ab1 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -33,7 +33,10 @@ import code.api.util.APIUtil.fullBoxOrException import code.api.util.ErrorMessages.CouldNotDeleteCascade import code.bankconnectors.Connector import code.cards.MappedPhysicalCard +import code.counterpartyattribute.CounterpartyAttribute +import code.counterpartylimit.CounterpartyLimit import code.entitlement.MappedEntitlement +import code.metadata.counterparties.{MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata, MappedCounterpartyWhereTag} import code.model.dataAccess.{BankAccountRouting, MappedBankAccount, MappedBankAccountData} import code.views.system.{AccountAccess, ViewDefinition} import code.webhook.MappedAccountWebhook @@ -59,6 +62,8 @@ object DeleteAccountCascade { deleteBankAccountData(bankId, accountId) :: deleteCards(accountId) :: deleteAccountRoutings(bankId, accountId) :: + deleteCounterparties(bankId, accountId) :: + deleteCounterpartyMetadata(bankId, accountId) :: deleteAccount(bankId, accountId) :: Nil doneTasks.forall(_ == true) @@ -136,6 +141,46 @@ object DeleteAccountCascade { ) } + // The counterparties this account created, with the rows that hang off each of them. + // Counterparties of other accounts that point at this account are theirs and are kept. + private def deleteCounterparties(bankId: BankId, accountId: AccountId): Boolean = { + val counterparties = MappedCounterparty.findAll( + By(MappedCounterparty.mThisBankId, bankId.value), + By(MappedCounterparty.mThisAccountId, accountId.value) + ) + val primaryKeys = counterparties.map(_.id.get) + val counterpartyIds = counterparties.map(_.counterpartyId) + val bespokesDeleted = primaryKeys.isEmpty || + MappedCounterpartyBespoke.bulkDelete_!!(ByList(MappedCounterpartyBespoke.mCounterparty, primaryKeys)) + val attributesDeleted = counterpartyIds.isEmpty || + CounterpartyAttribute.bulkDelete_!!(ByList(CounterpartyAttribute.CounterpartyId_, counterpartyIds)) + val limitsDeleted = CounterpartyLimit.bulkDelete_!!( + By(CounterpartyLimit.BankId, bankId.value), + By(CounterpartyLimit.AccountId, accountId.value) + ) + val counterpartiesDeleted = MappedCounterparty.bulkDelete_!!( + By(MappedCounterparty.mThisBankId, bankId.value), + By(MappedCounterparty.mThisAccountId, accountId.value) + ) + bespokesDeleted && attributesDeleted && limitsDeleted && counterpartiesDeleted + } + + // Metadata covers both the counterparties created on this account and the ones derived from its transactions. + private def deleteCounterpartyMetadata(bankId: BankId, accountId: AccountId): Boolean = { + val metadata = MappedCounterpartyMetadata.findAll( + By(MappedCounterpartyMetadata.thisBankId, bankId.value), + By(MappedCounterpartyMetadata.thisAccountId, accountId.value) + ) + val locationIds = metadata.flatMap(m => List(m.physicalLocation.get, m.corporateLocation.get)).filter(_ > 0) + val locationsDeleted = locationIds.isEmpty || + MappedCounterpartyWhereTag.bulkDelete_!!(ByList(MappedCounterpartyWhereTag.id, locationIds)) + val metadataDeleted = MappedCounterpartyMetadata.bulkDelete_!!( + By(MappedCounterpartyMetadata.thisBankId, bankId.value), + By(MappedCounterpartyMetadata.thisAccountId, accountId.value) + ) + locationsDeleted && metadataDeleted + } + private def deleteTransactions(bankId: BankId, accountId: AccountId): Boolean = { val deletedTransactions: Box[List[Boolean]] = for( From 094f0f544473b6dfaf2579c5d9850b29d68afd42 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 2 Nov 2026 09:27:57 +0100 Subject: [PATCH 4/7] test: account cascade must keep another bank's cards for the same ACCOUNT_ID --- .../api/v4_0_0/DeleteAccountCascadeTest.scala | 50 ++++++++++++++++++- 1 file changed, 48 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 2bd939c43a..7f4980752b 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 @@ -37,10 +37,14 @@ 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.cards.MappedPhysicalCard import code.entitlement.Entitlement +import code.model.dataAccess.MappedBankAccount import com.github.dwickern.macros.NameOf.nameOf -import com.openbankproject.commons.model.{AmountOfMoneyJsonV121, ErrorMessage} +import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage} import com.openbankproject.commons.util.ApiVersion +import net.liftweb.mapper.By +import net.liftweb.util.Helpers.randomString import org.json4s.native.Serialization.write import org.scalatest.Tag @@ -139,6 +143,48 @@ class DeleteAccountCascadeTest extends V400ServerSetup { makeDeleteRequest(request400).code should equal(404) } } - + + feature(s"test $ApiEndpoint1 version $VersionOfApi - cards") { + scenario("Deleting an account keeps the cards 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, each with a card") + 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 addCard(bank: BankId): String = { + val account = MappedBankAccount.find( + By(MappedBankAccount.bank, bank.value), + By(MappedBankAccount.theAccountId, accountId.value) + ).openOrThrowException("test account not found") + MappedPhysicalCard.create + .mBankId(bank.value) + .mAccount(account.id.get) + .mBankCardNumber(randomString(16)) + .mSerialNumber(randomString(10)) + .saveMe() + .mCardId.get + } + def cardExists(cardId: String): Boolean = MappedPhysicalCard.find(By(MappedPhysicalCard.mCardId, cardId)).isDefined + val card = addCard(bankId) + val otherBankCard = addCard(otherBankId) + + 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) + val response = makeDeleteRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The deleted account's card is gone and the other bank's card is kept") + cardExists(card) should equal(false) + cardExists(otherBankCard) should equal(true) + } + } } From 61b1b58384837a4119f8aeff48e75482b7aa2197 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 2 Nov 2026 09:28:37 +0100 Subject: [PATCH 5/7] fix: account cascade deletes only the cards of the account's own bank deleteCards looked the account up by ACCOUNT_ID alone, so deleting an account also deleted the cards of an account with the same ACCOUNT_ID at another bank. It now selects the account by BANK_ID and ACCOUNT_ID. --- obp-api/src/main/scala/deletion/DeleteAccountCascade.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index 0261f44ab1..b2957ab1fc 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -60,7 +60,7 @@ object DeleteAccountCascade { deleteAccountAttributes(bankId, accountId) :: deleteAccountWebhooks(bankId, accountId) :: deleteBankAccountData(bankId, accountId) :: - deleteCards(accountId) :: + deleteCards(bankId, accountId) :: deleteAccountRoutings(bankId, accountId) :: deleteCounterparties(bankId, accountId) :: deleteCounterpartyMetadata(bankId, accountId) :: @@ -93,8 +93,9 @@ object DeleteAccountCascade { ) } - private def deleteCards(accountId: AccountId): Boolean = { + private def deleteCards(bankId: BankId, accountId: AccountId): Boolean = { MappedBankAccount.findAll( + By(MappedBankAccount.bank, bankId.value), By(MappedBankAccount.theAccountId, accountId.value) ) map ( account => From 24135174e23777ec06781049c9d476fbbb7df6b4 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 2 Nov 2026 09:57:44 +0100 Subject: [PATCH 6/7] test: account cascade must keep entitlements of another bank's same-ACCOUNT_ID users --- .../api/v4_0_0/DeleteAccountCascadeTest.scala | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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 7f4980752b..fead73009d 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,7 +38,7 @@ 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.entitlement.Entitlement +import code.entitlement.{Entitlement, MappedEntitlement} import code.model.dataAccess.MappedBankAccount import com.github.dwickern.macros.NameOf.nameOf import com.openbankproject.commons.model.{AccountId, AmountOfMoneyJsonV121, BankId, ErrorMessage} @@ -187,4 +187,37 @@ class DeleteAccountCascadeTest extends V400ServerSetup { } } + 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") + 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(resourceUser2), otherBankId, accountId, "EUR") + + And("The other bank's account owner has an entitlement at the first bank") + Entitlement.entitlement.vend.addEntitlement(bankId.value, resourceUser2.userId, ApiRole.canCreateAccount.toString) + def otherOwnerEntitlements: Long = MappedEntitlement.count( + By(MappedEntitlement.mBankId, bankId.value), + By(MappedEntitlement.mUserId, resourceUser2.userId) + ) + otherOwnerEntitlements should equal(1L) + + 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) + val response = makeDeleteRequest(request) + + Then("We should get a 200") + response.code should equal(200) + + And("The other bank's account owner keeps the entitlement") + otherOwnerEntitlements should equal(1L) + } + } + } From ef1dbb67bc6088a3380ca1d40e408b4cbfd38b2c Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 2 Nov 2026 09:58:31 +0100 Subject: [PATCH 7/7] fix: account cascade picks the users whose entitlements it removes by BANK_ID too deleteEntitlements found the account's users by ACCOUNT_ID alone, so the users of an account with the same ACCOUNT_ID at another bank lost their entitlements at the deleted account's bank. It now selects the account access by BANK_ID and ACCOUNT_ID. --- obp-api/src/main/scala/deletion/DeleteAccountCascade.scala | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala index b2957ab1fc..6588c515b0 100644 --- a/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala +++ b/obp-api/src/main/scala/deletion/DeleteAccountCascade.scala @@ -86,7 +86,10 @@ object DeleteAccountCascade { ) } private def deleteEntitlements(bankId: BankId, accountId: AccountId): Boolean = { - val userIds = AccountAccess.findAll(By(AccountAccess.account_id, accountId.value)).map(_.user_fk.foreign.map(_.userId).getOrElse("")) + val userIds = AccountAccess.findAll( + By(AccountAccess.bank_id, bankId.value), + By(AccountAccess.account_id, accountId.value) + ).map(_.user_fk.foreign.map(_.userId).getOrElse("")) MappedEntitlement.bulkDelete_!!( By(MappedEntitlement.mBankId, bankId.value), ByList(MappedEntitlement.mUserId, userIds)