diff --git a/ON_BEHALF_OF_USER_ID_PLAN.md b/ON_BEHALF_OF_USER_ID_PLAN.md
index 4c495cfd5d..0bc6b6843d 100644
--- a/ON_BEHALF_OF_USER_ID_PLAN.md
+++ b/ON_BEHALF_OF_USER_ID_PLAN.md
@@ -10,7 +10,7 @@ guards and the sweep that proves each one is reachable are green
live front and the only one being worked through a table at a time: five providers resolve the
on-behalf-of user today (AccountHolders, UserCustomerLink, DynamicEntity/DynamicData, Bank,
Counterparty), which is eight of the sixty-two references that need wiring; the other fifty-four
-are listed by hand in `OnBehalfOfOwnershipSweepTest.notYetWired`, each with the reason it is still
+are listed by hand in `UserReference.notYetWired` (rendered by the "On Behalf Of" Glossary item), each with the reason it is still
open. The webhook row was audited on 2026-09-16 and deliberately deferred rather than wired; the
two v7 notification-webhook delete endpoints that audit turned out to need are committed
(`f2dddcd16`, 2026-09-17), and everything in Phases 0 to 4 is now committed — nothing in this plan
@@ -431,9 +431,9 @@ Progress:
| 1 | `MapperAccountHolders.getOrCreateAccountHolder` (`AccountHolders_User`) | ✅ 2026-09-03. Resolves `user.userId` via `attributedUserId`, re-fetches the on-behalf-of `User` once when delegated, writes the row for it. All five callers (v5/v7 createAccount via `BankAccountCreation`, holding accounts, `AfterApiAuth`, `AuthUser.refreshUser`, sandbox import) go through it. `AgentDelegationTest` has three scenarios (consent user → human holds; original user unchanged; unbound consent fails closed). Endpoint-level `cc.onBehalfOfUserId` in v5/v7 createAccount stays as clarity. |
| 2 | `MappedUserCustomerLink.createUserCustomerLink` | ✅ 2026-09-10, committed. Provider resolves via `linkOwnerUserId` on the three methods keyed by a single user id: `createUserCustomerLink`, `getOCreateUserCustomerLink`, and the two-argument `getUserCustomerLink`. Those three had to move together: the two-argument lookup is every caller's "already linked?" pre-check immediately before a create, and the table carries `UniqueIndex(mUserId, mCustomerId)` — a redirected create paired with an unredirected pre-check passes the check on the consent user and then breaks the index on the human (500, not the intended 400 `CustomerAlreadyExistsForUser`, since `createUserCustomerLink` has no `tryo`). `getUserCustomerLinksByUserId` is deliberately **not** resolved: it also serves the admin lookup at `GET /banks/BANK_ID/user_customer_links/users/USER_ID`, where the id is an explicit target and rewriting it would silently answer a different question; endpoints meaning "my links" pass the resolved id themselves. Phase 3 guards added to all five explicit-target callers (v1.4.0 `addCustomer`, v2.0.0 / v2.1.0 `createCustomer` — guarded only when `user_id` is supplied, since an omitted one means the caller and the provider redirects it — and v2.0.0 / v4.0.0 `createUserCustomerLinks`), each with `InvalidUserId` added to the ResourceDoc error list and a digest-bound `parity_allowlist.json` entry. `AgentDelegationTest` has five scenarios (consent user → human; original user unchanged; unbound consent fails closed; the pre-check asks about the row the create would write; listing by user id is not redirected). 33 scenarios green. |
| 3 | `DynamicData.UserId` (`DynamicData_UserId`), `DynamicEntity.UserId` (`DynamicEntity_UserId`) | ✅ 2026-09-04, committed. Provider `MappedDynamicDataProvider` resolves the caller on **every** entry point (save, update, get, getAll, delete, existsData): personal rows are keyed by the same column on reads and writes, so the redirect must be symmetric or a consent user could not read back what it wrote. Definition creator resolved in `MappedDynamicEntityProvider.createOrUpdate`. **Decided 2026-09-04 (access control): a consent user gets no `personal_requires_role=false` waiver** — on `/my` endpoints it must hold the entity's role, so a Consent has to name the entity explicitly before its holder reaches the human's personal rows (`Http4sDynamicEntity.personalRoleWaived`); the projection read path resolves the owner the same way (`personalRowOwner`). Doc strings of the six My endpoints say so; `UserHasMissingRoles` is now always in their error lists. Tests: `AgentDelegationTest` (provider + definition) and `DynamicEntityConsentUserTest` (HTTP: human no role → 201; consent without role → 403 naming the role; consent with roles → 201, row readable by both, stored on the human). Consumer: the Portal / API Manager Opey conversation entities (`obp_portal_opey_conversation`, `obp_manager_opey_conversation`); the apps write those as the human; **built 2026-09-04 in OBP-Frontend** (definitions, startup bootstrap, `ConversationRecorder`, rows under My Data). **Out of scope here: row-level (ACL) entities** — `DynamicDataAccess.UserId` bootstrap grant and the `allows` checks both stay on the consent user (consistent with each other: rows strand, nothing leaks); `DynamicDataAccess_UserId` is a later Phase 2 row. |
-| 4 | `MappedBank.CreatedByUserId` (`Bank_CreatedByUserId`) | ✅ 2026-09-14. `LocalMappedConnector.bankCreatorUserId` resolves the caller before `createOrUpdateBank` stamps the row. Two sources in the order `CallContext.onBehalfOfUserId` uses: the request layer (`consentCreator`/`consenter`, which a BG/UK consent carries on the request and the stored chain cannot know) wins, otherwise `attributedUserId(_, Bank_CreatedByUserId)` walks the stored chain, applies the policy and logs the delegation. **Closes the defect seen in the wild 2026-09-03**: a bank created through Opey under a temporary consent had `createdbyuserid` = the consent user, so it dropped out of every "banks created by me" read once that consent was revoked. Only one of the four `MappedBank.create` sites sets the column — the Boot, sandbox-import and internal-connector paths have no user and leave it empty. The read side (`Http4s700` self-service quota) already counted via `humanAndAgentUserIds`, so it keeps matching either way; write and read now agree on the human. `AgentDelegationTest` has five scenarios (original user unchanged; consent user → human; unbound consent fails closed; request-layer consenter wins; no authenticated user leaves it empty), and the `Bank_CreatedByUserId` entry is gone from `OnBehalfOfOwnershipSweepTest.notYetWired` — the ratchet fails if it comes back. 49 scenarios green. |
+| 4 | `MappedBank.CreatedByUserId` (`Bank_CreatedByUserId`) | ✅ 2026-09-14. `LocalMappedConnector.bankCreatorUserId` resolves the caller before `createOrUpdateBank` stamps the row. Two sources in the order `CallContext.onBehalfOfUserId` uses: the request layer (`consentCreator`/`consenter`, which a BG/UK consent carries on the request and the stored chain cannot know) wins, otherwise `attributedUserId(_, Bank_CreatedByUserId)` walks the stored chain, applies the policy and logs the delegation. **Closes the defect seen in the wild 2026-09-03**: a bank created through Opey under a temporary consent had `createdbyuserid` = the consent user, so it dropped out of every "banks created by me" read once that consent was revoked. Only one of the four `MappedBank.create` sites sets the column — the Boot, sandbox-import and internal-connector paths have no user and leave it empty. The read side (`Http4s700` self-service quota) already counted via `humanAndAgentUserIds`, so it keeps matching either way; write and read now agree on the human. `AgentDelegationTest` has five scenarios (original user unchanged; consent user → human; unbound consent fails closed; request-layer consenter wins; no authenticated user leaves it empty), and the `Bank_CreatedByUserId` entry is gone from `UserReference.notYetWired` (rendered by the "On Behalf Of" Glossary item) — the ratchet fails if it comes back. 49 scenarios green. |
| 5 | `MappedCounterparty.mCreatedByUserId` (`Counterparty_CreatedByUserId`) | ✅ 2026-09-15, and the first row decided as **record both** rather than redirect. `MapperCounterparties.counterpartyCreators` makes one `attributionOf` call and writes the actor to `mCreatedByUserId` and the human to a new `mCreatedByOnBehalfOfUserId`. Two reasons it is not a redirect: (a) a counterparty is the control on **where money may be sent**, so "which agent created this" has to be answerable from the row rather than by correlating a timestamp against a metrics table with its own retention — the same argument that made `MappedTransactionRequest` record both; (b) `mCreatedByUserId` is published as `created_by_user_id` on the **v2.2.0 and v4.0.0** counterparty responses, so redirecting it would make a STABLE field report a human for something an agent did (`CounterpartyTest` still green, confirming the API is unchanged). Safe at provider level because none of the four call sites takes a user id from the request — v2.2.0 `createCounterparty`, v4.0.0 `createExplicitCounterparty` and `createCounterpartyForAnyAccount`, and the v5.0.0 VRP consent flow all pass the caller's own id — so there is no explicit target a redirect could silently substitute. The new column is **internal**: not on `CounterpartyTrait` (obp-commons, implemented by the remote-connector DTOs) and not in any JSON, because adding a field to those STABLE responses would change the frozen contract; a v7 read can expose it later. No migration — Schemifier adds new columns, as it did for `MappedTransactionRequest.mOnBehalfOfUserId`. **Limitation**: the provider receives only a `String`, so unlike `bankCreatorUserId` it cannot honour the request layer's `consentCreator`/`consenter`; a BG/UK consent with no stored human yet falls back to the actor, which is the documented fail-closed behaviour. `AgentDelegationTest` has four scenarios (original user in both columns; consent user → actor + human; unbound consent fails closed; a broken chain still names the actor rather than blanking the audit column). 53 scenarios green. |
-| 6 | the three webhook creator columns: `AccountWebhook_CreatedByUserId`, `SystemAccountNotificationWebhook_CreatedByUserId`, `BankAccountNotificationWebhook_CreatedByUserId` | **Deferred 2026-09-16, deliberately, and the reason is written down in `todo/webhook_attribution.md`.** The audit corrected two things this plan believed. First, the creator column is *not* an ownership key: the only read that treats it as one, `getAccountWebhooksByUserIdFuture`, has no caller anywhere in the repo, while the live list endpoint `getAccountWebhooks` (`Http4s310.scala:625`) is gated on `canGetWebhooks`, returns every webhook at the bank and treats `user_id` as an optional filter the caller supplies. Editing does not consult the column either. So nobody is locked out of an agent-created webhook, and the `UserReference` comment that claimed otherwise has been corrected. Second, `created_by_user_id` is published on the v3.1.0 and both v4.0.0 responses, so a redirect would make a STABLE field report a person for something an agent did. The agreed direction is therefore **record both**, as `MappedCounterparty` does, and the argument is sharper here than for a counterparty because nothing garbage-collects a webhook when its Consent is revoked (Phase 5 item 3 declines revocation GC), so an agent-created webhook keeps POSTing account events forever with `created_by_user_id` naming an identity that no longer exists. It is not built, because the webhook code had not been read in a long time and the audit found two dead paths in it. The three entries in `OnBehalfOfOwnershipSweepTest.notYetWired` therefore carry their own `webhookDeferred` reason instead of the generic `mechanicalBatch` one, so that nobody picks them up as a quick win. **The second dead path is now closed**: `deleteSystemAccountNotificationWebhookFuture` and `deleteBankAccountNotificationWebhookFuture` existed but no endpoint called either, so a notification webhook could not be removed over the API by anyone. v7.0.0 gained `deleteSystemAccountNotificationWebhook` (`DELETE /web-hooks/account/notifications/on-create-transaction/WEBHOOK_ID`, role `canDeleteSystemAccountNotificationWebhook`) and `deleteBankAccountNotificationWebhook` (the same path under `/banks/BANK_ID`, role `canDeleteAccountNotificationWebhookAtOneBank`), both answering 204, with `NotificationWebhookNotFound` (OBP-30151) and `DeleteWebhookError` (OBP-30152). A webhook belonging to another bank reads as 404 rather than 403, because the role is held per bank and 403 would let a caller with the role at one bank discover which webhook ids exist at every other bank. Eight scenarios in `Http4s700RoutesTest`; 179 scenarios green on 2026-09-17. The endpoints, the two roles and the two error codes are committed in `f2dddcd16`. Adding attribution to a row that could not be deleted would have been the wrong order. **What "deferred" means on the wire, since the word does not say it:** nothing is refused and nothing is redirected. A consent user holding `canCreateWebhook` that POSTs to `/banks/BANK_ID/account-web-hooks` gets the ordinary **201**, and the row is stamped with the *agent's* id, because all three create endpoints pass `user.userId` straight to the provider (`Http4s310.scala:2473`, `Http4s400.scala:5638`, `Http4s400.scala:5661`) and no provider calls `attributionOf`. The human is recorded nowhere on the row. That is exactly the pre-plan behaviour, so "deferred" is the status quo continuing, not a hold: the webhook keeps firing after the Consent is revoked and `created_by_user_id` then names an identity that no longer exists. Decision 12 is the proposal to make that state say so out loud instead of looking like success. The first dead path, `getAccountWebhooksByUserIdFuture` with no caller, is untouched. |
+| 6 | the three webhook creator columns: `AccountWebhook_CreatedByUserId`, `SystemAccountNotificationWebhook_CreatedByUserId`, `BankAccountNotificationWebhook_CreatedByUserId` | **Deferred 2026-09-16, deliberately, and the reason is written down in `todo/webhook_attribution.md`.** The audit corrected two things this plan believed. First, the creator column is *not* an ownership key: the only read that treats it as one, `getAccountWebhooksByUserIdFuture`, has no caller anywhere in the repo, while the live list endpoint `getAccountWebhooks` (`Http4s310.scala:625`) is gated on `canGetWebhooks`, returns every webhook at the bank and treats `user_id` as an optional filter the caller supplies. Editing does not consult the column either. So nobody is locked out of an agent-created webhook, and the `UserReference` comment that claimed otherwise has been corrected. Second, `created_by_user_id` is published on the v3.1.0 and both v4.0.0 responses, so a redirect would make a STABLE field report a person for something an agent did. The agreed direction is therefore **record both**, as `MappedCounterparty` does, and the argument is sharper here than for a counterparty because nothing garbage-collects a webhook when its Consent is revoked (Phase 5 item 3 declines revocation GC), so an agent-created webhook keeps POSTing account events forever with `created_by_user_id` naming an identity that no longer exists. It is not built, because the webhook code had not been read in a long time and the audit found two dead paths in it. The three entries in `UserReference.notYetWired` (rendered by the "On Behalf Of" Glossary item) therefore carry their own `webhookDeferred` reason instead of the generic `mechanicalBatch` one, so that nobody picks them up as a quick win. **The second dead path is now closed**: `deleteSystemAccountNotificationWebhookFuture` and `deleteBankAccountNotificationWebhookFuture` existed but no endpoint called either, so a notification webhook could not be removed over the API by anyone. v7.0.0 gained `deleteSystemAccountNotificationWebhook` (`DELETE /web-hooks/account/notifications/on-create-transaction/WEBHOOK_ID`, role `canDeleteSystemAccountNotificationWebhook`) and `deleteBankAccountNotificationWebhook` (the same path under `/banks/BANK_ID`, role `canDeleteAccountNotificationWebhookAtOneBank`), both answering 204, with `NotificationWebhookNotFound` (OBP-30151) and `DeleteWebhookError` (OBP-30152). A webhook belonging to another bank reads as 404 rather than 403, because the role is held per bank and 403 would let a caller with the role at one bank discover which webhook ids exist at every other bank. Eight scenarios in `Http4s700RoutesTest`; 179 scenarios green on 2026-09-17. The endpoints, the two roles and the two error codes are committed in `f2dddcd16`. Adding attribution to a row that could not be deleted would have been the wrong order. **What "deferred" means on the wire, since the word does not say it:** nothing is refused and nothing is redirected. A consent user holding `canCreateWebhook` that POSTs to `/banks/BANK_ID/account-web-hooks` gets the ordinary **201**, and the row is stamped with the *agent's* id, because all three create endpoints pass `user.userId` straight to the provider (`Http4s310.scala:2473`, `Http4s400.scala:5638`, `Http4s400.scala:5661`) and no provider calls `attributionOf`. The human is recorded nowhere on the row. That is exactly the pre-plan behaviour, so "deferred" is the status quo continuing, not a hold: the webhook keeps firing after the Consent is revoked and `created_by_user_id` then names an identity that no longer exists. Decision 12 is the proposal to make that state say so out loud instead of looking like success. The first dead path, `getAccountWebhooksByUserIdFuture` with no caller, is untouched. |
**After Phase 2 — the third set of things a Consent carries.** Personal resources are owned, not
granted, so delegating them through entity Roles over-grants. Design settled 2026-09-04 in
@@ -553,7 +553,7 @@ The 19 guards, by version: v1.4.0 `addCustomer`; v2.0.0 `createAccount`, `create
stranding a row.** *Simon's idea, 2026-09-19; recorded here, not built, not yet decided.* Today the
doctrine has two answers for a consent user: **redirect** (the provider writes the human, Phase 2) and
**reject** (400 `InvalidUserId`, Phase 3, for a request that explicitly names a consent user). The 54
- references in `OnBehalfOfOwnershipSweepTest.notYetWired` have neither, so they get a third answer nobody
+ references in `UserReference.notYetWired` (rendered by the "On Behalf Of" Glossary item) have neither, so they get a third answer nobody
chose: **success**, with the agent's id in the column. That is the worst of the three, because it is
indistinguishable from the wired case at the call site — the caller gets a 201 and a row that will
strand. The proposal is to make "not wired yet" a *declared* state that answers on the wire:
diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/Http4sUKOBv200AIS.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/Http4sUKOBv200AIS.scala
index e46d86ddd7..ba8f449ce0 100644
--- a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/Http4sUKOBv200AIS.scala
+++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/Http4sUKOBv200AIS.scala
@@ -189,7 +189,8 @@ object Http4sUKOBv200AIS extends MdcLoggable {
(transactions, _) <- Future {
BankAccountExtended(account).getModeratedTransactions(bank, Full(u), view, BankIdAccountId(account.bankId, account.accountId), Some(cc), params)
} map { x => unboxFull(fullBoxOrException(x ~> APIFailureNewStyle(UnknownError, 400, Some(cc.toLight)))) }
- } yield JSONFactory_UKOpenBanking_200.createTransactionsJson(transactions, Nil)
+ // The account id comes from the account looked up above, not from the first transaction, which an empty result does not have.
+ } yield JSONFactory_UKOpenBanking_200.createTransactionsJson(account.accountId.value, transactions, Nil)
}
}
resourceDocs += ResourceDoc(
diff --git a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala
index a8df0be47f..2be2185b16 100644
--- a/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala
+++ b/obp-api/src/main/scala/code/api/UKOpenBanking/v2_0_0/JSONFactory_UKOpenBanking_200.scala
@@ -170,8 +170,8 @@ object JSONFactory_UKOpenBanking_200 extends CustomJsonFormats {
)
}
- def createTransactionsJson(transactions: List[ModeratedTransaction], transactionRequests: List[TransactionRequest]) : TransactionsJsonUKV200 = {
- val accountId = transactions.head.bankAccount.get.accountId.value
+ // The caller passes the account id from the path, so an account with no transactions gives an empty list, not an exception.
+ def createTransactionsJson(accountId: String, transactions: List[ModeratedTransaction], transactionRequests: List[TransactionRequest]) : TransactionsJsonUKV200 = {
val transactionsInnerJson = transactions.map(
transaction=>TransactionInnerJson(
AccountId = accountId,
diff --git a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala
index 3a2c2858c1..91aeaa019e 100644
--- a/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala
+++ b/obp-api/src/main/scala/code/api/berlin/group/v1_3/Http4sBGv13AIS.scala
@@ -265,7 +265,9 @@ object Http4sBGv13AIS extends MdcLoggable {
val u = cc.user.openOrThrowException(AuthenticatedUserIsRequired)
for {
_ <- passesPsd2Aisp(callContext)
- availablePrivateAccounts <- Views.views.vend.getPrivateBankAccountsFuture(u)
+ // only accounts the user reaches through the ReadAccountsBerlinGroup view (403 if none), as getAccountList
+ // and readCardAccount do, else any private view (owner, accountant, ...) would list a card account
+ (availablePrivateAccounts, callContext) <- NewStyle.function.getAccountListOfBerlinGroup(u, callContext)
(_, callContext) <- NewStyle.function.getPhysicalCardsForUser(u, callContext)
(accounts, callContext) <- NewStyle.function.getBankAccounts(availablePrivateAccounts, callContext)
(canReadBalancesAccounts, callContext) <- NewStyle.function.getAccountCanReadBalancesOfBerlinGroup(u, callContext)
@@ -422,6 +424,9 @@ object Http4sBGv13AIS extends MdcLoggable {
val callContext = Some(cc)
val user = cc.user.openOrThrowException(AuthenticatedUserIsRequired)
for {
+ // The calling TPP must hold a PSD2 certificate with the PSP_AI role, as for the transaction
+ // list of the same account; else a consumer without it could still read single transactions.
+ _ <- passesPsd2Aisp(callContext)
(account: BankAccount, callContext) <- NewStyle.function.getBankAccountByAccountId(AccountId(accountId), callContext)
viewId = ViewId(SYSTEM_READ_TRANSACTIONS_BERLIN_GROUP_VIEW_ID)
bankIdAccountId = BankIdAccountId(account.bankId, account.accountId)
@@ -1245,7 +1250,8 @@ respectively the OAuth2 access token.
}
]
}""")),
- List(AuthenticatedUserIsRequired, UnknownError),
+ // 403 NoViewReadAccountsBerlinGroup when the user has no account through the ReadAccountsBerlinGroup view
+ List(AuthenticatedUserIsRequired, NoViewReadAccountsBerlinGroup, UnknownError),
ApiTag("Account Information Service (AIS)") :: apiTagMockedData :: Nil,
http4sPartialFunction = Some(getCardAccounts)
)
@@ -1396,7 +1402,8 @@ of the "Read Transaction List" call within the _links subfield.
}
}
}""")),
- List(AuthenticatedUserIsRequired, UnknownError),
+ // The X509 / regulated-entity errors come from passesPsd2Aisp, when PSD2 certificates are required
+ List(AuthenticatedUserIsRequired, X509CannotGetCertificate, RegulatedEntityNotFoundByCertificate, X509ActionIsNotAllowed, X509GeneralError, UnknownError),
ApiTag("Account Information Service (AIS)") :: Nil,
http4sPartialFunction = Some(getTransactionDetails)
)
diff --git a/obp-api/src/main/scala/code/api/cache/Redis.scala b/obp-api/src/main/scala/code/api/cache/Redis.scala
index 0fd86771d7..cd0495b635 100644
--- a/obp-api/src/main/scala/code/api/cache/Redis.scala
+++ b/obp-api/src/main/scala/code/api/cache/Redis.scala
@@ -102,6 +102,46 @@ object Redis extends MdcLoggable {
jedis
}
+ private val SubscriberMinBackoffMs = 1000L // first wait after a lost subscriber connection
+ private val SubscriberMaxBackoffMs = 30000L // longest wait between reconnect attempts while Redis is down
+
+ /**
+ * Run an event bus's blocking subscribe on a dedicated connection for as long as `running` is true.
+ *
+ * When the connection fails (e.g. Redis restarts or drops the socket) the subscribe call throws;
+ * this closes that connection, waits (1s, doubling up to 30s) and subscribes again on a new one,
+ * so the bus's gRPC streams get messages again once Redis is back, without restarting OBP-API.
+ * `onConnection` hands each new connection to the bus, so its stop() can close the current one.
+ */
+ def runSubscriber(busName: String, running: => Boolean, onConnection: Jedis => Unit)(subscribe: Jedis => Unit): Unit = {
+ var backoffMs = SubscriberMinBackoffMs // wait before the next attempt; doubles per failure, capped
+ var failed = false // set after a failure, so the next connection is logged as a reconnect
+ while (running) { // keep subscribing until stop() clears running
+ var jedis: Jedis = null
+ val startedAt = System.currentTimeMillis() // how long this subscription lasted decides the next backoff
+ try {
+ jedis = newSubscriberConnection()
+ onConnection(jedis)
+ if (failed) logger.info(s"$busName says: Redis subscriber reconnected") // shows streaming resumed after an outage
+ subscribe(jedis) // blocks until unsubscribed by stop() or the connection fails
+ } catch {
+ case e: Throwable if running =>
+ // a subscription that lasted a while means Redis was up: start again from the shortest wait
+ if (System.currentTimeMillis() - startedAt >= SubscriberMaxBackoffMs) backoffMs = SubscriberMinBackoffMs
+ // the connection is gone but the bus is still meant to run: log and retry below
+ logger.error(s"$busName says: Redis subscriber connection lost, reconnecting in ${backoffMs}ms: ${e.getMessage}")
+ failed = true
+ case _: Throwable => // shutting down, ignore
+ } finally {
+ try { if (jedis != null) jedis.close() } catch { case _: Throwable => } // release the dead socket before opening a new one
+ }
+ if (running) { // wait before retrying, so a Redis outage is not hammered with reconnects
+ try Thread.sleep(backoffMs) catch { case _: InterruptedException => return } // interrupted: the JVM is shutting down
+ backoffMs = math.min(backoffMs * 2, SubscriberMaxBackoffMs) // wait longer each time Redis is still down
+ }
+ }
+ }
+
// Redis startup health check
private def performStartupHealthCheck(): Unit = {
try {
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 8659803538..8c59b4f8a7 100644
--- a/obp-api/src/main/scala/code/api/util/Glossary.scala
+++ b/obp-api/src/main/scala/code/api/util/Glossary.scala
@@ -1808,6 +1808,63 @@ object Glossary extends MdcLoggable {
|
|""".stripMargin)
+ glossaryItems += GlossaryItem(
+ title = "On Behalf Of",
+ description = onBehalfOfDescription
+ )
+
+ /**
+ * This builds the "On Behalf Of" description. The table is rendered from UserReference, which
+ * holds the policy of every column that stores a user id and which of them are applied yet, so
+ * the Glossary cannot drift from the code. The table is appended after stripMargin, because its
+ * rows start with "|" and stripMargin would eat that.
+ */
+ private def onBehalfOfDescription: String = {
+ import code.users.{AttributionPolicy, UserReference}
+ import code.users.UserReference.NotYetWired
+
+ def policyText(policy: AttributionPolicy): String = policy match {
+ case AttributionPolicy.UseAuthenticatedUserId => "the caller"
+ case AttributionPolicy.UseOnBehalfOfUserId => "the User acted for"
+ case AttributionPolicy.Reject => "refused"
+ }
+ def todayText(ref: UserReference): String = UserReference.notYetWired.get(ref) match {
+ case None => policyText(ref.policy)
+ case Some(NotYetWired.StoresCaller(_)) if ref.policy == AttributionPolicy.Reject => "the caller (not refused)"
+ case Some(NotYetWired.StoresCaller(_)) => "the caller"
+ case Some(NotYetWired.NotReachedByConsentUser(_)) => "not written by a consent user"
+ }
+ def cell(text: String): String = text.replace("|", "\\|")
+
+ val rows = UserReference.all.map { ref =>
+ s"| ${ref.name.takeWhile(_ != '_')} | ${ref.fields.map(field => s"`$field`").mkString(", ")} | " +
+ s"${policyText(ref.policy)} | ${todayText(ref)} | ${cell(ref.note)} |"
+ }
+ val delegated = UserReference.all.filter(ref => ref.policy != AttributionPolicy.UseAuthenticatedUserId)
+ val applied = delegated.count(UserReference.isWired)
+
+ s"""When a User grants a ${getGlossaryItemLink("Consent")} to an application, such as an AI agent, the application calls OBP under an identity of its own: a consent user, which OBP creates for that Consent. A call made that way has two Users behind it. `user_id` is the caller, the consent user that authenticated. `on_behalf_of_user_id` is the User the call was made for, the one who granted the Consent. When a User calls OBP for themselves, the two are the same User.
+ |
+ |OBP finds the User acted for itself, from the Consent the caller authenticated with. It never takes it from a request body, header or query parameter, so a caller cannot name somebody else to act for.
+ |
+ |This matters whenever OBP stores a user id. A consent user belongs to its Consent, so a row stored against it, such as an account holder, belongs to an identity that ends with the Consent, and the User who asked for it can no longer see it. So every database column that stores a user id has a policy, which says whose id to store when a consent user makes the call:
+ |
+ |* **the caller**: store the consent user's own id. This suits audit records of who acted, and the rows that carry a Consent's own permissions, which are meant to end with it.
+ |* **the User acted for**: store the id of the User who granted the Consent, so the row outlives the Consent. A few tables keep both ids, for example a counterparty or a transaction request, where who made the call is worth keeping too; the Note column says so.
+ |* **refused**: a consent user must not do this at all, because what it would create (a Consent, a Consumer or a token) outlives the Consent and would let the delegation extend itself.
+ |
+ |A request that explicitly names a consent user as the target User, for example in a `user_id` field or a `USER_ID` path segment, is answered with 400 `InvalidUserId`.
+ |
+ |Where a v7.0.0 response publishes both ids, it names them `user_id` and `on_behalf_of_user_id`, as in the record metadata of a ${getGlossaryItemLink("Dynamic-Entities")} record.
+ |
+ |**Every column that stores a user id**
+ |
+ |Policy is whose id the column should hold when a consent user makes the call. Today is whose id it holds on this instance now. Of the ${delegated.size} columns whose policy is "the User acted for" or "refused", ${applied} apply it today. "Not written by a consent user" means no API call writes that column with a consent user's id, because the endpoint refuses the consent user first or no endpoint writes the table.
+ |
+ |""".stripMargin +
+ (List("| Record | Column | Policy | Today | Note |", "|---|---|---|---|---|") ++ rows).mkString("\n") + "\n"
+ }
+
glossaryItems += GlossaryItem(
title = "Authentication: Consent OBP Flow Example",
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 b057402e0a..e2194dbdf8 100644
--- a/obp-api/src/main/scala/code/api/util/NewStyle.scala
+++ b/obp-api/src/main/scala/code/api/util/NewStyle.scala
@@ -5103,7 +5103,8 @@ object NewStyle extends MdcLoggable{
callContext: Option[CallContext]
): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = {
Connector.connector.vend.getMarketOrder(bankId, accountId, orderId, callContext) map {
- i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2)
+ // 404: a missing order and another account's order look the same
+ i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound", 404), i._2)
}
}
@@ -5114,7 +5115,8 @@ object NewStyle extends MdcLoggable{
callContext: Option[CallContext]
): OBPReturnType[com.openbankproject.commons.model.MarketOrder] = {
Connector.connector.vend.cancelMarketOrder(bankId, accountId, orderId, callContext) map {
- i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound"), i._2)
+ // 404: a missing order and another account's order look the same
+ i => (unboxFullOrFail(i._1, callContext, s"$OrderNotFound", 404), i._2)
}
}
@@ -5141,7 +5143,8 @@ object NewStyle extends MdcLoggable{
callContext: Option[CallContext]
): OBPReturnType[com.openbankproject.commons.model.MarketTrade] = {
Connector.connector.vend.getMarketTrade(bankId, accountId, tradeId, callContext) map {
- i => (unboxFullOrFail(i._1, callContext, s"$TradeNotFound"), i._2)
+ // 404: a missing trade and another account's trade look the same
+ i => (unboxFullOrFail(i._1, callContext, s"$TradeNotFound", 404), i._2)
}
}
diff --git a/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala b/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala
index 304a5456e4..f16df07853 100644
--- a/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala
+++ b/obp-api/src/main/scala/code/api/v1_2_1/Http4s121.scala
@@ -673,6 +673,13 @@ object Http4s121 {
s"${CreateCustomViewError} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views",
cc = Some(cc)
)(anyViewContainsPermission)
+ // the view may only get actions the caller already has on one of their views of this account, else they grant themselves more (as v5.1.0 does)
+ callerActions = Views.views.vend.permission(BankIdAccountId(account.bankId, account.accountId), user)
+ .map(_.views.flatMap(_.allowed_actions)).getOrElse(Nil).toSet
+ _ <- Helper.booleanToFuture(
+ s"$SourceViewHasLessPermission Current permissions of your views ($callerActions), target view permissions (${updateJsonV121.allowed_actions})",
+ failCode = 403, cc = Some(cc)
+ )(updateJsonV121.allowed_actions.toSet.subsetOf(callerActions))
updatedView <- Future(Views.views.vend.updateCustomView(BankIdAccountId(account.bankId, account.accountId), ViewId(viewId), updateViewJson)) map {
unboxFullOrFail(_, Some(cc), CreateCustomViewError)
}
@@ -694,7 +701,8 @@ object Http4s121 {
|of a view is not editable (it is only set when a view is created)""",
updateViewJsonV121,
viewJSONV121,
- List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, UnknownError, "user does not have owner access"),
+ // SourceViewHasLessPermission: the body asks for actions none of the caller's views on the account has
+ List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, ViewNotFound, SourceViewHasLessPermission, UnknownError, "user does not have owner access"),
List(apiTagAccount, apiTagView, apiTagOldStyle),
http4sPartialFunction = Some(updateViewForBankAccount)
)
diff --git a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
index f2aebf16e3..c88bca16c6 100644
--- a/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
+++ b/obp-api/src/main/scala/code/api/v1_4_0/Http4s140.scala
@@ -168,7 +168,11 @@ object Http4s140 {
case req @ POST -> `prefixPath` / "banks" / _ / "customer" / customerId / "messages" =>
EndpointHelpers.withUserAndBankAndBodyCreated[JSONFactory1_4_0.AddCustomerMessageJson, SuccessMessage](req) { (_, bank, body, cc) =>
for {
- (_, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
+ (customer, cc2) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
+ // the customer must be at the bank in the path, else a caller at one bank messages another bank's customers
+ _ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
+ customer.bankId == bank.bankId.value
+ }
(ucl, cc3) <- NewStyle.function.getUserCustomerLinkByCustomerId(customerId, cc2)
(targetUser, cc4) <- NewStyle.function.findByUserId(ucl.userId, cc3)
(_, _) <- NewStyle.function.createMessage(targetUser, bank.bankId, body.message, body.from_department, body.from_person, cc4)
@@ -185,9 +189,11 @@ object Http4s140 {
"Create a message for the customer specified by CUSTOMER_ID",
addCustomerMessageJson,
successMessage,
- List(AuthenticatedUserIsRequired, UnknownError),
+ // the role, customer and user-link errors this endpoint can return
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, CustomerNotFoundByCustomerId, UserCustomerLinksNotFoundForUser, UnknownError),
List(apiTagMessage, apiTagCustomer, apiTagPerson),
- None,
+ // the caller needs CanCreateCustomerMessage, as in v4.0.0, else any logged-in user writes to any customer's inbox
+ Some(List(canCreateCustomerMessage)),
http4sPartialFunction = Some(addCustomerMessage)
)
diff --git a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
index 72d534ffa6..b66eeaf8bc 100644
--- a/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
+++ b/obp-api/src/main/scala/code/api/v2_0_0/Http4s200.scala
@@ -497,6 +497,10 @@ object Http4s200 {
APIUtil.hasEntitlement(bank.bankId.value, user.userId, canGetSocialMediaHandles)
}
(customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
+ // the customer must be at the bank in the path, else a Role at one bank reads another bank's customers' handles
+ _ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
+ customer.bankId == bank.bankId.value
+ }
} yield {
val socialMedias = SocialMediaHandle.socialMediaHandleProvider.vend.getSocialMedias(customer.number)
createSocialMediasJSON(socialMedias)
@@ -644,10 +648,15 @@ object Http4s200 {
_ <- code.util.Helper.booleanToFuture(UserHasMissingRoles + canAddSocialMediaHandle, failCode = 403, cc = Some(cc)) {
APIUtil.hasEntitlement(bank.bankId.value, user.userId, canAddSocialMediaHandle)
}
- (_, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
+ (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerId, Some(cc))
+ // the customer must be at the bank in the path, else the Role at one bank writes to any bank's customer
+ _ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
+ customer.bankId == bank.bankId.value
+ }
_ <- code.util.Helper.booleanToFuture("Server error: could not add", cc = Some(cc)) {
SocialMediaHandle.socialMediaHandleProvider.vend.addSocialMedias(
- body.customer_number, body.`type`, body.handle,
+ // stored against the number of the customer in the path, not the body's customer_number, so it cannot target another customer
+ customer.number, body.`type`, body.handle,
body.date_added, body.date_activated)
}
} yield SuccessMessage("Success")
diff --git a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala
index 2e77cc86fd..4c9fa84db8 100644
--- a/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala
+++ b/obp-api/src/main/scala/code/api/v2_1_0/Http4s210.scala
@@ -492,6 +492,9 @@ object Http4s210 {
InvalidTransactionRequestChallengeId,
AllowedAttemptsUsedUp,
TransactionDisabled,
+ InvalidTransactionRequestId, // the request does not pay from the account in the URL
+ MakerCheckerSameUser, // the maker answers their own challenge on a view without the bypass
+ MakerCheckerUnknownMaker, // maker-checker is required but the request has no maker recorded
UnknownError
), List(apiTagTransactionRequest, apiTagPSD2PIS, apiTagPsd2), None,
http4sPartialFunction = Some(answerTransactionRequestChallenge))
@@ -521,9 +524,19 @@ object Http4s210 {
}
_ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest(viewId, account, user, Some(cc))
(existingTransactionRequest, _) <- NewStyle.function.getTransactionRequestImpl(transReqId, Some(cc))
+ // The request is found by its id alone, but the caller was authorised only on the account in the
+ // URL, so it must pay from that account, else it is treated as not found (404).
+ _ <- code.util.Helper.booleanToFuture(s"$InvalidTransactionRequestId Current TRANSACTION_REQUEST_ID(${transReqId.value})", 404, cc = Some(cc)) {
+ existingTransactionRequest.from.bank_id == fromAccount.bankId.value &&
+ existingTransactionRequest.from.account_id == fromAccount.accountId.value
+ }
_ <- code.util.Helper.booleanToFuture(TransactionRequestStatusNotInitiated, cc = Some(cc)) {
existingTransactionRequest.status.equals("INITIATED")
}
+ // Enforce maker-checker as v4.0.0 does, so the maker cannot answer their own challenge on a view
+ // without can_bypass_maker_checker_separation by switching to this version in the URL.
+ _ <- NewStyle.function.checkMakerCheckerForTransactionRequest(
+ fromAccount.bankId, fromAccount.accountId, viewId, transReqId, challengeAnswerJson.id, user.userId, Some(cc))
existingType = existingTransactionRequest.`type`
_ <- code.util.Helper.booleanToFuture(
s"${TransactionRequestTypeHasChanged} It should be: '$existingType', but current value ($transactionRequestTypeStr)",
@@ -878,6 +891,14 @@ object Http4s210 {
_ <- code.util.Helper.booleanToFuture(InsufficientAuthorisationToCreateTransactionType, failCode = 400, cc = Some(cc)) {
APIUtil.hasEntitlement(bank.bankId.value, user.userId, canCreateTransactionType)
}
+ // the Role is checked at BANK_ID, so the body may only write to that bank, else it creates types at any bank
+ _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue BANK_ID has to be the same in the URL and Body", failCode = 400, cc = Some(cc)) {
+ body.bank_id == bank.bankId.value
+ }
+ // an existing type with this id is updated in place, so it must be at BANK_ID, else another bank's type is rewritten or moved
+ _ <- code.util.Helper.booleanToFuture(s"$InvalidJsonValue id is already used by a Transaction Type that is not at this BANK_ID.", failCode = 400, cc = Some(cc)) {
+ TransactionType.TransactionTypeProvider.vend.getTransactionType(body.id).forall(_.bankId == bank.bankId)
+ }
returnTransactionType <- Future {
TransactionType.TransactionTypeProvider.vend.createOrUpdateTransactionType(body) match {
case Full(t) => t
@@ -907,8 +928,9 @@ object Http4s210 {
|
|${userAuthenticationMessage(true)}""".stripMargin,
transactionTypeJsonV200, transactionType,
+ // InvalidJsonValue: bank_id not the BANK_ID, or id used by a type at another bank
List(AuthenticatedUserIsRequired, BankNotFound, InvalidJsonFormat,
- InsufficientAuthorisationToCreateTransactionType, UnknownError),
+ InsufficientAuthorisationToCreateTransactionType, InvalidJsonValue, UnknownError),
List(apiTagBank),
None,
http4sPartialFunction = Some(createTransactionType))
diff --git a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
index 6c8d85756f..318b4eadec 100644
--- a/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
+++ b/obp-api/src/main/scala/code/api/v2_2_0/Http4s220.scala
@@ -245,7 +245,8 @@ object Http4s220 {
|The json sent is the same as during view creation (above), with one difference: the 'name' field
|of a view is not editable (it is only set when a view is created)""",
updateViewJsonV121, viewJSONV220,
- List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError),
+ // SourceViewHasLessPermission: the body asks for actions none of the caller's views on the account has
+ List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, SourceViewHasLessPermission, UnknownError),
List(apiTagAccount, apiTagView, apiTagOldStyle), None,
http4sPartialFunction = Some(updateViewForBankAccount))
@@ -265,6 +266,11 @@ object Http4s220 {
_ <- code.util.Helper.booleanToFuture(
s"${CreateCustomViewError} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views",
cc = Some(cc)) { anyCanUpdate }
+ // the view may only get actions the caller already has on one of their views of this account, else they grant themselves more (as v5.1.0 does)
+ callerActions = permission.views.flatMap(_.allowed_actions).toSet
+ _ <- code.util.Helper.booleanToFuture(
+ s"$SourceViewHasLessPermission Current permissions of your views ($callerActions), target view permissions (${updateBodyJson.allowed_actions})",
+ failCode = 403, cc = Some(cc)) { updateBodyJson.allowed_actions.toSet.subsetOf(callerActions) }
updateViewJson = UpdateViewJSON(
description = updateBodyJson.description,
metadata_view = view.metadataView,
@@ -696,8 +702,14 @@ object Http4s220 {
(existingAccount, _) <- Connector.connector.vend.checkBankAccountExists(bank.bankId, accountId, Some(cc))
_ <- code.util.Helper.booleanToFuture(AccountIdAlreadyExists, failCode = 409, cc = Some(cc)) { existingAccount.isEmpty }
loggedInUserId = user.userId
- userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else loggedInUserId
+ // Implicit owner resolves to the HUMAN: under a Consent the caller is the
+ // per-consent shadow, and an account held by it strands when the consent dies.
+ userIdAccountOwner = if (body.user_id.nonEmpty) body.user_id else cc.onBehalfOfUserId
(postedOrLoggedInUser, _) <- NewStyle.function.findByUserId(userIdAccountOwner, Some(cc))
+ // Explicit target: a consent user named in user_id is refused, so no account is held by an agent identity.
+ _ <- code.util.Helper.booleanToFuture(
+ s"$InvalidUserId user_id names a consent user (an agent identity minted by a Consent). Accounts are held by humans - use the granting user's USER_ID.",
+ failCode = 400, cc = Some(cc))(!postedOrLoggedInUser.isConsentUser)
_ <- if (userIdAccountOwner == loggedInUserId) Future.successful(Full(()))
else code.util.Helper.booleanToFuture(
s"$UserHasMissingRoles $canCreateAccount", failCode = 403, cc = Some(cc)) {
diff --git a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala
index 35ff39b85f..7573912324 100644
--- a/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala
+++ b/obp-api/src/main/scala/code/api/v3_0_0/Http4s300.scala
@@ -262,7 +262,8 @@ object Http4s300 {
|of a view is not editable (it is only set when a view is created)""",
updateViewJsonV300,
viewJsonV300,
- List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, UnknownError),
+ // SourceViewHasLessPermission: the body asks for actions none of the caller's views on the account has
+ List(InvalidJsonFormat, AuthenticatedUserIsRequired, BankAccountNotFound, SourceViewHasLessPermission, UnknownError),
List(apiTagView, apiTagAccount),
None,
http4sPartialFunction = Some(updateViewForBankAccount)
@@ -289,6 +290,11 @@ object Http4s300 {
_ <- code.util.Helper.booleanToFuture(
s"${ViewDoesNotPermitAccess} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on any your views",
cc = Some(cc)) { anyCanUpdate }
+ // the view may only get actions the caller already has on one of their views of this account, else they grant themselves more (as v5.1.0 does)
+ callerActions = permission.views.flatMap(_.allowed_actions).toSet
+ _ <- code.util.Helper.booleanToFuture(
+ s"$SourceViewHasLessPermission Current permissions of your views ($callerActions), target view permissions (${updateBodyJson.allowed_actions})",
+ failCode = 403, cc = Some(cc)) { updateBodyJson.allowed_actions.toSet.subsetOf(callerActions) }
(updatedView, _) <- ViewNewStyle.updateCustomView(BankIdAccountId(account.bankId, account.accountId), viewId, updateBodyJson.toUpdateViewJson, Some(cc))
} yield JSONFactory300.createViewJSON(updatedView)
}
@@ -623,7 +629,8 @@ object Http4s300 {
}
moderatedTransactionsWithAttributes <- Future.sequence(
transactions.map(transaction =>
- NewStyle.function.getTransactionAttributes(account.bankId, transaction.id, Some(cc))
+ // only the attributes whose Attribute Definition lists this view, so a limited view does not see attributes meant for other views
+ NewStyle.function.getModeratedTransactionAttributes(account.bankId, transaction.id, view.viewId, Some(cc))
.map(attributes => ModeratedTransactionWithAttributes(transaction, attributes._1))
)
)
@@ -677,7 +684,8 @@ object Http4s300 {
}
moderatedTransactionsCoreWithAttributes <- Future.sequence(
transactionsCore.map(transaction =>
- NewStyle.function.getTransactionAttributes(account.bankId, transaction.id, Some(cc))
+ // only the attributes whose Attribute Definition lists the owner view used here, as for the other transaction endpoints
+ NewStyle.function.getModeratedTransactionAttributes(account.bankId, transaction.id, view.viewId, Some(cc))
.map(attributes => ModeratedTransactionCoreWithAttributes(transaction, attributes._1))
)
)
@@ -721,7 +729,8 @@ object Http4s300 {
}
moderatedTransactionsWithAttributes <- Future.sequence(
transactions.map(transaction =>
- NewStyle.function.getTransactionAttributes(account.bankId, transaction.id, Some(cc))
+ // only the attributes whose Attribute Definition lists this view, so a limited view does not see attributes meant for other views
+ NewStyle.function.getModeratedTransactionAttributes(account.bankId, transaction.id, view.viewId, Some(cc))
.map(attributes => ModeratedTransactionWithAttributes(transaction, attributes._1))
)
)
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 c048078829..43b5f639ba 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
@@ -851,7 +851,13 @@ object Http4s310 {
EndpointHelpers.withUserAndBank(req) { (user, bank, cc) =>
for {
_ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canGetTaxResidence, Some(cc))
- (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
+ (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
+ // The customer must be at the bank in the path, where the Role is held, else the Role at one bank
+ // would let the caller read tax residences of customers at any bank
+ // 404, as for a customer that does not exist, so nothing is told about other banks' customers
+ _ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
+ customer.bankId == bank.bankId.value
+ }
(taxResidences, _) <- NewStyle.function.getTaxResidences(customerIdStr, Some(cc))
} yield JSONFactory310.createTaxResidences(taxResidences)
}
@@ -871,7 +877,8 @@ object Http4s310 {
|""",
EmptyBody,
taxResidencesJsonV310,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ // CustomerNotFoundByCustomerId: the customer is not found, or is at another bank than the one in the path
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, CustomerNotFoundByCustomerId, UnknownError),
List(apiTagCustomer, apiTagKyc),
Some(List(canGetTaxResidence)),
http4sPartialFunction = Some(getTaxResidence)
@@ -924,7 +931,13 @@ object Http4s310 {
EndpointHelpers.withUserAndBank(req) { (user, bank, cc) =>
for {
_ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canGetCustomerAddress, Some(cc))
- (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
+ (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
+ // The customer must be at the bank in the path, where the Role is held, else the Role at one bank
+ // would let the caller read addresses of customers at any bank
+ // 404, as for a customer that does not exist, so nothing is told about other banks' customers
+ _ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
+ customer.bankId == bank.bankId.value
+ }
(addresses, _) <- NewStyle.function.getCustomerAddress(customerIdStr, Some(cc))
} yield JSONFactory310.createAddresses(addresses)
}
@@ -944,7 +957,8 @@ object Http4s310 {
|""",
EmptyBody,
customerAddressesJsonV310,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ // CustomerNotFoundByCustomerId: the customer is not found, or is at another bank than the one in the path
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, CustomerNotFoundByCustomerId, UnknownError),
List(apiTagCustomer, apiTagKyc),
Some(List(canGetCustomerAddress)),
http4sPartialFunction = Some(getCustomerAddresses)
@@ -953,12 +967,18 @@ object Http4s310 {
// ─── getProductAttribute ─────────────────────────────────────────────────
val getProductAttribute: Http4sRoute = Http4sRoute {
- case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / _ / "attributes" / productAttributeIdStr =>
+ // productCodeStr is read so the attribute can be checked against the product in the path
+ case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "attributes" / productAttributeIdStr =>
EndpointHelpers.withUser(req) { (user, cc) =>
for {
_ <- NewStyle.function.hasEntitlement(bankIdStr, user.userId, canGetProductAttribute, Some(cc))
(_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc))
(productAttribute, _) <- NewStyle.function.getProductAttributeById(productAttributeIdStr, Some(cc))
+ // the attribute must belong to the bank and product in the path, else a role at one bank reads any bank's attribute
+ _ <- code.util.Helper.booleanToFuture(ProductAttributeNotFound, 404, Some(cc)) {
+ productAttribute.bankId.value == bankIdStr &&
+ productAttribute.productCode.value == productCodeStr
+ }
} yield createProductAttributeJson(productAttribute)
}
}
@@ -980,7 +1000,8 @@ object Http4s310 {
|""",
EmptyBody,
productAttributeResponseJson,
- List(UserHasMissingRoles, UnknownError),
+ // ProductAttributeNotFound: the attribute is not at the bank and product in the path
+ List(UserHasMissingRoles, ProductAttributeNotFound, UnknownError),
List(apiTagProduct, apiTagProductAttribute, apiTagAttribute),
Some(List(canGetProductAttribute)),
http4sPartialFunction = Some(getProductAttribute)
@@ -1024,13 +1045,22 @@ object Http4s310 {
val getAccountApplication: Http4sRoute = Http4sRoute {
case req @ GET -> `prefixPath` / "banks" / _ / "account-applications" / accountApplicationIdStr =>
- EndpointHelpers.withUserAndBank(req) { (_, _, cc) =>
+ EndpointHelpers.withUserAndBank(req) { (loggedInUser, bank, cc) =>
for {
(accountApplication, _) <- NewStyle.function.getAccountApplicationById(accountApplicationIdStr, Some(cc))
+ // only the applicant, or a holder of canGetAccountApplications (the role the list endpoint requires), may read an application
+ _ <- if (accountApplication.userId == loggedInUser.userId) Future.successful(Full(()))
+ else code.util.Helper.booleanToFuture(s"$UserHasMissingRoles$canGetAccountApplications", failCode = 403, cc = Some(cc)) {
+ APIUtil.hasEntitlement("", loggedInUser.userId, canGetAccountApplications)
+ }
userId = Option(accountApplication.userId)
customerId = Option(accountApplication.customerId)
user <- unboxOptionOBPReturnType(userId.map(NewStyle.function.findByUserId(_, Some(cc))))
customer <- unboxOptionOBPReturnType(customerId.map(NewStyle.function.getCustomerByCustomerId(_, Some(cc))))
+ // an application has no bank of its own: its customer must be at the bank in the path, else answer as if it did not exist
+ _ <- code.util.Helper.booleanToFuture(AccountApplicationNotFound, 404, Some(cc)) {
+ customer.forall(_.bankId == bank.bankId.value)
+ }
} yield createAccountApplicationJson(accountApplication, user, customer)
}
}
@@ -1049,7 +1079,8 @@ object Http4s310 {
|""",
EmptyBody,
accountApplicationResponseJson,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ // AccountApplicationNotFound: the application is not found, or its customer is at another bank than the one in the path
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, AccountApplicationNotFound, UnknownError),
List(apiTagAccountApplication, apiTagAccount),
None,
http4sPartialFunction = Some(getAccountApplication)
@@ -1454,8 +1485,9 @@ object Http4s310 {
TransactionId(transactionIdStr), view, Full(user), Some(cc)) map {
unboxFullOrFail(_, Some(cc), GetTransactionsException)
}
- (transactionAttributes, _) <- NewStyle.function.getTransactionAttributes(
- account.bankId, TransactionId(transactionIdStr), Some(cc))
+ // only the attributes whose Attribute Definition lists this view, so a limited view does not see attributes meant for other views
+ (transactionAttributes, _) <- NewStyle.function.getModeratedTransactionAttributes(
+ account.bankId, TransactionId(transactionIdStr), view.viewId, Some(cc))
} yield JSONFactory300.createTransactionJSON(moderatedTransaction, transactionAttributes)
}
}
@@ -1998,7 +2030,19 @@ object Http4s310 {
EndpointHelpers.withUserAndBank(req) { (user, bank, cc) =>
for {
_ <- NewStyle.function.hasEntitlement(bank.bankId.value, user.userId, canDeleteCustomerAddress, Some(cc))
- (_, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
+ // Keep the customer: its bank is checked next
+ (customer, _) <- NewStyle.function.getCustomerByCustomerId(customerIdStr, Some(cc))
+ // The customer must be at the bank in the path, where the Role is held, else the Role at one bank
+ // would let the caller delete addresses of customers at any bank
+ // 404, as for a customer that does not exist, so nothing is told about other banks' customers
+ _ <- code.util.Helper.booleanToFuture(CustomerNotFoundByCustomerId, 404, Some(cc)) {
+ customer.bankId == bank.bankId.value
+ }
+ // The address must be one of this customer's, else any address id could be deleted
+ (addresses, _) <- NewStyle.function.getCustomerAddress(customerIdStr, Some(cc))
+ _ <- code.util.Helper.booleanToFuture(CustomerAddressNotFound, 404, Some(cc)) {
+ addresses.exists(_.customerAddressId == customerAddressIdStr)
+ }
(deleted, _) <- NewStyle.function.deleteCustomerAddress(customerAddressIdStr, Some(cc))
} yield Full(deleted)
}
@@ -2018,7 +2062,10 @@ object Http4s310 {
|""",
EmptyBody,
EmptyBody,
- List(AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError),
+ // CustomerNotFoundByCustomerId and CustomerAddressNotFound (404): the customer must be at the bank in the path,
+ // and the address must be the customer's
+ List(AuthenticatedUserIsRequired, UserHasMissingRoles, CustomerNotFoundByCustomerId, CustomerAddressNotFound,
+ UnknownError),
List(apiTagCustomer, apiTagKyc),
Some(List(canDeleteCustomerAddress)),
http4sPartialFunction = Some(deleteCustomerAddress)
@@ -2339,7 +2386,8 @@ object Http4s310 {
postedData.line_1, postedData.line_2, postedData.line_3,
postedData.city, postedData.county, postedData.state,
postedData.postcode, postedData.country_code,
- postedData.state,
+ // The status parameter takes the posted status, so the address keeps the status the caller sent
+ postedData.status,
postedData.tags.mkString(","),
Some(cc))
} yield JSONFactory310.createAddress(address)
@@ -2393,7 +2441,8 @@ object Http4s310 {
postedData.line_1, postedData.line_2, postedData.line_3,
postedData.city, postedData.county, postedData.state,
postedData.postcode, postedData.country_code,
- postedData.state,
+ // The status argument takes the posted status, so the address keeps the status sent, not the state
+ postedData.status,
postedData.tags.mkString(","),
Some(cc))
} yield JSONFactory310.createAddress(address)
diff --git a/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala b/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala
index 10beb1b2d2..0b57a20788 100644
--- a/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala
+++ b/obp-api/src/main/scala/code/api/v3_1_0/JSONFactory3.1.0.scala
@@ -1190,14 +1190,15 @@ object JSONFactory310{
}
def createProductTreeJson(productsList: List[Product], rootProductCode: String): ProductTreeJsonV310 = {
+ // each step searches only the products not yet placed in the tree, so a product that is its own (or a cyclic) parent ends the nesting instead of recursing forever
def getProductTree(list: List[Product], code: String): Option[ProductTreeJsonV310] = {
- productsList.filter(_.code.value == code) match {
+ list.filter(_.code.value == code) match {
case x :: _ =>
Some(
ProductTreeJsonV310(
bank_id = x.bankId.toString,
code = x.code.value,
- parent_product = getProductTree(productsList, x.parentProductCode.value),
+ parent_product = getProductTree(list.filterNot(_.code.value == x.code.value), x.parentProductCode.value), // drop x, so it cannot be its own ancestor
name = x.name,
category = x.category,
family = x.family,
@@ -1217,7 +1218,7 @@ object JSONFactory310{
ProductTreeJsonV310(
bank_id = rootElement.bankId.toString,
code = rootElement.code.value,
- parent_product = getProductTree(productsList, rootElement.parentProductCode.value),
+ parent_product = getProductTree(productsList.filterNot(_.code.value == rootProductCode), rootElement.parentProductCode.value), // the root is not searched again, so it cannot be its own ancestor
name = rootElement.name,
category = rootElement.category,
family = rootElement.family,
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 493f5b7987..f32ccf454d 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
@@ -594,6 +594,10 @@ object Http4s400 {
val limit = req.uri.query.params.get("limit").map(Full(_)).getOrElse(net.liftweb.common.Empty)
val offset = req.uri.query.params.get("offset").map(Full(_)).getOrElse(net.liftweb.common.Empty)
for {
+ // with apiOptions.getAtmsIsPublic=false the ATM list needs a logged-in user (as Lift's authenticatedAccess did), else anonymous callers read it
+ _ <- if (!getAtmsIsPublic)
+ code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined }
+ else Future.unit
_ <- code.util.Helper.booleanToFuture(
failMsg = s"$InvalidNumber limit:${limit.getOrElse("")}", cc = Some(cc)) {
limit match {
@@ -634,6 +638,7 @@ object Http4s400 {
EmptyBody,
atmsJsonV400,
List(
+ $AuthenticatedUserIsRequired, // 401 when apiOptions.getAtmsIsPublic=false and no user; dropped from the list while ATMs are public
$BankNotFound,
UnknownError
),
@@ -687,6 +692,10 @@ object Http4s400 {
case (k, vs) => vs.map(v => com.openbankproject.commons.dto.GetProductsParam(k, List(v)))
}
for {
+ // with apiOptions.getProductsIsPublic=false the product list needs a logged-in user (as Lift's authenticatedAccess did), else anonymous callers read it
+ _ <- if (!getProductsIsPublic)
+ code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined }
+ else Future.unit
(_, _) <- NewStyle.function.getBank(BankId(bankIdStr), Some(cc))
(products, _) <- NewStyle.function.getProducts(BankId(bankIdStr), params, Some(cc))
} yield JSONFactory400.createProductsJson(products)
@@ -734,6 +743,10 @@ object Http4s400 {
case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr =>
EndpointHelpers.executeAndRespond(req) { cc =>
for {
+ // with apiOptions.getProductsIsPublic=false the product needs a logged-in user (as Lift's authenticatedAccess did), else anonymous callers read it
+ _ <- if (!getProductsIsPublic)
+ code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined }
+ else Future.unit
(product, _) <- NewStyle.function.getProduct(BankId(bankIdStr), ProductCode(productCodeStr), Some(cc))
(productAttributes, _) <- NewStyle.function.getProductAttributesByBankAndCode(
BankId(bankIdStr), ProductCode(productCodeStr), Some(cc))
@@ -5245,6 +5258,20 @@ object Http4s400 {
cc = Some(cc)) {
APIUtil.canRevokeAccessToAllViews(bankId, accountId, loggedInUser, Some(cc))
}
+ // the caller may only grant themselves views their held views can grant, else a limited view could be swapped for owner
+ _ <- code.util.Helper.booleanToFuture(
+ UserLacksPermissionCanGrantAccessToViewForTargetAccount +
+ s"Current ViewIds(${postJson.views.mkString}) and current UserId(${loggedInUser.userId})",
+ failCode = 403, cc = Some(cc)) {
+ APIUtil.canGrantAccessToMultipleViews(bankId, accountId, postJson.views.map(ViewId(_)), loggedInUser, Some(cc))
+ }
+ // every view must exist before the revoke runs, else an unknown view id removes all the caller's access and then fails
+ _ <- code.util.Helper.booleanToFuture(
+ s"$ViewNotFound Current ViewIds(${postJson.views.mkString(",")})", failCode = 404, cc = Some(cc)) {
+ postJson.views.forall(viewIdStr =>
+ Views.views.vend.customView(ViewId(viewIdStr), BankIdAccountId(bankId, accountId))
+ .or(Views.views.vend.systemView(ViewId(viewIdStr))).isDefined)
+ }
_ <- Future(
Views.views.vend.revokeAccountAccessByUser(bankId, accountId, loggedInUser, Some(cc))
) map { box => unboxFullOrFail(box, Some(cc), "Cannot revoke") }
@@ -6428,6 +6455,10 @@ object Http4s400 {
case req @ GET -> `prefixPath` / "banks" / bankIdStr / "products" / productCodeStr / "fees" =>
EndpointHelpers.executeAndRespond(req) { cc =>
for {
+ // with apiOptions.getProductsIsPublic=false the fees need a logged-in user (as Lift's authenticatedAccess did), else anonymous callers read them
+ _ <- if (!getProductsIsPublic)
+ code.util.Helper.booleanToFuture(AuthenticatedUserIsRequired, failCode = 401, cc = Some(cc)) { cc.user.isDefined }
+ else Future.unit
(productFees, _) <- NewStyle.function.getProductFeesFromProvider(
BankId(bankIdStr), ProductCode(productCodeStr), Some(cc))
} yield JSONFactory400.createProductFeesJson(productFees)
@@ -6511,7 +6542,9 @@ object Http4s400 {
(ucls, _) <- code.api.util.newstyle.UserCustomerLinkNewStyle
.getUserCustomerLinks(link.customerId, Some(cc))
(users, _) <- NewStyle.function.getUsersByUserIds(ucls.map(_.userId), Some(cc))
- (attributes, _) <- NewStyle.function.getUserAttributesByUsers(ucls.map(_.userId), Some(cc))
+ (allAttributes, _) <- NewStyle.function.getUserAttributesByUsers(ucls.map(_.userId), Some(cc))
+ // personal attributes are only for their owner, so drop other users' personal ones, else co-users of the customer read them
+ attributes = allAttributes.filter(a => !a.isPersonal || a.userId == user.userId)
} yield (customer, users, attributes)
})
} yield CorrelatedEntities(
@@ -6642,12 +6675,13 @@ object Http4s400 {
"Get Product Fees",
s"""Get Product Fees
|
- |${userAuthenticationMessage(false)}
+ |${userAuthenticationMessage(!getProductsIsPublic)}
|
|""",
EmptyBody,
productFeesResponseJsonV400,
- List($BankNotFound, UnknownError),
+ // the description says login is required when apiOptions.getProductsIsPublic=false; AuthenticatedUserIsRequired is then returned to anonymous callers
+ List(AuthenticatedUserIsRequired, $BankNotFound, UnknownError),
List(apiTagProduct),
None,
http4sPartialFunction = Some(getProductFees)
diff --git a/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala b/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala
index d58e361dcd..77acd47eb3 100644
--- a/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala
+++ b/obp-api/src/main/scala/code/api/v4_0_0/JSONFactory4.0.0.scala
@@ -1750,8 +1750,8 @@ object JSONFactory400 {
country = i.country,
attributes = List(
AttributeJsonV400("country_iso", i.countryIso),
- AttributeJsonV400("sepa_credit_transfer", i.sepaDirectDebit),
- AttributeJsonV400("sepa_direct_debit", i.sepaSddCore),
+ AttributeJsonV400("sepa_credit_transfer", i.sepaCreditTransfer), // each name reads its own field, else credit transfer showed the direct debit value
+ AttributeJsonV400("sepa_direct_debit", i.sepaDirectDebit), // and direct debit showed the SDD core value
AttributeJsonV400("sepa_sdd_core", i.sepaSddCore),
AttributeJsonV400("sepa_b2b", i.sepaB2b),
AttributeJsonV400("sepa_card_clearing", i.sepaCardClearing),
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 f30b73f74d..3bcff4c290 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
@@ -3115,7 +3115,8 @@ object Http4s510 {
(consumer, _) <- createConsumerNewStyle(
key = Some(Helpers.randomString(40).toLowerCase),
secret = Some(Helpers.randomString(40).toLowerCase),
- isActive = Some(postedJson.enabled),
+ // enabled only if asked for and consumers_enabled_by_default allows it, else any logged-in user gets a working key without an admin
+ isActive = Some(postedJson.enabled && APIUtil.getPropsAsBoolValue("consumers_enabled_by_default", defaultValue = false)),
name = Some(postedJson.app_name),
appType = Some(appType),
description = Some(postedJson.description),
@@ -3991,7 +3992,8 @@ object Http4s510 {
permissionsFromTarget.toSet.subsetOf(permissionsFromSource)
}
_ <- Helper.booleanToFuture(s"${ErrorMessages.ViewDoesNotPermitAccess} You need the `${CAN_UPDATE_CUSTOM_VIEW}` permission on VIEW_ID(${viewId.value})", cc = Some(cc)) {
- view.allowed_actions.exists(_ == CAN_CREATE_CUSTOM_VIEW)
+ // updating needs the update permission, so a view that may only create custom views cannot change them
+ view.allowed_actions.exists(_ == CAN_UPDATE_CUSTOM_VIEW)
}
(updatedView, _) <- ViewNewStyle.updateCustomView(BankIdAccountId(bankId, accountId), targetViewId, updateJson.toUpdateViewJson, Some(cc))
} yield JSONFactory510.createViewJson(updatedView)
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 54f3e82d98..59f809a825 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
@@ -862,7 +862,9 @@ object Http4s600 {
val params = req.uri.query.multiParams.toList.map { case (k, vs) => GetProductsParam(k, vs.toList) }
for {
(products, _) <- NewStyle.function.getProducts(bank.bankId, params, Some(cc))
- } yield JSONFactory600.createProductsJsonV600(products, Map.empty)
+ // one batch lookup of every listed product's tags, as Lift did, so the documented tags field is filled
+ tagsByCode <- Future(code.products.ProductTagsProvider.getTagsByProductCodes(bank.bankId, products.map(_.code.value)))
+ } yield JSONFactory600.createProductsJsonV600(products, tagsByCode)
}
}
@@ -5921,6 +5923,16 @@ object Http4s600 {
existing <- Future(code.group.GroupTrait.group.vend.getGroup(groupId))
.map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Group not found", 404))
_ <- groupRoleCheck(existing.bankId, user.userId, canDeleteGroupAtOneBank, canDeleteGroupAtAllBanks, cc)
+ // The Entitlements this Group granted, loaded while the Group still exists, so its members do not keep its Roles after the delete.
+ groupEntitlements <- Entitlement.entitlement.vend.getEntitlementsByGroupId(groupId)
+ .map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot get entitlements", 400))
+ // As in removeUserFromGroup: a Role another of the member's Groups also grants is kept and recorded against that Group, any other is revoked.
+ _ <- Future.sequence(groupEntitlements.map(e => Future {
+ code.group.GroupMemberships.otherGroupGranting(e.userId, e.bankId, e.roleName, groupId) match {
+ case Some(other) => Entitlement.entitlement.vend.setEntitlementGroupId(e.entitlementId, other.groupId)
+ case None => Entitlement.entitlement.vend.deleteEntitlement(Full(e))
+ }
+ }))
_ <- Future(code.group.GroupTrait.group.vend.deleteGroup(groupId))
.map(x => unboxFullOrFail(x, Some(cc), s"$UnknownError Cannot delete group", 400))
_ <- Future(code.group.GroupMemberships.removeMembershipsOfGroup(groupId))
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 ac584e36b0..941a1ae918 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
@@ -2445,6 +2445,25 @@ object Http4s700 {
// Validate bank and account
(_, callContext) <- NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(cc))
+ // Look up the trade; a missing one is refused below as not found
+ (tradeBox, _) <- BankConnector.connector.vend.getMarketTrade(BankId(bankId), AccountId(accountId), requestJson.trade_id, callContext)
+
+ // the trade belongs to the account in the path when its buy or sell order does (getMarketOrder only finds orders of that account)
+ tradeIsOfThisAccount <- tradeBox match {
+ case Full(trade) =>
+ Future.sequence(List(trade.buyOrderId, trade.sellOrderId).map(orderId =>
+ BankConnector.connector.vend.getMarketOrder(BankId(bankId), AccountId(accountId), orderId, callContext).map(_._1.isDefined)
+ )).map(_.contains(true))
+ case _ => Future.successful(false)
+ }
+
+ // the trade must exist and be of the account in the path, else anyone with a view on one account settles any trade
+ _ <- Helper.booleanToFuture(
+ failMsg = TradeNotFound,
+ failCode = 404,
+ cc = callContext
+ )(tradeIsOfThisAccount)
+
// Invoke connector
(settlement, callContext2) <- NewStyle.function.requestSettlement(
BankId(bankId),
@@ -2482,7 +2501,8 @@ object Http4s700 {
created_at = "2026-04-16T00:45:00Z",
completed_at = None
),
- List(InvalidJsonFormat, SettlementFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError),
+ // TradeNotFound: the trade_id is unknown or no side of the trade is an order of the account in the path
+ List(InvalidJsonFormat, TradeNotFound, SettlementFailed, $AuthenticatedUserIsRequired, $BankNotFound, $BankAccountNotFound, UnknownError),
apiTagTrading :: apiTagMarket :: Nil,
http4sPartialFunction = Some(requestSettlement)
)
diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
index 7d2f1383d5..29e1ad1436 100644
--- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnector.scala
@@ -2734,15 +2734,17 @@ object LocalMappedConnector extends Connector with MdcLoggable {
By(MappedProduct.mCode, productCode.value)
)
- def getProductTre(bankId : BankId, productCode : ProductCode): List[Product] = {
+ // visited holds the codes already in the tree, so a product that is its own (or a cyclic) parent ends the walk instead of recursing until the stack overflows
+ def getProductTre(bankId : BankId, productCode : ProductCode, visited: Set[String]): List[Product] = {
getProduct(bankId, productCode) match {
- case Full(p) if p.parentProductCode.value.nonEmpty => p :: getProductTre(p.bankId, p.parentProductCode)
+ case Full(p) if visited.contains(p.code.value) => List() // code already in the tree: a parent cycle, stop here
+ case Full(p) if p.parentProductCode.value.nonEmpty => p :: getProductTre(p.bankId, p.parentProductCode, visited + p.code.value)
case Full(p) => List(p)
case _ => List()
}
}
- Full(getProductTre(bankId : BankId, productCode : ProductCode))
+ Full(getProductTre(bankId : BankId, productCode : ProductCode, Set.empty[String]))
}.map(product => (product, callContext))
@@ -6398,7 +6400,8 @@ object LocalMappedConnector extends Connector with MdcLoggable {
orderId: String,
callContext: Option[CallContext]
): OBPReturnType[Box[MarketOrder]] = Future {
- val order = Option(marketOrders.get(orderId))
+ // the order must belong to the account in the path, else anyone with access to one account reads any order
+ val order = Option(marketOrders.get(orderId)).filter(_.accountId == accountId.value)
(Box(order), callContext)
}
@@ -6408,7 +6411,8 @@ object LocalMappedConnector extends Connector with MdcLoggable {
orderId: String,
callContext: Option[CallContext]
): OBPReturnType[Box[MarketOrder]] = Future {
- val order = Option(marketOrders.get(orderId))
+ // the order must belong to the account in the path, else anyone with access to one account cancels any order
+ val order = Option(marketOrders.get(orderId)).filter(_.accountId == accountId.value)
order match {
case Some(o) =>
@@ -6478,7 +6482,10 @@ object LocalMappedConnector extends Connector with MdcLoggable {
tradeId: String,
callContext: Option[CallContext]
): OBPReturnType[Box[MarketTrade]] = Future {
- val trade = Option(marketTrades.get(tradeId))
+ // the trade's buy or sell order must belong to the account in the path, else anyone with access to one account reads any trade
+ val trade = Option(marketTrades.get(tradeId)).filter(t =>
+ List(t.buyOrderId, t.sellOrderId).exists(orderId => Option(marketOrders.get(orderId)).exists(_.accountId == accountId.value))
+ )
(Box(trade), callContext)
}
diff --git a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
index 1524e3c9f1..1d220caa92 100644
--- a/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
+++ b/obp-api/src/main/scala/code/bankconnectors/LocalMappedConnectorInternal.scala
@@ -661,7 +661,14 @@ object LocalMappedConnectorInternal extends MdcLoggable {
json.extract[TransactionRequestBodyCardJsonV400]
}
// 1.1 get Card from card_number
- (cardFromCbs,callContext) <- NewStyle.function.getPhysicalCardByCardNumber(transactionRequestBodyCard.card.card_number, callContext)
+ (cardBox, callContext) <- Connector.connector.vend.getPhysicalCardByCardNumber(transactionRequestBodyCard.card.card_number, callContext)
+ // the caller must be allowed to pay from the card's account before any card field is compared, and an unknown
+ // card number gets the same 403, else any logged-in user learns which cards exist and guesses their details field by field
+ _ <- Helper.booleanToFuture(s"$InsufficientAuthorisationToCreateTransactionRequest Current ViewId(${viewId.value}), current UserId(${u.userId})", 403, callContext) {
+ cardBox.exists(card => APIUtil.checkAuthorisationToCreateTransactionRequest(viewId, BankIdAccountId(card.account.bankId, card.account.accountId), u, callContext).isDefined)
+ }
+ // the card was found just above
+ cardFromCbs = cardBox.openOrThrowException(InvalidCardNumber)
// 1.2 check card name/expire month. year.
calendar = Calendar.getInstance
diff --git a/obp-api/src/main/scala/code/chat/ChatEventBus.scala b/obp-api/src/main/scala/code/chat/ChatEventBus.scala
index 1542c4eec3..15ac9752d4 100644
--- a/obp-api/src/main/scala/code/chat/ChatEventBus.scala
+++ b/obp-api/src/main/scala/code/chat/ChatEventBus.scala
@@ -148,15 +148,11 @@ object ChatEventBus extends MdcLoggable {
}
subscriberThread = new Thread(() => {
- try {
- // Dedicated connection for the subscriber (not from the pool)
- subscriberJedis = Redis.newSubscriberConnection()
+ // Dedicated connection for the subscriber (not from the pool), reopened after a Redis disconnect
+ // so chat streams keep getting messages until stop()
+ Redis.runSubscriber("ChatEventBus", running, subscriberJedis = _) { jedis =>
logger.info(s"ChatEventBus says: Redis subscriber started, pattern-subscribing to ${CHANNEL_PREFIX}*")
- subscriberJedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*")
- } catch {
- case e: Throwable if running =>
- logger.error(s"ChatEventBus says: Redis subscriber thread died: ${e.getMessage}")
- case _: Throwable => // shutting down, ignore
+ jedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*")
}
}, "chat-event-bus-subscriber")
subscriberThread.setDaemon(true)
diff --git a/obp-api/src/main/scala/code/customeraddress/MappedCustomerAddressProvider.scala b/obp-api/src/main/scala/code/customeraddress/MappedCustomerAddressProvider.scala
index d2be3312fe..032909b565 100644
--- a/obp-api/src/main/scala/code/customeraddress/MappedCustomerAddressProvider.scala
+++ b/obp-api/src/main/scala/code/customeraddress/MappedCustomerAddressProvider.scala
@@ -156,7 +156,7 @@ class MappedCustomerAddress extends CustomerAddress with LongKeyedMapper[MappedC
override def state: String = mState.get
override def postcode: String = mPostCode.get
override def countryCode: String = mCountryCode.get
- override def status: String = mState.get
+ override def status: String = mStatus.get
override def tags: String = mTags.get
override def insertDate: Date = createdAt.get
diff --git a/obp-api/src/main/scala/code/logcache/LogCacheEventBus.scala b/obp-api/src/main/scala/code/logcache/LogCacheEventBus.scala
index 5f3bb6287d..a6b2c2955d 100644
--- a/obp-api/src/main/scala/code/logcache/LogCacheEventBus.scala
+++ b/obp-api/src/main/scala/code/logcache/LogCacheEventBus.scala
@@ -133,14 +133,10 @@ object LogCacheEventBus extends MdcLoggable {
}
subscriberThread = new Thread(() => {
- try {
- subscriberJedis = Redis.newSubscriberConnection()
+ // reopens the subscriber connection after a Redis disconnect, so log streams keep getting lines until stop()
+ Redis.runSubscriber("LogCacheEventBus", running, subscriberJedis = _) { jedis =>
logger.info(s"LogCacheEventBus says: Redis subscriber started, pattern-subscribing to ${CHANNEL_PREFIX}*")
- subscriberJedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*")
- } catch {
- case e: Throwable if running =>
- logger.error(s"LogCacheEventBus says: Redis subscriber thread died: ${e.getMessage}")
- case _: Throwable => // shutting down
+ jedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*")
}
}, "log-cache-event-bus-subscriber")
subscriberThread.setDaemon(true)
diff --git a/obp-api/src/main/scala/code/meetings/MappedMeetingProvider.scala b/obp-api/src/main/scala/code/meetings/MappedMeetingProvider.scala
index 7a8a165d63..069c54b1fc 100644
--- a/obp-api/src/main/scala/code/meetings/MappedMeetingProvider.scala
+++ b/obp-api/src/main/scala/code/meetings/MappedMeetingProvider.scala
@@ -42,22 +42,28 @@ import scala.collection.immutable.List
object MappedMeetingProvider extends MeetingProvider {
+ // The user takes part in the meeting as its customer or staff user, else it is none of their business
+ private def isParticipant(meeting: MappedMeeting, user: User): Boolean =
+ meeting.mCustomerUserId.get == user.userPrimaryKey.value || meeting.mStaffUserId.get == user.userPrimaryKey.value
+
override def getMeeting(bankId : BankId, user: User, meetingId : String): Box[Meeting] = {
// Return a Box so we can handle errors later.
MappedMeeting.find(
- // TODO Need to check permissions (user)
By(MappedMeeting.mBankId, bankId.toString),
By(MappedMeeting.mMeetingId, meetingId)
, OrderBy(MappedMeeting.mWhen, Descending))
+ // Only a meeting the user takes part in is found, so nobody reads other people's contact details or meeting keys
+ .filter(isParticipant(_, user))
}
override def getMeetings(bankId : BankId, user: User): Box[List[Meeting]] = {
// Return a Box so we can handle errors later.
tryo{MappedMeeting.findAll(By(
-// TODO Need to check permissions (user)
MappedMeeting.mBankId, bankId.toString),
- OrderBy(MappedMeeting.mWhen, Descending))}
+ OrderBy(MappedMeeting.mWhen, Descending))
+ // Only the meetings the user takes part in, so nobody lists other people's contact details or meeting keys
+ .filter(isParticipant(_, user))}
}
diff --git a/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala b/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala
index e31537ca6c..78a5a6c4bc 100644
--- a/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala
+++ b/obp-api/src/main/scala/code/metrics/ConnectorMetrics.scala
@@ -65,8 +65,9 @@ object ConnectorMetrics extends ConnectorMetricsProvider {
* https://github.com/OpenBankProject/scala-macros/blob/master/macros/src/main/scala/com/tesobe/CacheKeyFromArgumentsMacro.scala#L49
*/
var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString)
- CacheKeyFromArguments.buildCacheKey {
- Caching.memoizeSyncWithProvider(Some(cacheKey.toString()))(cachedAllConnectorMetrics.days){
+ CacheKeyFromArguments.buildCacheKey {
+ // the TTL is in seconds, as the prop name says, so newly written connector metrics show up after seconds, not days
+ Caching.memoizeSyncWithProvider(Some(cacheKey.toString()))(cachedAllConnectorMetrics.seconds){
val limit = queryParams.collect { case OBPLimit(value) => MaxRows[MappedConnectorMetric](value) }.headOption
val offset = queryParams.collect { case OBPOffset(value) => StartAt[MappedConnectorMetric](value) }.headOption
val fromDate = queryParams.collect { case OBPFromDate(date) => By_>=(MappedConnectorMetric.date, date) }.headOption
diff --git a/obp-api/src/main/scala/code/metricsstream/MetricsEventBus.scala b/obp-api/src/main/scala/code/metricsstream/MetricsEventBus.scala
index 0b83706302..5c57ac5f5c 100644
--- a/obp-api/src/main/scala/code/metricsstream/MetricsEventBus.scala
+++ b/obp-api/src/main/scala/code/metricsstream/MetricsEventBus.scala
@@ -128,14 +128,10 @@ object MetricsEventBus extends MdcLoggable {
}
subscriberThread = new Thread(() => {
- try {
- subscriberJedis = Redis.newSubscriberConnection()
+ // reopens the subscriber connection after a Redis disconnect, so metrics streams keep getting events until stop()
+ Redis.runSubscriber("MetricsEventBus", running, subscriberJedis = _) { jedis =>
logger.info(s"MetricsEventBus says: Redis subscriber started, subscribing to $ALL_CHANNEL")
- subscriberJedis.subscribe(pubSub, ALL_CHANNEL)
- } catch {
- case e: Throwable if running =>
- logger.error(s"MetricsEventBus says: Redis subscriber thread died: ${e.getMessage}")
- case _: Throwable => // shutting down
+ jedis.subscribe(pubSub, ALL_CHANNEL)
}
}, "metrics-event-bus-subscriber")
subscriberThread.setDaemon(true)
diff --git a/obp-api/src/main/scala/code/model/ModeratedBankingData.scala b/obp-api/src/main/scala/code/model/ModeratedBankingData.scala
index 4041da0803..0ab1794a19 100644
--- a/obp-api/src/main/scala/code/model/ModeratedBankingData.scala
+++ b/obp-api/src/main/scala/code/model/ModeratedBankingData.scala
@@ -144,7 +144,8 @@ class ModeratedTransactionMetadata(
u <- Box(user) ?~ { AuthenticatedUserIsRequired}
imageList <- Box(images) ?~ { s"$NoViewPermission can_delete_image." }
image <- Box(imageList.find(image => image.id_ == imageId)) ?~ {"Image with id " + imageId + "not found for this transaction"}
- deleteFunc <- if(image.postedBy == user || view.allowed_actions.exists(_ ==CAN_DELETE_IMAGE))
+ // the poster may delete their own image: compare user keys, as postedBy is a Box and user an Option, which never compare equal
+ deleteFunc <- if(image.postedBy.exists(poster => user.exists(_.userPrimaryKey == poster.userPrimaryKey)) || view.allowed_actions.exists(_ ==CAN_DELETE_IMAGE))
Box(deleteImage) ?~ "Deleting images not permitted for this view"
else
Failure("Deleting images not permitted for the current user")
@@ -158,7 +159,8 @@ class ModeratedTransactionMetadata(
u <- Box(user) ?~ { AuthenticatedUserIsRequired}
commentList <- Box(comments) ?~ { s"$NoViewPermission can_delete_comment." }
comment <- Box(commentList.find(comment => comment.id_ == commentId)) ?~ {"Comment with id "+commentId+" not found for this transaction"}
- deleteFunc <- if(comment.postedBy == user || view.allowed_actions.exists(_ ==CAN_DELETE_COMMENT))
+ // the poster may delete their own comment: compare user keys, as postedBy is a Box and user an Option, which never compare equal
+ deleteFunc <- if(comment.postedBy.exists(poster => user.exists(_.userPrimaryKey == poster.userPrimaryKey)) || view.allowed_actions.exists(_ ==CAN_DELETE_COMMENT))
Box(deleteComment) ?~ "Deleting comments not permitted for this view"
else
Failure("Deleting comments not permitted for the current user")
diff --git a/obp-api/src/main/scala/code/model/View.scala b/obp-api/src/main/scala/code/model/View.scala
index bbb44d32a2..d273ec5b3f 100644
--- a/obp-api/src/main/scala/code/model/View.scala
+++ b/obp-api/src/main/scala/code/model/View.scala
@@ -74,8 +74,10 @@ case class ViewExtended(val view: View) extends MdcLoggable {
Some(transaction.metadata.comments(view.viewId))
else None
val addCommentFunc= if(viewPermissions.exists(_ == CAN_ADD_COMMENT)) Some(transaction.metadata.addComment) else None
+ // a view that can add comments also offers delete, so the poster can delete their own comment;
+ // ModeratedTransactionMetadata.deleteComment still lets only the poster or a can_delete_comment view use it
val deleteCommentFunc =
- if(viewPermissions.exists(_ == CAN_DELETE_COMMENT))
+ if(viewPermissions.exists(_ == CAN_DELETE_COMMENT) || viewPermissions.exists(_ == CAN_ADD_COMMENT))
Some(transaction.metadata.deleteComment)
else
None
@@ -102,8 +104,10 @@ case class ViewExtended(val view: View) extends MdcLoggable {
if(viewPermissions.exists(_ == CAN_ADD_IMAGE)) Some(transaction.metadata.addImage)
else None
+ // a view that can add images also offers delete, so the poster can delete their own image;
+ // ModeratedTransactionMetadata.deleteImage still lets only the poster or a can_delete_image view use it
val deleteImageFunc =
- if(viewPermissions.exists(_ == CAN_DELETE_IMAGE)) Some(transaction.metadata.deleteImage)
+ if(viewPermissions.exists(_ == CAN_DELETE_IMAGE) || viewPermissions.exists(_ == CAN_ADD_IMAGE)) Some(transaction.metadata.deleteImage)
else None
val whereTag =
diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala b/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala
index 4b81fb2d6c..d52887f040 100644
--- a/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala
+++ b/obp-api/src/main/scala/code/routingscheme/RoutingScheme.scala
@@ -83,16 +83,20 @@ object MappedRoutingSchemeProvider extends RoutingSchemeProvider {
country.map(c => By(RoutingScheme.Country, c)).toList :::
category.map(c => By(RoutingScheme.Category, c)).toList :::
status.map(s => By(RoutingScheme.Status, s)).toList
- // Count BEFORE applying limit/offset for total
- val total: Int = RoutingScheme.count(baseQuery: _*).toInt
- val rows: List[RoutingScheme] =
- RoutingScheme.findAll((baseQuery :+ OrderBy(RoutingScheme.Scheme, Ascending) :+ StartAt[RoutingScheme](offset) :+ MaxRows[RoutingScheme](limit)): _*)
- // Rail is a free-text tag list (CSV); filter in-memory after the SQL pass.
- val filtered = rail match {
- case Some(r) => rows.filter(_.downstreamRails.contains(r))
- case None => rows
+ rail match {
+ // Rail is a free-text tag list (CSV) so it is filtered in memory; filter all SQL matches
+ // first, then count and page, so pages are not cut short and total counts only this rail.
+ case Some(r) =>
+ val matching = RoutingScheme.findAll((baseQuery :+ OrderBy(RoutingScheme.Scheme, Ascending)): _*)
+ .filter(_.downstreamRails.contains(r))
+ (matching.drop(offset).take(limit).asInstanceOf[List[RoutingSchemeTrait]], matching.size)
+ case None =>
+ // Count BEFORE applying limit/offset for total
+ val total: Int = RoutingScheme.count(baseQuery: _*).toInt
+ val rows: List[RoutingScheme] =
+ RoutingScheme.findAll((baseQuery :+ OrderBy(RoutingScheme.Scheme, Ascending) :+ StartAt[RoutingScheme](offset) :+ MaxRows[RoutingScheme](limit)): _*)
+ (rows.asInstanceOf[List[RoutingSchemeTrait]], total)
}
- (filtered.asInstanceOf[List[RoutingSchemeTrait]], total)
}
}
diff --git a/obp-api/src/main/scala/code/signal/SignalEventBus.scala b/obp-api/src/main/scala/code/signal/SignalEventBus.scala
index 8179e1421a..1088567316 100644
--- a/obp-api/src/main/scala/code/signal/SignalEventBus.scala
+++ b/obp-api/src/main/scala/code/signal/SignalEventBus.scala
@@ -108,15 +108,11 @@ object SignalEventBus extends MdcLoggable {
}
subscriberThread = new Thread(() => {
- try {
- // Dedicated connection: a subscribing Jedis cannot be returned to the pool.
- subscriberJedis = Redis.newSubscriberConnection()
+ // Dedicated connection: a subscribing Jedis cannot be returned to the pool.
+ // It is reopened after a Redis disconnect, so signal streams keep getting messages until stop().
+ Redis.runSubscriber("SignalEventBus", running, subscriberJedis = _) { jedis =>
logger.info(s"SignalEventBus says: Redis subscriber started, pattern-subscribing to ${CHANNEL_PREFIX}*")
- subscriberJedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*")
- } catch {
- case e: Throwable if running =>
- logger.error(s"SignalEventBus says: Redis subscriber thread died: ${e.getMessage}")
- case _: Throwable => // shutting down, ignore
+ jedis.psubscribe(pubSub, s"${CHANNEL_PREFIX}*")
}
}, "signal-event-bus-subscriber")
subscriberThread.setDaemon(true)
diff --git a/obp-api/src/main/scala/code/standingorders/MappedStandingOrder.scala b/obp-api/src/main/scala/code/standingorders/MappedStandingOrder.scala
index d6eda246c3..2933bb70eb 100644
--- a/obp-api/src/main/scala/code/standingorders/MappedStandingOrder.scala
+++ b/obp-api/src/main/scala/code/standingorders/MappedStandingOrder.scala
@@ -60,6 +60,8 @@ object MappedStandingOrderProvider extends StandingOrderProvider {
.AmountValue(convertToSmallestCurrencyUnits(amountValue, amountCurrency))
.AmountCurrency(amountCurrency)
.WhenFrequency(whenFrequency)
+ // store the schedule detail the caller posted (e.g. day of the month), so the response and later reads return it
+ .WhenDetail(whenDetail)
.DateSigned(dateSigned)
.DateStarts(dateStarts)
.DateExpires(if (dateExpires.isDefined) dateExpires.get else null)
diff --git a/obp-api/src/main/scala/code/users/UserReference.scala b/obp-api/src/main/scala/code/users/UserReference.scala
index a88539d14d..1fe9c0db9d 100644
--- a/obp-api/src/main/scala/code/users/UserReference.scala
+++ b/obp-api/src/main/scala/code/users/UserReference.scala
@@ -337,6 +337,122 @@ object UserReference {
Token_UserForeignKey
)
+ /**
+ * This class says why a reference's provider does not apply its policy yet, and what that means
+ * for a consent user's call in the meantime. Each value is one entry of notYetWired below.
+ */
+ sealed abstract class NotYetWired(val reason: String)
+ object NotYetWired {
+ /** This means the provider stores the caller's own id, so a row written by an agent is stored
+ * against the agent, and a Reject reference is not refused. */
+ final case class StoresCaller(override val reason: String) extends NotYetWired(reason)
+ /** This means no API call reaches the provider with a consent user's id, so there is nothing
+ * for the policy to act on: the endpoint refuses first, or no endpoint writes the table. */
+ final case class NotReachedByConsentUser(override val reason: String) extends NotYetWired(reason)
+ }
+
+ private val mechanicalBatch = NotYetWired.StoresCaller(
+ "Phase 2 mechanical batch: the provider does not call attributionOf yet, so a consent user's " +
+ "row is stored against the consent user and dies with the Consent.")
+
+ private val webhookDeferred = NotYetWired.StoresCaller(
+ "deferred 2026-09-16, not mechanical: agreed as record-both (like Counterparty), but the webhook " +
+ "code has two dead paths already and is not to be touched in a hurry. todo/webhook_attribution.md.")
+
+ private val filesNotYetServed = NotYetWired.NotReachedByConsentUser(
+ "the Files tables (code.files) exist but no endpoint writes them yet. The endpoints must store " +
+ "cc.onBehalfOfUserId as the owner (ideas/ogcr_file_storage.md) and reject a consent user as grantee.")
+
+ private val rejectNotEnforced = NotYetWired.StoresCaller(
+ "Reject, not yet enforced: attributionOf already returns Failure for it (AgentDelegationTest pins " +
+ "that), but no caller consults it.")
+
+ /**
+ * This lists the UseOnBehalfOfUserId and Reject references whose provider does not apply the
+ * policy yet, because Phase 2 of ON_BEHALF_OF_USER_ID_PLAN.md is done a table at a time. Every
+ * other reference in `all` is applied. Together with `all` this is the state of on-behalf-of
+ * attribution, and the "On Behalf Of" Glossary item renders it.
+ *
+ * It is written out by hand rather than derived from what the main sources mention.
+ * OnBehalfOfOwnershipSweepTest checks it against those sources: a UseOnBehalfOfUserId or Reject
+ * reference that is neither mentioned nor listed fails, and so does one that is listed but now
+ * mentioned. So wiring a provider forces its entry out of this list, and the list may only shrink.
+ */
+ lazy val notYetWired: Map[UserReference, NotYetWired] = Map(
+ AccountApplication_UserId -> mechanicalBatch,
+ AccountAccessRequest_RequestorUserId -> mechanicalBatch,
+ AccountAccessRequest_CheckerUserId -> mechanicalBatch,
+ DynamicChangeRequest_RequestorUserId -> mechanicalBatch,
+ DynamicChangeRequest_CheckerUserId -> mechanicalBatch,
+ EntitlementRequest_UserId -> mechanicalBatch,
+ UserScope_UserId -> mechanicalBatch,
+ ApiCollection_UserId -> mechanicalBatch,
+ UserAttribute_UserId -> mechanicalBatch,
+ UserAgreement_UserId -> mechanicalBatch,
+ UserInitAction_UserId -> mechanicalBatch,
+ UserAuthContext_UserId -> mechanicalBatch,
+ UserAuthContextUpdate_UserId -> mechanicalBatch,
+ DynamicDataAccess_UserId -> mechanicalBatch,
+ DynamicEndpoint_UserId -> mechanicalBatch,
+ DynamicResourceDoc_CreatedByUserId -> mechanicalBatch,
+ DynamicMessageDoc_CreatedByUserId -> mechanicalBatch,
+ ConnectorMethod_CreatedByUserId -> mechanicalBatch,
+ AbacRule_CreatedByUserId -> mechanicalBatch,
+ CounterpartyWhereTag_User -> mechanicalBatch,
+ ApiProductSubscription_CreatedByUserId -> mechanicalBatch,
+ DynamicGlossaryItem_CreatedByUserId -> mechanicalBatch,
+ Organisation_CreatedByUserId -> mechanicalBatch,
+ Asset_CreatedByUserId -> NotYetWired.NotReachedByConsentUser(
+ "no endpoint writes the asset registry yet; only the boot seed does. Wire it with the asset write endpoints."),
+ File_UserId -> filesNotYetServed,
+ FileAttachment_UserId -> filesNotYetServed,
+ FileAccess_GrantedByUserId -> filesNotYetServed,
+ FileAccess_GranteeUserId -> filesNotYetServed,
+ PayeeLookup_CreatedByUserId -> mechanicalBatch,
+ RoutingScheme_CreatedByUserId -> mechanicalBatch,
+ UtilityPaymentCallback_CreatedByUserId -> mechanicalBatch,
+ StandingOrder_UserId -> mechanicalBatch,
+ DirectDebit_UserId -> mechanicalBatch,
+ Mandate_CreatedByUserId -> mechanicalBatch,
+ SignatoryPanel_UserIds -> mechanicalBatch,
+ AccountWebhook_CreatedByUserId -> webhookDeferred,
+ SystemAccountNotificationWebhook_CreatedByUserId -> webhookDeferred,
+ BankAccountNotificationWebhook_CreatedByUserId -> webhookDeferred,
+ ChatRoom_CreatedByUserId -> mechanicalBatch,
+ Participant_UserId -> mechanicalBatch,
+ Reaction_UserId -> mechanicalBatch,
+ ChatEmailDigestState_UserId -> mechanicalBatch,
+ ChatMessage_MentionedUserIds -> mechanicalBatch,
+ CrmEvent_UserId -> mechanicalBatch,
+ KycCheck_User -> mechanicalBatch,
+ KycCheck_StaffUserId -> mechanicalBatch,
+ KycDocument_User -> mechanicalBatch,
+ KycStatus_User -> mechanicalBatch,
+ SocialMedia_User -> mechanicalBatch,
+ CustomerMessage_User -> mechanicalBatch,
+ Meeting_CustomerUserId -> mechanicalBatch,
+ Meeting_StaffUserId -> mechanicalBatch,
+ Tag_User -> mechanicalBatch,
+ WhereTag_User -> mechanicalBatch,
+ TransactionImage_User -> mechanicalBatch,
+ AccountAccessRequest_TargetUserId -> NotYetWired.NotReachedByConsentUser(
+ "explicit target, so the endpoint refuses a consent user rather than redirecting -- covered " +
+ "by ExplicitTargetConsentUserSweepTest. The provider redirect is unreachable from the API."),
+ GroupMembership_UserId -> NotYetWired.NotReachedByConsentUser(
+ "the member is never the caller: the Group member sync endpoint reads each member's user id " +
+ "from the Group's existing Entitlements and membership rows, so there is nothing to redirect."),
+ Consent_UserId -> NotYetWired.StoresCaller(
+ "Reject, not yet enforced: a consent user can still create a Consent (nested delegation). " +
+ "attributionOf already returns Failure for it -- AgentDelegationTest pins that -- but no " +
+ "caller consults it. ON_BEHALF_OF_USER_ID_PLAN.md Phase 3, still open."),
+ Consumer_CreatedByUserId -> rejectNotEnforced,
+ Token_UserForeignKey -> rejectNotEnforced
+ )
+
+ /** This is true when the reference's provider applies its policy. A UseAuthenticatedUserId
+ * reference needs no resolver, so it is always applied. */
+ def isWired(ref: UserReference): Boolean = !notYetWired.contains(ref)
+
/** This lists the columns whose names look like user ids to UserReferenceAttributionPolicyTest
* but which do not in fact hold one, so no policy applies. Each entry is the Mapper class, the
* field, and why it is excluded.
diff --git a/obp-api/src/test/scala/code/api/UKOpenBanking/v2_0_0/UKOpenBankingV200Tests.scala b/obp-api/src/test/scala/code/api/UKOpenBanking/v2_0_0/UKOpenBankingV200Tests.scala
index f3e28b2d03..b5346b472a 100644
--- a/obp-api/src/test/scala/code/api/UKOpenBanking/v2_0_0/UKOpenBankingV200Tests.scala
+++ b/obp-api/src/test/scala/code/api/UKOpenBanking/v2_0_0/UKOpenBankingV200Tests.scala
@@ -144,6 +144,20 @@ class UKOpenBankingV200Tests extends UKOpenBankingV200ServerSetup with DefaultUs
transactionsJsonUKV200.Links.Self contains("Transactions")
}
+ // A date window that matches no transaction must give 200 with an empty list and the account id from the path in the link.
+ scenario("No transactions in the date window gives an empty list", UKOpenBankingV200)
+ {
+ val requestGetAll = (UKOpenBankingV200Request / "accounts"/ testAccountId1.value /"transactions" ).GET <@(user1)
+ val params = ("obp_from_date", "2100-01-01T00:00:00.000Z") :: ("obp_to_date", "2100-01-02T00:00:00.000Z") :: Nil
+ val response = makeGetRequest(requestGetAll, params)
+
+ Then("We should get a 200 with no transactions")
+ response.code should equal(200)
+ val transactionsJsonUKV200 = response.body.extract[TransactionsJsonUKV200]
+ transactionsJsonUKV200.Data.Transaction should equal(Nil)
+ transactionsJsonUKV200.Links.Self contains (s"/accounts/${testAccountId1.value}/transactions") should be (true)
+ }
+
scenario("Unauthenticated access is rejected", UKOpenBankingV200)
{
val requestGetAll = (UKOpenBankingV200Request / "accounts"/ testAccountId1.value /"transactions" ).GET
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 d74f49eecc..fd24972860 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
@@ -74,6 +74,8 @@ class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures {
object getCardAccountTransactionList extends Tag(nameOf(Http4sBGv13AIS.getCardAccountTransactionList))
+ object getCardAccounts extends Tag(nameOf(Http4sBGv13AIS.getCardAccounts))
+
object createConsent extends Tag(nameOf(Http4sBGv13AIS.createConsent))
object deleteConsent extends Tag(nameOf(Http4sBGv13AIS.deleteConsent))
@@ -116,6 +118,18 @@ class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures {
}
}
+ feature(s"BG v1.3 - $getCardAccounts") {
+ // user1 holds the owner view on the test accounts but not ReadAccountsBerlinGroup, so no card account is listed
+ scenario("Authentication User with only non Berlin Group views, test failed", BerlinGroupV1_3, getCardAccounts) {
+ val requestGet = (V1_3_BG / "card-accounts").GET <@ (user1)
+ val response = makeGetRequest(requestGet)
+
+ Then("We should get a 403 ")
+ response.code should equal(403)
+ response.body.extract[ErrorMessagesBG].tppMessages.head.text should startWith(NoViewReadAccountsBerlinGroup)
+ }
+ }
+
feature(s"BG v1.3 - $getAccountDetails") {
scenario("Not Authentication User, test failed ", BerlinGroupV1_3, getAccountDetails) {
val requestGet = (V1_3_BG / "accounts" / "accountId").GET
@@ -432,6 +446,16 @@ class AccountInformationServiceAISApiTest extends BerlinGroupConsentFixtures {
response2.code should equal(200)
response2.body.extract[SingleTransactionJsonV13].value.transactionsDetails.transactionId should be (transactionId)
}
+
+ // With PSD2 certificates required, a caller sending no TPP certificate is refused before the account is read
+ scenario("PSD2 certificate required but not sent, test failed", BerlinGroupV1_3, getTransactionDetails) {
+ setPropsValues("requirePsd2Certificates" -> "CERTIFICATE")
+ val requestGet = (V1_3_BG / "accounts" / testAccountId1.value / "transactions" / "whatever").GET <@ (user1)
+ val response: APIResponse = makeGetRequest(requestGet)
+ Then("We should get a 401 ")
+ response.code should equal(401)
+ response.body.extract[ErrorMessagesBG].tppMessages.head.text should include(X509CannotGetCertificate)
+ }
}
feature(s"BG v1.3 - $getCardAccountTransactionList") {
diff --git a/obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala b/obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala
index 7762802fc6..6424c45b78 100644
--- a/obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala
+++ b/obp-api/src/test/scala/code/api/http4sserver/Http4sServerIntegrationTest.scala
@@ -295,6 +295,33 @@ class Http4sServerIntegrationTest extends ServerSetup with DefaultUsers with Ser
pending
}
}
+
+ // The v6.0.0 product list documents a tags field
+ scenario("GET /obp/v6.0.0/banks/BANK_ID/products returns each product's tags", Http4sServerIntegrationTag) {
+ Given("A product of the bank carries tags")
+ val productCode = "tagged-product-v600"
+ val bankId = com.openbankproject.commons.model.BankId("testBank0")
+ val product = code.products.MappedProduct.create
+ .mBankId("testBank0")
+ .mCode(productCode)
+ .mName("Tagged Product")
+ .mLicenseId("some-license")
+ .mLicenseName("Some License")
+ .saveMe()
+ code.products.ProductTagsProvider.setTags(bankId, com.openbankproject.commons.model.ProductCode(productCode), List("savings", "green"))
+
+ When("We request the products of the bank")
+ val (status, body) = makeHttp4sGetRequest(s"/obp/v6.0.0/banks/testBank0/products")
+ code.products.ProductTagsProvider.setTags(bankId, com.openbankproject.commons.model.ProductCode(productCode), Nil)
+ product.delete_!
+
+ Then("We should get a 200 response")
+ status should equal(200)
+
+ And("That product should list its tags")
+ val listed = (parse(body) \ "products").children.find(p => (p \ "product_code").extract[String] == productCode)
+ listed.map(p => (p \ "tags").extract[List[String]]) should equal(Some(List("green", "savings")))
+ }
}
feature("HTTP4S version-cascade fallback") {
diff --git a/obp-api/src/test/scala/code/api/sweep/ExplicitTargetConsentUserSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/ExplicitTargetConsentUserSweepTest.scala
index 77c7057712..f4e9ead86c 100644
--- a/obp-api/src/test/scala/code/api/sweep/ExplicitTargetConsentUserSweepTest.scala
+++ b/obp-api/src/test/scala/code/api/sweep/ExplicitTargetConsentUserSweepTest.scala
@@ -38,6 +38,7 @@ import code.api.util.ErrorMessages.InvalidUserId
import code.api.v1_4_0.JSONFactory1_4_0.CustomerFaceImageJson
import code.api.v2_0_0.{CreateAccountJSON, CreateCustomerJson, CreateEntitlementJSON, CreateUserCustomerLinkJson}
import code.api.v2_1_0.{CustomerCreditRatingJSON, PostCustomerJsonV210}
+import code.api.v2_2_0.CreateAccountJSONV220
import code.api.v3_1_0.{AccountApplicationJson, AccountApplicationUpdateStatusJson, CreateAccountRequestJsonV310}
import code.api.v4_0_0.SettlementAccountRequestJson
import code.api.v5_0_0.CreateAccountRequestJsonV500
@@ -47,7 +48,7 @@ import code.api.v7_0_0.JSONFactory700.CreateAccountRequestJsonV700
import code.model.dataAccess.ResourceUser
import code.setup.{DefaultUsers, ServerSetupWithTestData}
import code.users.Users
-import com.openbankproject.commons.model.{AmountOfMoneyJsonV121, ProductCode}
+import com.openbankproject.commons.model.{AccountRoutingJsonV121, AmountOfMoneyJsonV121, ProductCode}
import org.json4s.{Extraction, JValue}
import org.json4s.native.JsonMethods.{compact, render}
import org.scalatest.Tag
@@ -66,9 +67,9 @@ import scala.io.Source
* would be wrong: the caller asked for a specific target, and if that target is an agent identity
* the request is a mistake the caller needs told about. So the endpoint answers 400 OBP-30107.
*
- * Why a sweep and not a scenario per version file: the guard is nineteen near-identical three-line
+ * Why a sweep and not a scenario per version file: the guard is twenty near-identical three-line
* blocks spread over ten Http4s objects, and the failure mode that matters is not one of them
- * being wrong -- it is a twentieth being added, or an existing one being dropped, with nobody
+ * being wrong -- it is a twenty-first being added, or an existing one being dropped, with nobody
* noticing. So this suite pairs each probe with a source scan (the last scenario) that counts the
* guards in main and fails if the count and this table disagree.
*
@@ -97,7 +98,7 @@ class ExplicitTargetConsentUserSweepTest extends ServerSetupWithTestData with De
implicit val runtime: IORuntime = IORuntime.global
/**
- * The phrase every one of the nineteen guard messages shares.
+ * The phrase every one of the twenty guard messages shares.
*
* The messages differ in their subject ("user_id", "target_user_id", "USER_ID", "The
* application's user") and in the sentence that follows, so the assertion anchors on the part
@@ -234,6 +235,10 @@ class ExplicitTargetConsentUserSweepTest extends ServerSetupWithTestData with De
sweep(List(
Probe("OBPv2.0.0-createAccount", "PUT", s"/obp/v2.0.0/banks/$bankId/accounts/${generateUUID()}",
CreateAccountJSON(user_id = agentId, label = "explicit target sweep", `type` = "CURRENT", balance = money)),
+ Probe("OBPv2.2.0-createAccount", "PUT", s"/obp/v2.2.0/banks/$bankId/accounts/${generateUUID()}",
+ CreateAccountJSONV220(
+ user_id = agentId, label = "explicit target sweep", `type` = "CURRENT", balance = money,
+ branch_id = "", account_routing = AccountRoutingJsonV121(scheme = "OBP", address = generateUUID()))),
Probe("OBPv3.1.0-createAccount", "PUT", s"/obp/v3.1.0/banks/$bankId/accounts/${generateUUID()}", v310Body),
Probe("OBPv4.0.0-addAccount", "POST", s"/obp/v4.0.0/banks/$bankId/accounts", v310Body),
Probe("OBPv4.0.0-createSettlementAccount", "POST", s"/obp/v4.0.0/banks/$bankId/settlement-accounts",
@@ -310,8 +315,8 @@ class ExplicitTargetConsentUserSweepTest extends ServerSetupWithTestData with De
feature("Every explicit-target guard in the codebase is probed above") {
/**
- * The scenarios above are a hand-written table, and a hand-written table of nineteen
- * near-identical things drifts. A twentieth guard added next month is not covered by
+ * The scenarios above are a hand-written table, and a hand-written table of twenty
+ * near-identical things drifts. A twenty-first guard added next month is not covered by
* anything here, and nothing says so -- the suite stays green while its subject grows.
*
* So the guards are counted at their source. Every one is a booleanToFuture whose message
@@ -339,11 +344,11 @@ class ExplicitTargetConsentUserSweepTest extends ServerSetupWithTestData with De
finally source.close()
}
- // Twenty probes cover nineteen guards: v7.0.0 registers createAccountV700 (POST) and
+ // Twenty-one probes cover twenty guards: v7.0.0 registers createAccountV700 (POST) and
// createAccountWithIdV700 (PUT) over one shared createAccountCommon, so the two probes
// reach the same guard by two different routes -- the PUT arm validates the account id
// first, which is the part worth probing separately.
- val guardsInMain = 19
+ val guardsInMain = 20
withClue(s"found ${guardLines.size} explicit-target guards in main, but this suite is " +
s"written against $guardsInMain. A guard added without a probe is untested; a " +
diff --git a/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala b/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala
index a1ac159dd7..37c091fc7b 100644
--- a/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala
+++ b/obp-api/src/test/scala/code/api/sweep/OnBehalfOfOwnershipSweepTest.scala
@@ -64,12 +64,9 @@ import scala.io.Source
*
* The plan says: "call every UseOnBehalfOfUserId create endpoint with the consent JWT; assert no
* row in any such table references the consent user's id". Taken literally that suite is red on the
- * day it is written and stays red for months: of the 62 references this scenario covers (the 59
- * UseOnBehalfOfUserId ones and the 3 Reject ones), EIGHT are wired today (TransactionRequest_UserId,
- * Entitlement_UserId, AccountHolders_User, UserCustomerLink_UserId,
- * DynamicEntity_UserId/DynamicData_UserId, Bank_CreatedByUserId, Counterparty_CreatedByUserId --
- * Phase 2 rows 1 to 5), and the other 54 are the notYetWired list below.
- * A permanently
+ * day it is written and stays red for months, because Phase 2 wires the providers a table at a
+ * time and most UseOnBehalfOfUserId and Reject references are still in UserReference.notYetWired
+ * (the first scenario logs the current counts). A permanently
* red suite is one people learn to ignore, which is the same reasoning AuthSweepTest's
* expectedAuthDeviation records for its own two entries.
*
@@ -96,101 +93,20 @@ class OnBehalfOfOwnershipSweepTest extends ServerSetupWithTestData with DefaultU
implicit val runtime: IORuntime = IORuntime.global
- private val webhookDeferred =
- "deferred 2026-09-16, not mechanical: agreed as record-both (like Counterparty), but the webhook " +
- "code has two dead paths already and is not to be touched in a hurry. todo/webhook_attribution.md."
-
- private val filesNotYetServed =
- "the Files tables (code.files) exist but no endpoint writes them yet. The endpoints must store " +
- "cc.onBehalfOfUserId as the owner (ideas/ogcr_file_storage.md) and reject a consent user as grantee."
-
- private val mechanicalBatch =
- "Phase 2 mechanical batch: the provider does not call attributionOf yet, so a consent user's " +
- "row is stored against the consent user and dies with the Consent."
-
/**
- * This lists the references whose provider does not consult the resolver yet, because Phase 2 is
- * being done a table at a time rather than all at once.
+ * This is UserReference.notYetWired keyed by reference name, the form the source scan produces.
*
- * Written out by hand rather than derived from "what main does not reference". A derived list
- * would agree with reality by construction: the undecided check could never fail, because
- * anything missing would be added to the list at the same moment; and the staleness check could
- * never fail either. Both assertions would be checking the list against a copy of itself, which
- * is the failure mode SweepCoverageDriftCheckTest exists to prevent elsewhere in this package.
- * Spelling the names out is what makes adding a table a decision somebody has to take.
- *
- * This list may only shrink.
+ * The list itself lives in UserReference, next to the policies, so that the "On Behalf Of"
+ * Glossary item can render it. It is still written out by hand there rather than derived from
+ * "what main does not reference". A derived list would agree with reality by construction: the
+ * undecided check could never fail, because anything missing would be added to the list at the
+ * same moment; and the staleness check could never fail either. Both assertions would be
+ * checking the list against a copy of itself, which is the failure mode
+ * SweepCoverageDriftCheckTest exists to prevent elsewhere in this package. Spelling the names
+ * out is what makes adding a table a decision somebody has to take.
*/
- private val notYetWired: Map[String, String] = Map(
- "AccountApplication_UserId" -> mechanicalBatch,
- "AccountAccessRequest_RequestorUserId" -> mechanicalBatch,
- "AccountAccessRequest_CheckerUserId" -> mechanicalBatch,
- "DynamicChangeRequest_RequestorUserId" -> mechanicalBatch,
- "DynamicChangeRequest_CheckerUserId" -> mechanicalBatch,
- "EntitlementRequest_UserId" -> mechanicalBatch,
- "UserScope_UserId" -> mechanicalBatch,
- "ApiCollection_UserId" -> mechanicalBatch,
- "UserAttribute_UserId" -> mechanicalBatch,
- "UserAgreement_UserId" -> mechanicalBatch,
- "UserInitAction_UserId" -> mechanicalBatch,
- "UserAuthContext_UserId" -> mechanicalBatch,
- "UserAuthContextUpdate_UserId" -> mechanicalBatch,
- "DynamicDataAccess_UserId" -> mechanicalBatch,
- "DynamicEndpoint_UserId" -> mechanicalBatch,
- "DynamicResourceDoc_CreatedByUserId" -> mechanicalBatch,
- "DynamicMessageDoc_CreatedByUserId" -> mechanicalBatch,
- "ConnectorMethod_CreatedByUserId" -> mechanicalBatch,
- "AbacRule_CreatedByUserId" -> mechanicalBatch,
- "CounterpartyWhereTag_User" -> mechanicalBatch,
- "ApiProductSubscription_CreatedByUserId" -> mechanicalBatch,
- "DynamicGlossaryItem_CreatedByUserId" -> mechanicalBatch,
- "Organisation_CreatedByUserId" -> mechanicalBatch,
- "Asset_CreatedByUserId" ->
- "no endpoint writes the asset registry yet; only the boot seed does. Wire it with the asset write endpoints.",
- "File_UserId" -> filesNotYetServed,
- "FileAttachment_UserId" -> filesNotYetServed,
- "FileAccess_GrantedByUserId" -> filesNotYetServed,
- "FileAccess_GranteeUserId" -> filesNotYetServed,
- "PayeeLookup_CreatedByUserId" -> mechanicalBatch,
- "RoutingScheme_CreatedByUserId" -> mechanicalBatch,
- "UtilityPaymentCallback_CreatedByUserId" -> mechanicalBatch,
- "StandingOrder_UserId" -> mechanicalBatch,
- "DirectDebit_UserId" -> mechanicalBatch,
- "Mandate_CreatedByUserId" -> mechanicalBatch,
- "SignatoryPanel_UserIds" -> mechanicalBatch,
- "AccountWebhook_CreatedByUserId" -> webhookDeferred,
- "SystemAccountNotificationWebhook_CreatedByUserId" -> webhookDeferred,
- "BankAccountNotificationWebhook_CreatedByUserId" -> webhookDeferred,
- "ChatRoom_CreatedByUserId" -> mechanicalBatch,
- "Participant_UserId" -> mechanicalBatch,
- "Reaction_UserId" -> mechanicalBatch,
- "ChatEmailDigestState_UserId" -> mechanicalBatch,
- "ChatMessage_MentionedUserIds" -> mechanicalBatch,
- "CrmEvent_UserId" -> mechanicalBatch,
- "KycCheck_User" -> mechanicalBatch,
- "KycCheck_StaffUserId" -> mechanicalBatch,
- "KycDocument_User" -> mechanicalBatch,
- "KycStatus_User" -> mechanicalBatch,
- "SocialMedia_User" -> mechanicalBatch,
- "CustomerMessage_User" -> mechanicalBatch,
- "Meeting_CustomerUserId" -> mechanicalBatch,
- "Meeting_StaffUserId" -> mechanicalBatch,
- "Tag_User" -> mechanicalBatch,
- "WhereTag_User" -> mechanicalBatch,
- "TransactionImage_User" -> mechanicalBatch,
- "AccountAccessRequest_TargetUserId" ->
- ("explicit target, so the endpoint refuses a consent user rather than redirecting -- covered " +
- "by ExplicitTargetConsentUserSweepTest. The provider redirect is unreachable from the API."),
- "GroupMembership_UserId" ->
- ("the member is never the caller: the Group member sync endpoint reads each member's user id " +
- "from the Group's existing Entitlements and membership rows, so there is nothing to redirect."),
- "Consent_UserId" ->
- ("Reject, not yet enforced: a consent user can still create a Consent (nested delegation). " +
- "attributionOf already returns Failure for it -- AgentDelegationTest pins that -- but no " +
- "caller consults it. ON_BEHALF_OF_USER_ID_PLAN.md Phase 3, still open."),
- "Consumer_CreatedByUserId" -> "Reject, not yet enforced: no caller consults attributionOf.",
- "Token_UserForeignKey" -> "Reject, not yet enforced: no caller consults attributionOf."
- )
+ private val notYetWired: Map[String, String] =
+ UserReference.notYetWired.map { case (ref, why) => ref.name -> why.reason }
// ── which references main actually uses ────────────────────────────────────
diff --git a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala
index ae876edbb3..a81bc8684c 100644
--- a/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala
+++ b/obp-api/src/test/scala/code/api/v1_2_1/API1_2_1Test.scala
@@ -1675,7 +1675,25 @@ class API1_2_1Test extends ServerSetupWithTestData with DefaultUsers with Privat
And("we should get an error message")
reply.body.extract[ErrorMessage].message.nonEmpty should equal (true)
}
-
+
+ // the view may only get actions the caller already has on one of their views of the account
+ scenario("we will not give a view an action that none of the caller's views has", API1_2_1, PutView){
+ Given("A view exists")
+ val bankId = randomBank
+ val bankAccount : AccountJSON = randomPrivateAccount(bankId)
+ val view = randomView(true, "")
+ val creationReply = postView(bankId, bankAccount.id, view, user1)
+ creationReply.code should equal (201)
+ val createdView : ViewJSONV121 = creationReply.body.extract[ViewJSONV121]
+
+ When("we ask for an action that no view of the caller has")
+ val reply = putView(bankId, bankAccount.id, createdView.id,
+ viewUpdateJson(createdView).copy(allowed_actions = allowedActions :+ "can_do_what_no_view_of_the_caller_can"), user1)
+ Then("we should get a 403 and the SourceViewHasLessPermission message")
+ reply.code should equal (403)
+ reply.body.extract[ErrorMessage].message should startWith (code.api.util.ErrorMessages.SourceViewHasLessPermission)
+ }
+
scenario("we can not update a System view on a bank account") {
val bankId = randomBank
val bankAccount : AccountJSON = randomPrivateAccount(bankId)
@@ -5348,6 +5366,22 @@ class API1_2_1Test extends ServerSetupWithTestData with DefaultUsers with Privat
deleteReply.code should equal (400)
}
+ // the public view has can_add_comment but not can_delete_comment, so only the poster check lets the poster delete it
+ scenario("we will delete a comment we posted for one random transaction through a view without can_delete_comment", API1_2_1, DeleteComment){
+ Given("We will use an access token and will set a comment first through the public view")
+ val bankId = randomBank
+ val bankAccount : AccountJSON = randomPrivateAccount(bankId)
+ val view = CUSTOM_PUBLIC_VIEW_ID
+ val transaction = randomTransaction(bankId, bankAccount.id, view)
+ val randomComment = PostTransactionCommentJSON(randomString(20))
+ val postedReply = postCommentForOneTransaction(bankId, bankAccount.id, view, transaction.id, randomComment, user2)
+ val postedComment = postedReply.body.extract[TransactionCommentJSON]
+ When("the poster sends the delete request")
+ val deleteReply = deleteCommentForOneTransaction(bankId, bankAccount.id, view, transaction.id, postedComment.id, user2)
+ Then("we should get a 204 code")
+ deleteReply.code should equal (204)
+ }
+
scenario("we will not delete a comment for one random transaction because the comment does not exist", API1_2_1, DeleteComment){
Given("We will use an access token")
val bankId = randomBank
@@ -6029,6 +6063,22 @@ class API1_2_1Test extends ServerSetupWithTestData with DefaultUsers with Privat
deleteReply.code should equal (400)
}
+ // the public view has can_add_image but not can_delete_image, so only the poster check lets the poster delete it
+ scenario("we will delete an image we posted for one random transaction through a view without can_delete_image", API1_2_1, DeleteImage){
+ Given("We will use an access token and will set an image first through the public view")
+ val bankId = randomBank
+ val bankAccount : AccountJSON = randomPrivateAccount(bankId)
+ val view = CUSTOM_PUBLIC_VIEW_ID
+ val transaction = randomTransaction(bankId, bankAccount.id, view)
+ val randomImage = PostTransactionImageJSON(randomString(5),"http://www.mysuperimage.com")
+ val postedReply = postImageForOneTransaction(bankId, bankAccount.id, view, transaction.id, randomImage, user1)
+ val postedImage = postedReply.body.extract[TransactionImageJSON]
+ When("the poster sends the delete request")
+ val deleteReply = deleteImageForOneTransaction(bankId, bankAccount.id, view, transaction.id, postedImage.id, user1)
+ Then("we should get a 204 code")
+ deleteReply.code should equal (204)
+ }
+
scenario("we will not delete an image for one random transaction because the image does not exist", API1_2_1, DeleteImage){
Given("We will use an access token")
val bankId = randomBank
diff --git a/obp-api/src/test/scala/code/api/v1_4_0/MappedCustomerMessagesTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/MappedCustomerMessagesTest.scala
index aef07a2795..8014b673eb 100644
--- a/obp-api/src/test/scala/code/api/v1_4_0/MappedCustomerMessagesTest.scala
+++ b/obp-api/src/test/scala/code/api/v1_4_0/MappedCustomerMessagesTest.scala
@@ -30,6 +30,8 @@ package code.api.v1_4_0
import org.json4s._
import code.api.util.APIUtil.OAuth._
import code.api.util.{APIUtil, ApiRole}
+import code.api.util.ErrorMessages.{CustomerNotFoundByCustomerId, UserHasMissingRoles}
+import com.openbankproject.commons.model.ErrorMessage
import code.api.v1_4_0.JSONFactory1_4_0.{AddCustomerMessageJson, CustomerFaceImageJson, CustomerMessagesJson}
import code.api.v2_0_0.CreateCustomerJson
import code.customer.{CustomerX, MappedCustomerMessage}
@@ -96,6 +98,8 @@ class MappedCustomerMessagesTest extends V140ServerSetup with DefaultUsers {
}
When("We add a message")
+ // sending a customer message needs CanCreateCustomerMessage at the bank
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.CanCreateCustomerMessage.toString)
request = (v1_4Request / "banks" / testBankId1.value / "customer" / customerId / "messages").POST <@ user1
val messageJson = AddCustomerMessageJson("some message", "some department", "some person")
response = makePostRequest(request, write(messageJson))
@@ -114,6 +118,56 @@ class MappedCustomerMessagesTest extends V140ServerSetup with DefaultUsers {
msg.from_person should equal(messageJson.from_person)
msg.id.nonEmpty should equal(true)
}
+
+ // without the role, a logged-in user cannot put messages in a customer's inbox
+ scenario("Adding a message without CanCreateCustomerMessage") {
+ When("A user without the role adds a message")
+ val request = (v1_4Request / "banks" / testBankId1.value / "customer" / "some-customer-id" / "messages").POST <@ user2
+ val response = makePostRequest(request, write(AddCustomerMessageJson("some message", "some department", "some person")))
+ Then("We should get a 403")
+ response.code should equal(403)
+ response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + ApiRole.canCreateCustomerMessage)
+ }
+
+ // a customer of another bank is treated as not found, so nothing is told about other banks' customers
+ scenario("Adding a message to a customer of another bank") {
+ val customerId = createCustomerAtBank1()
+ Entitlement.entitlement.vend.addEntitlement(testBankId2.value, resourceUser1.userId, ApiRole.CanCreateCustomerMessage.toString)
+
+ When("We add a message at bank 2 for a customer of bank 1")
+ val request = (v1_4Request / "banks" / testBankId2.value / "customer" / customerId / "messages").POST <@ user1
+ val response = makePostRequest(request, write(AddCustomerMessageJson("some message", "some department", "some person")))
+ Then("We should get a 404")
+ response.code should equal(404)
+ response.body.extract[ErrorMessage].message should startWith(CustomerNotFoundByCustomerId)
+ MappedCustomerMessage.count() should equal(0)
+ }
+ }
+
+ // creates a customer at bank 1 linked to resourceUser1 and returns its customer id
+ private def createCustomerAtBank1(): String = {
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.CanCreateCustomer.toString)
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.CanCreateUserCustomerLink.toString)
+ val customerJson = CreateCustomerJson(
+ title = "Title",
+ branchId = "The branchId",
+ nameSuffix = "The nameSuffix",
+ user_id = resourceUser1.userId,
+ customer_number = mockCustomerNumber,
+ legal_name = "Someone",
+ mobile_phone_number = "125245",
+ email = "hello@hullo.com",
+ face_image = CustomerFaceImageJson("www.example.com/person/123/image.png", exampleDate),
+ date_of_birth = exampleDate,
+ relationship_status = "Single",
+ dependants = 1,
+ dob_of_dependants = List(exampleDate),
+ highest_education_attained = "Bachelor’s Degree",
+ employment_status = "Employed",
+ kyc_status = true,
+ last_ok_date = exampleDate)
+ makePostRequest((v1_4Request / "banks" / testBankId1.value / "customer").POST <@ user1, write(customerJson))
+ CustomerX.customerProvider.vend.getCustomerByCustomerNumber(mockCustomerNumber, testBankId1).map(_.customerId).openOrThrowException("customer not created")
}
diff --git a/obp-api/src/test/scala/code/api/v2_0_0/CustomerTest.scala b/obp-api/src/test/scala/code/api/v2_0_0/CustomerTest.scala
index d67b9d04af..e4fc7074a4 100644
--- a/obp-api/src/test/scala/code/api/v2_0_0/CustomerTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_0_0/CustomerTest.scala
@@ -27,11 +27,14 @@ TESOBE (http://www.tesobe.com/)
package code.api.v2_0_0
+import java.util.Date
import org.json4s._
-import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.model.{BankId, CustomerFaceImage, ErrorMessage}
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON
import code.api.util.APIUtil.OAuth._
-import code.api.util.{ApiRole, ErrorMessages}
+import code.api.util.{APIUtil, ApiRole, ErrorMessages}
+import code.customer.CustomerX
+import code.socialmedia.SocialMediaHandle
import code.api.v1_4_0.JSONFactory1_4_0.CustomerJsonV140
import code.entitlement.Entitlement
import code.setup.DefaultUsers
@@ -133,4 +136,52 @@ class CustomerTest extends V200ServerSetup with DefaultUsers {
}
}
+ // a customer created straight through the provider, at the given bank
+ private def createCustomerAtBank(bankId: String) =
+ CustomerX.customerProvider.vend.addCustomer(
+ BankId(bankId), APIUtil.generateUUID(), "Social Media Test", "+49 123 456", "social-media-test@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")
+
+ feature("Create Customer Social Media Handle only writes to a customer of the bank in the path - v2.0.0") {
+
+ scenario("The CUSTOMER_ID in the path belongs to another bank - v2.0.0") {
+ Given("A customer at the second bank, and the Role at the first bank only")
+ val otherBankCustomer = createCustomerAtBank(testBankId2.value)
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.CanAddSocialMediaHandle.toString)
+
+ When("We post a handle for that customer at the first bank")
+ val request = (v2_0Request / "banks" / testBankId1.value / "customers" / otherBankCustomer.customerId / "social_media_handles").POST <@ (user1)
+ val response = makePostRequest(request, write(SwaggerDefinitionsJSON.socialMediaJSON.copy(customer_number = otherBankCustomer.number)))
+
+ Then("We should get a 404")
+ response.code should equal(404)
+ And("We should get a message: " + ErrorMessages.CustomerNotFoundByCustomerId)
+ response.body.extract[ErrorMessage].message should startWith (ErrorMessages.CustomerNotFoundByCustomerId)
+ And("No handle is stored for that customer")
+ SocialMediaHandle.socialMediaHandleProvider.vend.getSocialMedias(otherBankCustomer.number) should equal(Nil)
+ }
+ }
+
+ feature("Get Customer Social Media Handles only reads a customer of the bank in the path - v2.0.0") {
+
+ scenario("The CUSTOMER_ID in the path belongs to another bank - v2.0.0") {
+ Given("A customer at the second bank with a handle, and the Role at the first bank only")
+ val otherBankCustomer = createCustomerAtBank(testBankId2.value)
+ SocialMediaHandle.socialMediaHandleProvider.vend.addSocialMedias(otherBankCustomer.number, "twitter", "@other-bank-customer", new Date(), new Date())
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.CanGetSocialMediaHandles.toString)
+
+ When("We get the handles of that customer at the first bank")
+ val request = (v2_0Request / "banks" / testBankId1.value / "customers" / otherBankCustomer.customerId / "social_media_handles").GET <@ (user1)
+ val response = makeGetRequest(request)
+
+ Then("We should get a 404")
+ response.code should equal(404)
+ And("We should get a message: " + ErrorMessages.CustomerNotFoundByCustomerId)
+ response.body.extract[ErrorMessage].message should startWith (ErrorMessages.CustomerNotFoundByCustomerId)
+ }
+ }
+
}
diff --git a/obp-api/src/test/scala/code/api/v2_1_0/CreateTransactionTypeTest.scala b/obp-api/src/test/scala/code/api/v2_1_0/CreateTransactionTypeTest.scala
index ca498b9035..dfbb8db225 100644
--- a/obp-api/src/test/scala/code/api/v2_1_0/CreateTransactionTypeTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_1_0/CreateTransactionTypeTest.scala
@@ -35,7 +35,9 @@ import code.api.v2_0_0.{TransactionTypeJsonV200, TransactionTypesJsonV200}
import code.api.v2_0_0.Http4s200.Implementations2_0_0
import code.api.v2_1_0.Http4s210.Implementations2_1_0
import code.setup.DefaultUsers
+import code.TransactionTypes.TransactionType.TransactionTypeProvider
import code.transaction_types.MappedTransactionType
+import net.liftweb.mapper.By
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.{AmountOfMoneyJsonV121, ErrorMessage, TransactionTypeId}
import com.openbankproject.commons.util.ApiVersion
@@ -163,7 +165,7 @@ class CreateTransactionTypeTest extends V210ServerSetup with DefaultUsers {
Then("insert new data and We make the request")
lazy val transactionTypeJSON2 = TransactionTypeJsonV200(
TransactionTypeId("1"), //mockTransactionTypeId,
- "1", //mockBankId.value,
+ testBankId1.value, // the BANK_ID in the URL, so the bad currency, not the bank check, makes the update fail
"1", //short_code
"1 ", //summary,
"1", //description,
@@ -177,6 +179,44 @@ class CreateTransactionTypeTest extends V210ServerSetup with DefaultUsers {
And("We should get a message: " + ErrorMessages.CreateTransactionTypeUpdateError)
responsePut3.body.extract[ErrorMessage].message should startWith (ErrorMessages.CreateTransactionTypeUpdateError)
}
+
+ // the Role is at the BANK_ID in the URL, so a body naming another bank must be refused
+ scenario("We try to create a Transaction Type at another bank than the one in the URL", VersionOfApi210, ApiEndpoint2) {
+ Given("The Authentication at testBankId1 only")
+ setCanCreateTransactionType
+
+ When("We make the request with testBankId2 in the body")
+ val otherBankJSON = transactionTypeJSON.copy(id = TransactionTypeId("other-bank-new"), bank_id = testBankId2.value)
+ val requestPut = (v2_1Request / "banks" / testBankId1.value / "transaction-types").PUT <@ (user1)
+ val responsePut = makePutRequest(requestPut, write(otherBankJSON))
+
+ Then("We should get a 400")
+ responsePut.code should equal(400)
+ responsePut.body.extract[ErrorMessage].message should startWith (ErrorMessages.InvalidJsonValue)
+ And("No Transaction Type was created")
+ MappedTransactionType.count(By(MappedTransactionType.mTransactionTypeId, "other-bank-new")) should equal(0)
+ }
+
+ // an existing type is updated by id, so the id of another bank's type must be refused
+ scenario("We try to overwrite a Transaction Type of another bank by its id", VersionOfApi210, ApiEndpoint2) {
+ Given("A Transaction Type at testBankId2, and the Authentication at testBankId1 only")
+ val otherBankType = transactionTypeJSON.copy(id = TransactionTypeId("other-bank-existing"), bank_id = testBankId2.value, short_code = "other")
+ TransactionTypeProvider.vend.createOrUpdateTransactionType(otherBankType).isDefined should equal(true)
+ setCanCreateTransactionType
+
+ When("We make the request at testBankId1 with that id")
+ val takeOver = otherBankType.copy(bank_id = testBankId1.value, summary = "taken over")
+ val requestPut = (v2_1Request / "banks" / testBankId1.value / "transaction-types").PUT <@ (user1)
+ val responsePut = makePutRequest(requestPut, write(takeOver))
+
+ Then("We should get a 400")
+ responsePut.code should equal(400)
+ responsePut.body.extract[ErrorMessage].message should startWith (ErrorMessages.InvalidJsonValue)
+ And("The other bank's Transaction Type is unchanged")
+ val stored = TransactionTypeProvider.vend.getTransactionType(otherBankType.id).openOrThrowException("type exists")
+ stored.bankId.value should equal(testBankId2.value)
+ stored.summary should equal(otherBankType.summary)
+ }
}
/**
* set CanCreateTransactionType Entitlements to user1
diff --git a/obp-api/src/test/scala/code/api/v2_1_0/TransactionRequestsTest.scala b/obp-api/src/test/scala/code/api/v2_1_0/TransactionRequestsTest.scala
index f840c005e2..af426e1d5d 100644
--- a/obp-api/src/test/scala/code/api/v2_1_0/TransactionRequestsTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_1_0/TransactionRequestsTest.scala
@@ -915,6 +915,63 @@ class TransactionRequestsTest extends V210ServerSetup with DefaultUsers {
}
}
+ feature("Answer Transaction Request Challenge -- SEPA: account and maker-checker checks") {
+
+ // Creates a SEPA request with a challenge from accountId1 and returns its id and challenge id
+ def createSepaRequestWithChallenge(helper: DefaultSetup): (String, String) = {
+ helper.setCurrencyAndAmt("EUR", "EUR", "9000.00")
+ helper.bodyValue = AmountOfMoneyJsonV121("EUR", "9000.00")
+ helper.transactionRequestBodySEPA = helper.transactionRequestBodySEPA.copy(helper.bodyValue)
+ val createResponse = helper.makeCreateTransReqRequestSEPA
+ createResponse.code should equal(201)
+ ((createResponse.body \ "id").values.toString, (createResponse.body \ "challenge" \ "id").values.toString)
+ }
+
+ if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
+ ignore("Answering a request through an account it does not pay from", TransactionRequest) {}
+ } else {
+ scenario("Answering a request through an account it does not pay from", TransactionRequest) {
+ val helper = defaultSetup(SEPA.toString)
+ val (transRequestId, challengeId) = createSepaRequestWithChallenge(helper)
+
+ When("user1 answers the challenge through accountId2, which the request does not pay from")
+ val request = (v2_1Request / "banks" / helper.bankId.value / "accounts" / helper.accountId2.value /
+ SYSTEM_OWNER_VIEW_ID / "transaction-request-types" / SEPA.toString / "transaction-requests" / transRequestId / "challenge").POST <@ (user1)
+ val response = makePostRequest(request, write(ChallengeAnswerJSON(id = challengeId, answer = "123")))
+
+ Then("we get 404 and the request is not paid")
+ response.code should equal(404)
+ response.body.extract[ErrorMessage].message should startWith(InvalidTransactionRequestId)
+ helper.checkBankAccountBalance(false)
+ }
+ }
+
+ if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
+ ignore("The maker answers their own challenge on a view without the maker-checker bypass", TransactionRequest) {}
+ } else {
+ scenario("The maker answers their own challenge on a view without the maker-checker bypass", TransactionRequest) {
+ val helper = defaultSetup(SEPA.toString)
+ val (transRequestId, challengeId) = createSepaRequestWithChallenge(helper)
+
+ Given("a custom view that may add transaction requests but not bypass maker-checker, granted to user1")
+ val view = createCustomRandomView(helper.bankId, helper.accountId1)
+ code.views.system.ViewPermission.resetViewPermissions(view, List(CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT))
+ code.views.Views.views.vend.grantAccessToCustomView(
+ com.openbankproject.commons.model.BankIdAccountIdViewId(helper.bankId, helper.accountId1, view.viewId), resourceUser1)
+
+ When("user1, the maker, answers the challenge through that view")
+ val request = (v2_1Request / "banks" / helper.bankId.value / "accounts" / helper.accountId1.value /
+ view.viewId.value / "transaction-request-types" / SEPA.toString / "transaction-requests" / transRequestId / "challenge").POST <@ (user1)
+ val response = makePostRequest(request, write(ChallengeAnswerJSON(id = challengeId, answer = "123")))
+
+ Then("we get 400 MakerCheckerSameUser and the request is not paid")
+ response.code should equal(400)
+ response.body.extract[ErrorMessage].message should include(MakerCheckerSameUser)
+ helper.checkBankAccountBalance(false)
+ }
+ }
+ }
+
feature("we can create transaction requests -- COUNTERPARTY") {
if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
diff --git a/obp-api/src/test/scala/code/api/v2_2_0/API2_2_0Test.scala b/obp-api/src/test/scala/code/api/v2_2_0/API2_2_0Test.scala
index d9309e49b9..c3686214b0 100644
--- a/obp-api/src/test/scala/code/api/v2_2_0/API2_2_0Test.scala
+++ b/obp-api/src/test/scala/code/api/v2_2_0/API2_2_0Test.scala
@@ -396,7 +396,25 @@ class API2_2_0Test extends V220ServerSetup with DefaultUsers {
And("we should get an error message")
reply.body.extract[ErrorMessage].message.nonEmpty should equal (true)
}
-
+
+ // the view may only get actions the caller already has on one of their views of the account
+ scenario("we will not give a view an action that none of the caller's views has", API2_2, ApiEndpoint3) {
+ Given("A view exists")
+ val bankId = randomBank
+ val bankAccountId = randomPrivateAccountId(bankId)
+ val view = randomView(true, "")
+ val creationReply = postView(bankId, bankAccountId, view, user1)
+ creationReply.code should equal (201)
+ val createdView : ViewJSONV220 = creationReply.body.extract[ViewJSONV220]
+
+ When("we ask for an action that no view of the caller has")
+ val reply = putView(bankId, bankAccountId, createdView.id,
+ viewUpdateJson(createdView).copy(allowed_actions = allowedActions :+ "can_do_what_no_view_of_the_caller_can"), user1)
+ Then("we should get a 403 and the SourceViewHasLessPermission message")
+ reply.code should equal (403)
+ reply.body.extract[ErrorMessage].message should startWith (code.api.util.ErrorMessages.SourceViewHasLessPermission)
+ }
+
scenario("we can not update a System view on a bank account") {
val bankId = randomBankId
val bankAccountId = randomPrivateAccountId(bankId)
diff --git a/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala b/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala
index 3eb6046d92..42ba8bce5a 100644
--- a/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala
+++ b/obp-api/src/test/scala/code/api/v2_2_0/AccountTest.scala
@@ -256,4 +256,33 @@ class AccountTest extends V220ServerSetup with DefaultUsers {
holderUserIds should not contain(resourceUser2.userId)
}
}
+
+ feature("Create Account refuses a consent user as the account holder - v2.2.0") {
+
+ scenario("a User with CanCreateAccount naming a consent user's USER_ID gets 400 InvalidUserId and no account is created") {
+ // A consent user: an agent identity minted by a Consent, which disappears with the Consent.
+ val consentUser = code.users.Users.users.vend.createResourceUser(
+ provider = "create-account-consent-user-test",
+ providerId = Some(code.api.util.APIUtil.generateUUID()),
+ createdByConsentId = Some(code.api.util.APIUtil.generateUUID()),
+ name = Some("consent-user"),
+ email = None,
+ userId = None,
+ createdByUserInvitationId = None,
+ company = None,
+ lastMarketingAgreementSignedDate = None
+ ).openOrThrowException("Expected resource user to be created")
+ Entitlement.entitlement.vend.addEntitlement(testBankId1.value, resourceUser1.userId, ApiRole.canCreateAccount.toString)
+ val accountIdForConsentUser = "CONSENT_USER_ACCOUNT_ID_V220"
+ val response = makePutRequest(
+ (v2_2Request / "banks" / testBankId1.value / "accounts" / accountIdForConsentUser).PUT <@ (user1),
+ write(createAccountBodyFor(consentUser.userId)))
+ response.code should equal(400)
+ (response.body \ "message").extract[String] should include(ErrorMessages.InvalidUserId)
+ (response.body \ "message").extract[String] should include("consent user")
+
+ AccountHolders.accountHolders.vend
+ .getAccountHolders(testBankId1, AccountId(accountIdForConsentUser)) should be (empty)
+ }
+ }
}
diff --git a/obp-api/src/test/scala/code/api/v3_0_0/ViewsTests.scala b/obp-api/src/test/scala/code/api/v3_0_0/ViewsTests.scala
index 6d21dc5797..204a4c0192 100644
--- a/obp-api/src/test/scala/code/api/v3_0_0/ViewsTests.scala
+++ b/obp-api/src/test/scala/code/api/v3_0_0/ViewsTests.scala
@@ -335,7 +335,24 @@ class ViewsTests extends V300ServerSetup {
And("we should get an error message")
reply.body.extract[ErrorMessage].message.nonEmpty should equal (true)
}
-
+
+ // the view may only get actions the caller already has on one of their views of the account
+ scenario("we will not give a view an action that none of the caller's views has") {
+ Given("A view exists")
+ val bankId = randomBankId
+ val bankAccountId = randomPrivateAccountId(bankId)
+ val creationReply = postView(bankId, bankAccountId, postBodyViewJson, user1)
+ creationReply.code should equal (201)
+ val createdView : ViewJsonV300 = creationReply.body.extract[ViewJsonV300]
+
+ When("we ask for an action that no view of the caller has")
+ val reply = putView(bankId, bankAccountId, createdView.id,
+ viewUpdateJson(createdView).copy(allowed_actions = allowedActions :+ "can_do_what_no_view_of_the_caller_can"), user1)
+ Then("we should get a 403 and the SourceViewHasLessPermission message")
+ reply.code should equal (403)
+ reply.body.extract[ErrorMessage].message should startWith (code.api.util.ErrorMessages.SourceViewHasLessPermission)
+ }
+
scenario("we can not update a System view on a bank account") {
val bankId = randomBankId
val bankAccountId = randomPrivateAccountId(bankId)
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/AccountApplicationTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/AccountApplicationTest.scala
new file mode 100644
index 0000000000..72bfba4af8
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v3_1_0/AccountApplicationTest.scala
@@ -0,0 +1,102 @@
+/**
+Open Bank Project - API
+Copyright (C) 2011-2019, 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.v3_1_0
+
+import java.util.Date
+
+import org.json4s._
+import code.accountapplication.AccountApplicationX
+import code.api.util.APIUtil.OAuth._
+import code.api.util.ApiRole.CanGetAccountApplications
+import code.api.util.ErrorMessages.{AccountApplicationNotFound, UserHasMissingRoles}
+import code.api.v3_1_0.Http4s310.Implementations3_1_0
+import code.customer.CustomerX
+import code.entitlement.Entitlement
+import com.github.dwickern.macros.NameOf.nameOf
+import com.openbankproject.commons.model.{BankId, CustomerFaceImage, ErrorMessage, ProductCode}
+import com.openbankproject.commons.util.ApiVersion
+import org.scalatest.Tag
+
+import scala.concurrent.Await
+import scala.concurrent.duration._
+
+class AccountApplicationTest extends V310ServerSetup {
+
+ override def afterAll(): Unit = {
+ super.afterAll()
+ CustomerX.customerProvider.vend.bulkDeleteCustomers()
+ }
+
+ object VersionOfApi extends Tag(ApiVersion.v3_1_0.toString)
+ object ApiEndpoint1 extends Tag(nameOf(Implementations3_1_0.getAccountApplication))
+
+ private def createCustomerAt(bankId: String): String =
+ CustomerX.customerProvider.vend.addCustomer(
+ BankId(bankId), java.util.UUID.randomUUID().toString, "Test Customer", "+49 123 456", "test-customer@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").customerId
+
+ private def createApplication(userId: String, customerId: String): String =
+ Await.result(
+ AccountApplicationX.accountApplication.vend.createAccountApplication(ProductCode("1234BW"), Some(userId), Some(customerId)),
+ 10.seconds
+ ).openOrThrowException("expected the account application to be created").accountApplicationId
+
+ feature("Get Account Application by Id v3.1.0") {
+ scenario("Another User without the Role cannot read a User's application", ApiEndpoint1, VersionOfApi) {
+ val bankId = testBankId1.value
+ val applicationId = createApplication(resourceUser1.userId, createCustomerAt(bankId))
+ val request = (v3_1_0_Request / "banks" / bankId / "account-applications" / applicationId).GET
+
+ When("The applicant reads it")
+ Then("We should get a 200")
+ makeGetRequest(request <@(user1)).code should equal(200)
+
+ When("Another User without CanGetAccountApplications reads it")
+ val response = makeGetRequest(request <@(user2))
+ Then("We should get a 403")
+ response.code should equal(403)
+ response.body.extract[ErrorMessage].message should equal(s"$UserHasMissingRoles$CanGetAccountApplications")
+
+ When("That User has CanGetAccountApplications")
+ Entitlement.entitlement.vend.addEntitlement("", resourceUser2.userId, CanGetAccountApplications.toString)
+ Then("We should get a 200")
+ makeGetRequest(request <@(user2)).code should equal(200)
+ }
+
+ scenario("An application whose customer is at another bank is not found at the bank in the path", ApiEndpoint1, VersionOfApi) {
+ val applicationId = createApplication(resourceUser1.userId, createCustomerAt(testBankId2.value))
+ When("The applicant reads it with another bank in the path")
+ val response = makeGetRequest((v3_1_0_Request / "banks" / testBankId1.value / "account-applications" / applicationId).GET <@(user1))
+ Then("We should get a 404")
+ response.code should equal(404)
+ response.body.extract[ErrorMessage].message should equal(AccountApplicationNotFound)
+ }
+ }
+
+}
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/CustomerAddressTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/CustomerAddressTest.scala
index ff726ba69d..7144812de6 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/CustomerAddressTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/CustomerAddressTest.scala
@@ -231,6 +231,67 @@ class CustomerAddressTest extends V310ServerSetup {
And("error should be " + CustomerAddressNotFound)
response310.body.extract[ErrorMessage].message should startWith (CustomerAddressNotFound)
}
+
+ // The update stores the posted status and the posted state, each in its own field
+ scenario("The posted status is stored as the status, not the state", ApiEndpoint4, VersionOfApi) {
+ for (role <- List(CanCreateCustomer, CanCreateCustomerAddress))
+ Entitlement.entitlement.vend.addEntitlement(bankA, resourceUser1.userId, role.toString)
+ val (customerId, addressId) = customerWithAddress(bankA)
+ When("We update the address with a status different from the state")
+ val request310 = (v3_1_0_Request / "banks" / bankA / "customers" / customerId / "addresses" / addressId).PUT <@(user1)
+ val response310 = makePutRequest(request310, write(postCustomerAddressJson.copy(state = "Some State", status = "inactive")))
+ Then("We should get a 200")
+ response310.code should equal(200)
+ And("the address should keep the posted status and state")
+ val address = response310.body.extract[CustomerAddressJsonV310]
+ address.status shouldBe "inactive"
+ address.state shouldBe "Some State"
+ }
+ }
+
+ // The Delete endpoint deletes an address only of the customer in the path, and only at the bank in the path
+ feature("Delete Customer Address v3.1.0 stays within the bank and the customer in the path") {
+ val bankA = testBankId1.value
+ val bankB = testBankId2.value
+
+ // Creates a customer at the bank, with one address, through the endpoints; returns their ids
+ def customerWithAddress(bank: String): (String, String) = {
+ val customer = makePostRequest((v3_1_0_Request / "banks" / bank / "customers").POST <@(user1), write(postCustomerJson))
+ customer.code should equal(201)
+ val customerId = customer.body.extract[CustomerJsonV310].customer_id
+ val address = makePostRequest((v3_1_0_Request / "banks" / bank / "customers" / customerId / "address").POST <@(user1),
+ write(postCustomerAddressJson))
+ address.code should equal(201)
+ (customerId, address.body.extract[CustomerAddressJsonV310].customer_address_id)
+ }
+
+ scenario("A customer at another bank than the one in the path is not found", ApiEndpoint3, VersionOfApi) {
+ Entitlement.entitlement.vend.addEntitlement(bankA, resourceUser1.userId, CanDeleteCustomerAddress.toString)
+ for (role <- List(CanCreateCustomer, CanCreateCustomerAddress))
+ Entitlement.entitlement.vend.addEntitlement(bankB, resourceUser1.userId, role.toString)
+ val (customerAtB, addressAtB) = customerWithAddress(bankB)
+ When("We delete bank B's customer address through bank A's path")
+ val request310 = (v3_1_0_Request / "banks" / bankA / "customers" / customerAtB / "addresses" / addressAtB).DELETE <@(user1)
+ val response310 = makeDeleteRequest(request310)
+ Then("We should get a 404")
+ response310.code should equal(404)
+ And("error should be " + CustomerNotFoundByCustomerId)
+ response310.body.extract[ErrorMessage].message should startWith (CustomerNotFoundByCustomerId)
+ }
+
+ scenario("An address of another customer than the one in the path is not found", ApiEndpoint3, VersionOfApi) {
+ for (role <- List(CanCreateCustomer, CanCreateCustomerAddress, CanDeleteCustomerAddress))
+ Entitlement.entitlement.vend.addEntitlement(bankA, resourceUser1.userId, role.toString)
+ val (customer1, _) = customerWithAddress(bankA)
+ val (_, addressOfCustomer2) = customerWithAddress(bankA)
+ When("We delete customer 2's address through customer 1's path")
+ val request310 = (v3_1_0_Request / "banks" / bankA / "customers" / customer1 / "addresses" / addressOfCustomer2).DELETE <@(user1)
+ val response310 = makeDeleteRequest(request310)
+ Then("We should get a 404")
+ response310.code should equal(404)
+ And("error should be " + CustomerAddressNotFound)
+ response310.body.extract[ErrorMessage].message should startWith (CustomerAddressNotFound)
+ }
}
// The Create endpoint adds an address only to a customer at the bank in the path
@@ -253,5 +314,25 @@ class CustomerAddressTest extends V310ServerSetup {
}
}
+ // The Get endpoint reads addresses only of a customer at the bank in the path
+ feature("Get Customer Addresses v3.1.0 stays within the bank in the path") {
+ scenario("A customer at another bank than the one in the path is not found", ApiEndpoint2, VersionOfApi) {
+ val bankA = testBankId1.value
+ val bankB = testBankId2.value
+ Entitlement.entitlement.vend.addEntitlement(bankA, resourceUser1.userId, CanGetCustomerAddress.toString)
+ Entitlement.entitlement.vend.addEntitlement(bankB, resourceUser1.userId, CanCreateCustomer.toString)
+ val customer = makePostRequest((v3_1_0_Request / "banks" / bankB / "customers").POST <@(user1), write(postCustomerJson))
+ customer.code should equal(201)
+ val customerAtB = customer.body.extract[CustomerJsonV310].customer_id
+ When("We get the addresses of bank B's customer through bank A's path")
+ val request310 = (v3_1_0_Request / "banks" / bankA / "customers" / customerAtB / "addresses").GET <@(user1)
+ val response310 = makeGetRequest(request310)
+ Then("We should get a 404")
+ response310.code should equal(404)
+ And("error should be " + CustomerNotFoundByCustomerId)
+ response310.body.extract[ErrorMessage].message should startWith (CustomerNotFoundByCustomerId)
+ }
+ }
+
}
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/MeetingsTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/MeetingsTest.scala
index c95f6081e0..d95e1f8a2f 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/MeetingsTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/MeetingsTest.scala
@@ -30,7 +30,7 @@ import com.openbankproject.commons.model.ErrorMessage
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON
import code.api.util.APIUtil.OAuth._
import com.openbankproject.commons.util.ApiVersion
-import code.api.util.ErrorMessages.{InvalidJsonFormat, AuthenticatedUserIsRequired}
+import code.api.util.ErrorMessages.{InvalidJsonFormat, AuthenticatedUserIsRequired, MeetingNotFound}
import code.api.v2_0_0.CreateMeetingJson
import code.api.v3_1_0.Http4s310.Implementations3_1_0
import com.github.dwickern.macros.NameOf.nameOf
@@ -106,6 +106,27 @@ class MeetingsTest extends V310ServerSetup {
val meetingGetJsonV310: MeetingJsonV310 = responseGetOne310.body.extract[MeetingJsonV310]
meetingGetJsonV310 shouldBe meetingJsonV310
}
+
+ // Another user at the bank neither lists nor gets a meeting they do not take part in
+ scenario("We will Create a Meeting and another user cannot list or get it", ApiEndpoint2, ApiEndpoint3, VersionOfApi) {
+ val bankId = randomBankId
+ val request310 = (v3_1_0_Request / "banks" / bankId / "meetings" ).POST<@(user1)
+ val response310 = makePostRequest(request310, write(SwaggerDefinitionsJSON.createMeetingJsonV310))
+ response310.code should equal(201)
+ val meetingId = response310.body.extract[MeetingJsonV310].meeting_id
+
+ Then("user2 does not see the meeting of user1 in getMeetings")
+ val requestGetAll310 = (v3_1_0_Request / "banks" / bankId / "meetings" ).GET<@(user2)
+ val responseGetAll310 = makeGetRequest(requestGetAll310)
+ responseGetAll310.code should equal(200)
+ responseGetAll310.body.extract[MeetingsJsonV310].meetings.map(_.meeting_id) should not contain (meetingId)
+
+ Then("user2 gets a 400 MeetingNotFound for the meeting of user1")
+ val requestGetOne310 = (v3_1_0_Request / "banks" / bankId / "meetings" / meetingId ).GET<@(user2)
+ val responseGetOne310 = makeGetRequest(requestGetOne310)
+ responseGetOne310.code should equal(400)
+ responseGetOne310.body.extract[ErrorMessage].message should startWith (MeetingNotFound)
+ }
}
}
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/ProductAttributeTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/ProductAttributeTest.scala
index 075fc143b6..683eea0428 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/ProductAttributeTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/ProductAttributeTest.scala
@@ -188,6 +188,34 @@ class ProductAttributeTest extends V310ServerSetup {
errorMessage contains (UserHasMissingRoles) should be (true)
errorMessage contains (canGetProductAttribute.toString()) should be (true)
}
+ // a role at one bank must not read a product attribute of another bank or another product
+ scenario("We will call the Get endpoint with an attribute of another bank or another product", ApiEndpoint2, VersionOfApi) {
+ // an attribute owned by another bank, created directly so no role at that bank is needed
+ val otherBankAttribute = Await.result(
+ ProductAttributeX.productAttributeProvider.vend.createOrUpdateProductAttribute(
+ BankId("other-bank-for-product-attribute-get"), ProductCode("PRODUCT_CODE"), None,
+ "other bank attribute", ProductAttributeType.STRING, "12345678", None),
+ Duration.Inf).openOrThrowException("could not create the test attribute")
+ // an attribute of another product at the bank in the path
+ val otherProductAttribute = Await.result(
+ ProductAttributeX.productAttributeProvider.vend.createOrUpdateProductAttribute(
+ BankId(testBankId), ProductCode("OTHER_PRODUCT_CODE"), None,
+ "other product attribute", ProductAttributeType.STRING, "12345678", None),
+ Duration.Inf).openOrThrowException("could not create the test attribute")
+ Entitlement.entitlement.vend.addEntitlement(testBankId, resourceUser1.userId, CanGetProductAttribute.toString)
+ When("We get the other bank's attribute through the bank in the path")
+ val requestOtherBank = (v3_1_0_Request / "banks" / testBankId / "products" / "PRODUCT_CODE" / "attributes" / otherBankAttribute.productAttributeId).GET <@(user1)
+ val responseOtherBank = makeGetRequest(requestOtherBank)
+ Then("We should get a 404")
+ responseOtherBank.code should equal(404)
+ responseOtherBank.body.extract[ErrorMessage].message should equal (ProductAttributeNotFound)
+ When("We get an attribute of another product through the product in the path")
+ val requestOtherProduct = (v3_1_0_Request / "banks" / testBankId / "products" / "PRODUCT_CODE" / "attributes" / otherProductAttribute.productAttributeId).GET <@(user1)
+ val responseOtherProduct = makeGetRequest(requestOtherProduct)
+ Then("We should get a 404")
+ responseOtherProduct.code should equal(404)
+ responseOtherProduct.body.extract[ErrorMessage].message should equal (ProductAttributeNotFound)
+ }
}
feature("Update Product Attribute v3.1.0") {
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/ProductTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/ProductTest.scala
index d27b609c95..d563faeb61 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/ProductTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/ProductTest.scala
@@ -153,6 +153,35 @@ class ProductTest extends V310ServerSetup {
responseGetTree310.code should equal(200)
val productTree: ProductTreeJsonV310 = responseGetTree310.body.extract[ProductTreeJsonV310]
}
+ scenario("We get the product tree of products that are their own or each other's parent", ApiEndpoint1, ApiEndpoint4, VersionOfApi) {
+ Entitlement.entitlement.vend.addEntitlement(testBankId, resourceUser1.userId, CanCreateProduct.toString)
+
+ // CYCLE_A -> CYCLE_B -> CYCLE_A: create both, then point CYCLE_A at CYCLE_B
+ createProduct(code = "CYCLE_A", json = parentPostPutProductJsonV310)
+ createProduct(code = "CYCLE_B", json = parentPostPutProductJsonV310.copy(parent_product_code = "CYCLE_A"))
+ createProduct(code = "CYCLE_A", json = parentPostPutProductJsonV310.copy(parent_product_code = "CYCLE_B"))
+
+ When("We get the tree of CYCLE_A")
+ val responseCycle = makeGetRequest((v3_1_0_Request / "banks" / testBankId / "product-tree" / "CYCLE_A").GET <@(user1))
+ Then("We should get a 200 with each product once, and the nesting ending at the repeat")
+ responseCycle.code should equal(200)
+ val cycleTree = responseCycle.body.extract[ProductTreeJsonV310]
+ cycleTree.code shouldBe "CYCLE_A"
+ cycleTree.parent_product.map(_.code) shouldBe Some("CYCLE_B")
+ cycleTree.parent_product.flatMap(_.parent_product) shouldBe None
+
+ // SELF_PARENT -> SELF_PARENT
+ createProduct(code = "SELF_PARENT", json = parentPostPutProductJsonV310)
+ createProduct(code = "SELF_PARENT", json = parentPostPutProductJsonV310.copy(parent_product_code = "SELF_PARENT"))
+
+ When("We get the tree of SELF_PARENT")
+ val responseSelf = makeGetRequest((v3_1_0_Request / "banks" / testBankId / "product-tree" / "SELF_PARENT").GET <@(user1))
+ Then("We should get a 200 with the product and no parent")
+ responseSelf.code should equal(200)
+ val selfTree = responseSelf.body.extract[ProductTreeJsonV310]
+ selfTree.code shouldBe "SELF_PARENT"
+ selfTree.parent_product shouldBe None
+ }
scenario("Test the getProducts by url parameters", ApiEndpoint3, VersionOfApi) {
When("We need to first create the products ")
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/TaxResidenceTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/TaxResidenceTest.scala
index 3c3ab1e520..e15b407524 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/TaxResidenceTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/TaxResidenceTest.scala
@@ -198,6 +198,26 @@ class TaxResidenceTest extends V310ServerSetup {
}
}
+ // The Get endpoint reads the tax residences only of a customer at the bank in the path
+ feature("Get Tax Residences v3.1.0 stays within the bank in the path") {
+ scenario("A customer at another bank than the one in the path is not found", ApiEndpoint2, VersionOfApi) {
+ val bankA = testBankId1.value
+ val bankB = testBankId2.value
+ Entitlement.entitlement.vend.addEntitlement(bankA, resourceUser1.userId, CanGetTaxResidence.toString)
+ Entitlement.entitlement.vend.addEntitlement(bankB, resourceUser1.userId, CanCreateCustomer.toString)
+ val customer = makePostRequest((v3_1_0_Request / "banks" / bankB / "customers").POST <@(user1), write(postCustomerJson))
+ customer.code should equal(201)
+ val customerAtB = customer.body.extract[CustomerJsonV310].customer_id
+ When("We read bank B's customer's tax residences through bank A's path")
+ val request310 = (v3_1_0_Request / "banks" / bankA / "customers" / customerAtB / "tax-residences").GET <@(user1)
+ val response310 = makeGetRequest(request310)
+ Then("We should get a 404")
+ response310.code should equal(404)
+ And("error should be " + CustomerNotFoundByCustomerId)
+ response310.body.extract[ErrorMessage].message should startWith (CustomerNotFoundByCustomerId)
+ }
+ }
+
feature("Delete the Tax Residence of the Customer specified by CUSTOMER_ID and TAX_RESIDENCE_ID v3.1.0 - Authorized access") {
scenario("We will call the endpoint without the proper Role " + canDeleteTaxResidence, ApiEndpoint3, VersionOfApi) {
diff --git a/obp-api/src/test/scala/code/api/v3_1_0/TransactionTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/TransactionTest.scala
index c911a6dd0d..8de71f8b5c 100644
--- a/obp-api/src/test/scala/code/api/v3_1_0/TransactionTest.scala
+++ b/obp-api/src/test/scala/code/api/v3_1_0/TransactionTest.scala
@@ -47,6 +47,13 @@ import com.openbankproject.commons.model.AmountOfMoneyJsonV121
import com.openbankproject.commons.util.ApiVersion
import org.scalatest.Tag
import org.json4s.native.Serialization.write
+import code.api.attributedefinition.AttributeDefinitionDI
+import code.api.util.APIUtil
+import code.transactionattribute.TransactionAttributeX
+import com.openbankproject.commons.model.{BankId, TransactionId}
+import com.openbankproject.commons.model.enums.{AttributeCategory, AttributeType, TransactionAttributeType}
+import scala.concurrent.Await
+import scala.concurrent.duration._
class TransactionTest extends V310ServerSetup {
@@ -102,6 +109,31 @@ class TransactionTest extends V310ServerSetup {
response310.code should equal(200)
response310.body.extract[TransactionRequestWithChargeJSONs210]
}
+ // an attribute is shown on a view only when its Attribute Definition lists that view
+ scenario("We will Get Transaction by Id - only attributes defined as seen on the view are returned", ApiEndpoint4, VersionOfApi) {
+ val bankId = randomBankId
+ val bankAccount = randomPrivateAccount(bankId)
+ val transaction = randomTransaction(bankId, bankAccount.id, SYSTEM_OWNER_VIEW_ID)
+ val hiddenName = "HIDDEN_" + APIUtil.generateUUID().take(8)
+ val shownName = "SHOWN_" + APIUtil.generateUUID().take(8)
+ Given("two attributes on the transaction, one defined as seen on another view, one on the owner view")
+ Await.result(AttributeDefinitionDI.attributeDefinition.vend.createOrUpdateAttributeDefinition(
+ BankId(bankId), hiddenName, AttributeCategory.Transaction, AttributeType.STRING, "", "", List("accountant"), true), 10.seconds)
+ Await.result(AttributeDefinitionDI.attributeDefinition.vend.createOrUpdateAttributeDefinition(
+ BankId(bankId), shownName, AttributeCategory.Transaction, AttributeType.STRING, "", "", List(SYSTEM_OWNER_VIEW_ID), true), 10.seconds)
+ Await.result(TransactionAttributeX.transactionAttributeProvider.vend.createOrUpdateTransactionAttribute(
+ BankId(bankId), TransactionId(transaction.id), None, hiddenName, TransactionAttributeType.STRING, "secret"), 10.seconds)
+ Await.result(TransactionAttributeX.transactionAttributeProvider.vend.createOrUpdateTransactionAttribute(
+ BankId(bankId), TransactionId(transaction.id), None, shownName, TransactionAttributeType.STRING, "visible"), 10.seconds)
+ When("We get the transaction through the owner view")
+ val request310 = (v3_1_0_Request / "banks" / bankId / "accounts" / bankAccount.id / SYSTEM_OWNER_VIEW_ID / "transactions" / transaction.id / "transaction").GET <@(user1)
+ val response310 = makeGetRequest(request310)
+ Then("We should get a 200 with only the attribute defined for the owner view")
+ response310.code should equal(200)
+ val attributeNames = response310.body.extract[TransactionJsonV300].transaction_attributes.map(_.name)
+ attributeNames should contain (shownName)
+ attributeNames should not contain (hiddenName)
+ }
}
feature(s"$ApiEndpoint2")
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/AccountAccessTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/AccountAccessTest.scala
index 80546593f4..efb82f91e0 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/AccountAccessTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/AccountAccessTest.scala
@@ -32,9 +32,13 @@ import com.openbankproject.commons.model.ErrorMessage
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON
import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.createViewJsonV300
import code.api.util.APIUtil.OAuth._
-import code.api.util.ApiRole
+import code.api.util.{APIUtil, ApiRole}
import com.openbankproject.commons.util.ApiVersion
-import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, CannotRevokeOwnAccountAccess}
+import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, CannotRevokeOwnAccountAccess, UserLacksPermissionCanGrantAccessToViewForTargetAccount, ViewNotFound}
+import code.views.Views
+import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, View}
+import scala.concurrent.Await
+import scala.concurrent.duration.Duration
import code.api.v3_0_0.ViewJsonV300
import code.api.v3_1_0.CreateAccountResponseJsonV310
import code.api.v4_0_0.Http4s400.Implementations4_0_0
@@ -59,6 +63,7 @@ class AccountAccessTest extends V400ServerSetup {
object ApiEndpoint1 extends Tag(nameOf(Implementations4_0_0.grantUserAccessToView))
object ApiEndpoint2 extends Tag(nameOf(Implementations4_0_0.revokeUserAccessToView))
object ApiEndpoint3 extends Tag(nameOf(Implementations4_0_0.createUserWithAccountAccess))
+ object ApiEndpoint4 extends Tag(nameOf(Implementations4_0_0.revokeGrantUserAccessToViews))
lazy val bankId = randomBankId
@@ -81,6 +86,18 @@ class AccountAccessTest extends V400ServerSetup {
createViewViaEndpoint(bankId, accountId, postBodyViewJson, user1)
}
+ // gives resourceUser2 only a new system view, which may revoke itself and grant just the views in canGrant
+ def grantUser2ALimitedSystemView(bankId: String, accountId: String, canGrant: List[String]): View = {
+ val name = "limited" + APIUtil.generateUUID().replace("-", "").toLowerCase
+ val view = Await.result(Views.views.vend.createSystemView(postBodyViewJson.copy(
+ name = name, is_public = false,
+ allowed_actions = (postBodyViewJson.allowed_actions ++ List(code.api.Constant.CAN_GRANT_ACCESS_TO_VIEWS, code.api.Constant.CAN_REVOKE_ACCESS_TO_VIEWS)).distinct,
+ can_grant_access_to_views = Some(canGrant), can_revoke_access_to_views = Some(List(name)))), Duration(30, TimeUnit.SECONDS))
+ .openOrThrowException("the limited system view should be created")
+ Views.views.vend.grantAccessToSystemView(BankId(bankId), AccountId(accountId), view, resourceUser2)
+ view
+ }
+
feature(s"test $ApiEndpoint1 version $VersionOfApi - Unauthorized access") {
scenario("We will call the endpoint without user credentials", ApiEndpoint1, VersionOfApi) {
When("We make a request v4.0.0")
@@ -153,8 +170,49 @@ class AccountAccessTest extends V400ServerSetup {
Then("We should get a 201 and check the response body")
response.code should equal(201)
val views = response.body.extract[List[ViewJsonV300]]
- views.length
+ views.length
+ }
+ }
+ }
+
+ feature(s"test $ApiEndpoint4 version $VersionOfApi - views the caller may not grant, or that do not exist") {
+ scenario("A user with a limited view cannot swap it for the owner view", VersionOfApi, ApiEndpoint4) {
+ val addedEntitlement: Box[Entitlement] = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.CanCreateAccount.toString)
+ val account = try {
+ createAnAccount(bankId, user1)
+ } finally {
+ Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement)
+ }
+ val limitedView = grantUser2ALimitedSystemView(bankId, account.account_id, Nil)
+
+ When("The holder of the limited view asks for the owner view instead")
+ val request = (v4_0_0_Request / "banks" / bankId / "accounts" / account.account_id / "account-access").PUT <@ (user2)
+ val response = makePutRequest(request, write(PostRevokeGrantAccountAccessJsonV400(List(code.api.Constant.SYSTEM_OWNER_VIEW_ID))))
+ Then("We should get a 403, and the user keeps only the limited view")
+ response.code should equal(403)
+ response.body.extract[ErrorMessage].message should include(UserLacksPermissionCanGrantAccessToViewForTargetAccount)
+ Views.views.vend.permission(BankIdAccountId(BankId(bankId), AccountId(account.account_id)), resourceUser2)
+ .map(_.views.map(_.viewId.value)).openOr(Nil) should equal(List(limitedView.viewId.value))
+ }
+
+ scenario("An unknown view id is refused before the caller's access is revoked", VersionOfApi, ApiEndpoint4) {
+ val addedEntitlement: Box[Entitlement] = Entitlement.entitlement.vend.addEntitlement(bankId, resourceUser1.userId, ApiRole.CanCreateAccount.toString)
+ val account = try {
+ createAnAccount(bankId, user1)
+ } finally {
+ Entitlement.entitlement.vend.deleteEntitlement(addedEntitlement)
}
+ val missingViewId = "missing" + APIUtil.generateUUID().replace("-", "").toLowerCase
+ val limitedView = grantUser2ALimitedSystemView(bankId, account.account_id, List(missingViewId))
+
+ When("The user asks for a view that does not exist")
+ val request = (v4_0_0_Request / "banks" / bankId / "accounts" / account.account_id / "account-access").PUT <@ (user2)
+ val response = makePutRequest(request, write(PostRevokeGrantAccountAccessJsonV400(List(missingViewId))))
+ Then("We should get a 404, and the user keeps the limited view")
+ response.code should equal(404)
+ response.body.extract[ErrorMessage].message should include(ViewNotFound)
+ Views.views.vend.permission(BankIdAccountId(BankId(bankId), AccountId(account.account_id)), resourceUser2)
+ .map(_.views.map(_.viewId.value)).openOr(Nil) should equal(List(limitedView.viewId.value))
}
}
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/AtmsTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/AtmsTest.scala
index a2e0e073be..a8d566f94c 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/AtmsTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/AtmsTest.scala
@@ -114,6 +114,19 @@ class AtmsTest extends V400ServerSetup {
response.body.extract[ErrorMessage].message should equal (UserHasMissingRoles + List(canUpdateAtm, canUpdateAtmAtAnyBank).mkString(" or "))
}
}
+
+ // with apiOptions.getAtmsIsPublic=false the ATM list refuses anonymous callers with 401; the prop is read once at startup, so this JVM sees only one branch
+ scenario("Get Atms - no authentication", ApiEndpoint10, VersionOfApi) {
+ When(" Get Atms - no authentication")
+ val requestGetAtmsNoAuth = (v4_0_0_Request / "banks" / bankId.value / "atms").GET
+ val responseGetAtmsNoAuth = makeGetRequest(requestGetAtmsNoAuth)
+ if (code.api.util.APIUtil.getAtmsIsPublic) {
+ responseGetAtmsNoAuth.code should be (200)
+ } else {
+ responseGetAtmsNoAuth.code should be (401)
+ responseGetAtmsNoAuth.body.extract[ErrorMessage].message should equal ($AuthenticatedUserIsRequired)
+ }
+ }
}
feature("Test Create/Update/Get -- successful cases") {
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/CorrelatedUserInfoTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/CorrelatedUserInfoTest.scala
index 472c2e91b5..d857148aed 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/CorrelatedUserInfoTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/CorrelatedUserInfoTest.scala
@@ -34,6 +34,7 @@ import code.api.v4_0_0.Http4s400.Implementations4_0_0
import code.entitlement.Entitlement
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.model.ErrorMessage
+import com.openbankproject.commons.model.enums.UserAttributeType
import com.openbankproject.commons.util.ApiVersion
import org.scalatest.Tag
@@ -135,6 +136,30 @@ class CorrelatedUserInfoTest extends V400ServerSetup {
correlatedEntities.correlated_entities.head.customer.bank_id should be (bankId)
correlatedEntities.correlated_entities.head.users.length should be (1)
}
+
+ // another user linked to the same customer must not see the caller's co-user's personal attributes
+ scenario("We will not get other users' personal attributes", ApiEndpoint2, VersionOfApi) {
+ val customerId = createAndGetCustomerIdViaEndpoint(bankId, resourceUser1.userId)
+ createUserCustomerLink(bankId, resourceUser1.userId, customerId)
+ createUserCustomerLink(bankId, resourceUser2.userId, customerId)
+ val provider = code.users.UserAttributeProvider.userAttributeProvider.vend
+ def addAttribute(userId: String, name: String, isPersonal: Boolean) = scala.concurrent.Await.result(
+ provider.createOrUpdateUserAttribute(userId, None, name, UserAttributeType.STRING, "value", isPersonal),
+ scala.concurrent.duration.Duration(10, "seconds"))
+ addAttribute(resourceUser2.userId, "OTHER_PERSONAL", isPersonal = true)
+ addAttribute(resourceUser2.userId, "OTHER_PUBLIC", isPersonal = false)
+ addAttribute(resourceUser1.userId, "OWN_PERSONAL", isPersonal = true)
+ When("We make a request v4.0.0")
+ val request400 = (v4_0_0_Request / "my" / "correlated-entities").GET <@(user1)
+ val response400 = makeGetRequest(request400)
+ Then("We should get a 200 without the other user's personal attribute")
+ response400.code should equal(200)
+ val entity = response400.body.extract[CorrelatedEntities].correlated_entities.find(_.customer.customer_id == customerId).get
+ val names = entity.users.flatMap(_.user_attributes.map(_.name))
+ names should contain ("OTHER_PUBLIC")
+ names should contain ("OWN_PERSONAL")
+ names should not contain ("OTHER_PERSONAL")
+ }
}
}
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/IbanCheckerJsonTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/IbanCheckerJsonTest.scala
new file mode 100644
index 0000000000..fdfd5de4bb
--- /dev/null
+++ b/obp-api/src/test/scala/code/api/v4_0_0/IbanCheckerJsonTest.scala
@@ -0,0 +1,27 @@
+package code.api.v4_0_0
+
+import com.openbankproject.commons.model.{IbanChecker, IbanDetails}
+import org.scalatest.{FlatSpec, Matchers}
+
+/**
+ * This class tests that the response of POST /obp/v4.0.0/account/check/scheme/iban gives each SEPA
+ * attribute the value of its own field, so a bank's credit transfer support is not shown as its
+ * direct debit support.
+ */
+class IbanCheckerJsonTest extends FlatSpec with Matchers {
+
+ // every SEPA field has a different value, so a shifted mapping shows up
+ private val details = IbanDetails("BIC1", "bank", "branch", "address", "city", "zip", "phone", "country", "DE",
+ sepaCreditTransfer = "credit-transfer", sepaDirectDebit = "direct-debit", sepaSddCore = "sdd-core",
+ sepaB2b = "b2b", sepaCardClearing = "card-clearing")
+
+ "createIbanCheckerJson" should "map each SEPA attribute from its own field" in {
+ val attributes = JSONFactory400.createIbanCheckerJson(IbanChecker(isValid = true, Some(details)))
+ .details.toList.flatMap(_.attributes).map(a => a.name -> a.value).toMap
+ attributes("sepa_credit_transfer") shouldBe "credit-transfer"
+ attributes("sepa_direct_debit") shouldBe "direct-debit"
+ attributes("sepa_sdd_core") shouldBe "sdd-core"
+ attributes("sepa_b2b") shouldBe "b2b"
+ attributes("sepa_card_clearing") shouldBe "card-clearing"
+ }
+}
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ProductFeeTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ProductFeeTest.scala
index 32edb97956..9ba455f7e4 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/ProductFeeTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/ProductFeeTest.scala
@@ -201,10 +201,16 @@ class ProductFeeTest extends V400ServerSetup {
// then we getProductFees
- val requestGetProductFees = (v4_0_0_Request / "banks" / product.bank_id / "products" / product.product_code / "fees").GET
+ val requestGetProductFees = (v4_0_0_Request / "banks" / product.bank_id / "products" / product.product_code / "fees").GET
val responseGetProductFees = makeGetRequest(requestGetProductFees)
- responseGetProductFees.code should equal(200)
-
+ // with apiOptions.getProductsIsPublic=false the fees refuse anonymous callers with 401; the prop is read once at startup, so this JVM sees only one branch
+ if (code.api.util.APIUtil.getProductsIsPublic) {
+ responseGetProductFees.code should equal(200)
+ } else {
+ responseGetProductFees.code should equal(401)
+ responseGetProductFees.body.toString contains(AuthenticatedUserIsRequired) should be (true)
+ }
+
// then we getProductFee
val productFeeId = "xxx"
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala
index 577010ccdb..2f8f8cf744 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/ProductTest.scala
@@ -132,6 +132,38 @@ class ProductTest extends V400ServerSetup {
products.products.size shouldBe 3
}
+ // with apiOptions.getProductsIsPublic=false the product refuses anonymous callers with 401; the prop is read once at startup, so this JVM sees only one branch
+ scenario("We will call the Get Product endpoint without user credentials", ApiEndpoint2, VersionOfApi) {
+ Entitlement.entitlement.vend.addEntitlement(testBankId, resourceUser1.userId, CanCreateProduct.toString)
+ val product: ProductJsonV400 = createProduct(code = "ANONYMOUS_GET_CODE", json = parentPutProductJsonV400)
+ When("We get the product with no authentication")
+ val requestGet400 = (v4_0_0_Request / "banks" / product.bank_id / "products" / product.product_code).GET
+ val responseGet400 = makeGetRequest(requestGet400)
+ if (code.api.util.APIUtil.getProductsIsPublic) {
+ Then("We should get a 200")
+ responseGet400.code should equal(200)
+ } else {
+ Then("We should get a 401")
+ responseGet400.code should equal(401)
+ And("error should be " + AuthenticatedUserIsRequired)
+ responseGet400.body.extract[ErrorMessage].message should equal (AuthenticatedUserIsRequired)
+ }
+ }
+ // with apiOptions.getProductsIsPublic=false the product list refuses anonymous callers with 401; the prop is read once at startup, so this JVM sees only one branch
+ scenario("We will call the Get Products endpoint without user credentials", ApiEndpoint3, VersionOfApi) {
+ When("We get the products with no authentication")
+ val requestGetAll400 = (v4_0_0_Request / "banks" / testBankId / "products").GET
+ val responseGetAll400 = makeGetRequest(requestGetAll400)
+ if (code.api.util.APIUtil.getProductsIsPublic) {
+ Then("We should get a 200")
+ responseGetAll400.code should equal(200)
+ } else {
+ Then("We should get a 401")
+ responseGetAll400.code should equal(401)
+ And("error should be " + AuthenticatedUserIsRequired)
+ responseGetAll400.body.extract[ErrorMessage].message should equal (AuthenticatedUserIsRequired)
+ }
+ }
scenario("Test the getProducts by url parameters", ApiEndpoint3, VersionOfApi) {
When("We need to first create the products ")
diff --git a/obp-api/src/test/scala/code/api/v4_0_0/StandingOrderTest.scala b/obp-api/src/test/scala/code/api/v4_0_0/StandingOrderTest.scala
index b0ad20c8b9..66201beac5 100644
--- a/obp-api/src/test/scala/code/api/v4_0_0/StandingOrderTest.scala
+++ b/obp-api/src/test/scala/code/api/v4_0_0/StandingOrderTest.scala
@@ -101,5 +101,21 @@ class StandingOrderTest extends V400ServerSetup {
errorMessage contains (CanCreateStandingOrderAtOneBank.toString()) should be (true)
}
}
-
+
+ // the mapped provider behind both endpoints must store when.detail, so the response and later reads return it
+ feature(s"test $ApiEndpoint1 version $VersionOfApi - when.detail is stored") {
+ scenario("We create a standing order with a when detail and read it back", ApiEndpoint1, VersionOfApi) {
+ When("We create a standing order through the mapped provider")
+ val created = code.standingorders.MappedStandingOrderProvider.createStandingOrder(
+ bankId, bankAccount.id, "customer-id", "user-id", "counterparty-id",
+ BigDecimal("10"), "EUR", "MONTHLY", "15",
+ new java.util.Date(), new java.util.Date(), None)
+ Then("The created and the stored standing order both carry the when detail")
+ created.map(_.whenDetail) should equal(net.liftweb.common.Full("15"))
+ code.standingorders.MappedStandingOrderProvider.getStandingOrdersByBankAccount(bankId, bankAccount.id)
+ .find(_.standingOrderId == created.map(_.standingOrderId).openOr(""))
+ .map(_.whenDetail) should equal(Some("15"))
+ }
+ }
+
}
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 8779c14833..85650d823f 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
@@ -1595,7 +1595,46 @@ class TransactionRequestsTest extends V400ServerSetup with DefaultUsers {
val response = makePostRequest(requestByUser2, write(helper.transactionRequestBodyCard))
Then("it is refused and no money moves")
- response.code should equal(400)
+ response.code should equal(403)
+ (response.body \ "message").extract[String] should include(InsufficientAuthorisationToCreateTransactionRequest)
+ helper.checkBankAccountBalance(false)
+ }
+ }
+
+ if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
+ ignore("A User with no access to the card's account learns nothing from a wrong card field", ApiEndpoint10) {}
+ } else {
+ scenario("A User with no access to the card's account learns nothing from a wrong card field", ApiEndpoint10) {
+ setPropsValues("transactionRequests_supported_types" -> "SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,ACCOUNT_OTP,SIMPLE,CARD")
+ setPropsValues("CARD_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY")
+ val helper = defaultSetup(CARD.toString)
+
+ When("a User with no view on the card's account presents the right card number with a wrong cvv")
+ val requestByUser2 = (v4_0_0_Request / "transaction-request-types" / "CARD" / "transaction-requests").POST <@ (user2)
+ val wrongCvv = helper.transactionRequestBodyCard.copy(card = helper.transactionRequestBodyCard.card.copy(cvv = "000x"))
+ val response = makePostRequest(requestByUser2, write(wrongCvv))
+
+ Then("it is refused for the missing access, not for the cvv, and no money moves")
+ response.code should equal(403)
+ (response.body \ "message").extract[String] should include(InsufficientAuthorisationToCreateTransactionRequest)
+ helper.checkBankAccountBalance(false)
+ }
+ }
+
+ if (APIUtil.getPropsAsBoolValue("transactionRequests_enabled", false) == false) {
+ ignore("An unknown card number is refused like a card of another User", ApiEndpoint10) {}
+ } else {
+ scenario("An unknown card number is refused like a card of another User", ApiEndpoint10) {
+ setPropsValues("transactionRequests_supported_types" -> "SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,ACCOUNT_OTP,SIMPLE,CARD")
+ setPropsValues("CARD_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY")
+ val helper = defaultSetup(CARD.toString)
+
+ When("a card number that no card has is presented")
+ val unknownCard = helper.transactionRequestBodyCard.copy(card = helper.transactionRequestBodyCard.card.copy(card_number = UUID.randomUUID.toString))
+ val response = makePostRequest(helper.createTransReqRequestCard, write(unknownCard))
+
+ Then("it is refused with the same error as a card of another User, and no money moves")
+ response.code should equal(403)
(response.body \ "message").extract[String] should include(InsufficientAuthorisationToCreateTransactionRequest)
helper.checkBankAccountBalance(false)
}
diff --git a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
index 2d240978cb..826d16a68f 100644
--- a/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
+++ b/obp-api/src/test/scala/code/api/v5_1_0/ConsumerTest.scala
@@ -264,6 +264,8 @@ class ConsumerTest extends V510ServerSetup {
// A Consumer name that is already taken gets a random suffix, and the Consumer table survives
// between runs, so this Consumer gets a name no earlier run can have used.
val myConsumerRequestJson = createConsumerRequestJsonV510.copy(app_name = "myConsumer" + java.util.UUID.randomUUID.toString)
+ // only an instance that enables consumers by default lets the creator ask for an enabled Consumer
+ setPropsValues("consumers_enabled_by_default" -> "true")
val requestApiEndpoint6 = (v5_1_0_Request / "my" / "consumers").POST<@ (user1)
val responseApiEndpoint6 = makePostRequest(requestApiEndpoint6, write(myConsumerRequestJson))
val consumerJson6 = responseApiEndpoint6.body.extract[ConsumerJsonOnlyForPostResponseV510]
@@ -276,6 +278,19 @@ class ConsumerTest extends V510ServerSetup {
consumerJson6.certificate_pem shouldBe createConsumerRequestJsonV510.client_certificate.head
}
+
+ scenario("A user creates their own Consumer asking for it enabled, on an instance that does not enable consumers by default", CreateMyConsumer, VersionOfApi) {
+ // the body's enabled flag used to be taken as is, so any logged-in user got a working key without an admin
+ setPropsValues("consumers_enabled_by_default" -> "false")
+ val myConsumerRequestJson = SwaggerDefinitionsJSON.createConsumerRequestJsonV510.copy(
+ app_name = "myConsumer" + java.util.UUID.randomUUID.toString, enabled = true)
+ When("We create the Consumer with POST /my/consumers")
+ val request = (v5_1_0_Request / "my" / "consumers").POST <@ (user1)
+ val response = makePostRequest(request, write(myConsumerRequestJson))
+ Then("The Consumer is created but stays disabled until an admin enables it")
+ response.code should equal(201)
+ response.body.extract[ConsumerJsonOnlyForPostResponseV510].enabled shouldBe false
+ }
}
}
diff --git a/obp-api/src/test/scala/code/api/v5_1_0/CustomViewTest.scala b/obp-api/src/test/scala/code/api/v5_1_0/CustomViewTest.scala
index 6b53855e0e..a8512bff35 100644
--- a/obp-api/src/test/scala/code/api/v5_1_0/CustomViewTest.scala
+++ b/obp-api/src/test/scala/code/api/v5_1_0/CustomViewTest.scala
@@ -28,12 +28,13 @@ TESOBE (http://www.tesobe.com/)
package code.api.v5_1_0
import org.json4s._
-import code.api.Constant.{SYSTEM_AUDITOR_VIEW_ID, SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID, SYSTEM_OWNER_VIEW_ID, SYSTEM_STAGE_ONE_VIEW_ID}
+import code.api.Constant.{CAN_CREATE_CUSTOM_VIEW, SYSTEM_AUDITOR_VIEW_ID, SYSTEM_MANAGE_CUSTOM_VIEWS_VIEW_ID, SYSTEM_OWNER_VIEW_ID, SYSTEM_STAGE_ONE_VIEW_ID}
import code.api.util.APIUtil.OAuth._
import code.api.util.ErrorMessages._
import code.api.v5_1_0.Http4s510.Implementations5_1_0
import com.github.dwickern.macros.NameOf.nameOf
-import com.openbankproject.commons.model.ErrorMessage
+import code.views.Views
+import com.openbankproject.commons.model.{AccountId, BankId, BankIdAccountId, CreateViewJson, ErrorMessage}
import com.openbankproject.commons.util.ApiVersion
import org.json4s.native.Serialization.write
import net.liftweb.util.StringHelpers
@@ -181,6 +182,33 @@ class CustomViewTest extends V510ServerSetup {
}
}
+ // a source view that may create custom views but not update them must not be able to update one
+ scenario(s"We will call the update endpoint from a view with only can_create_custom_view, $ViewDoesNotPermitAccess", ApiEndpoint2, VersionOfApi) {
+ Given("a custom source view holding can_create_custom_view and the target permissions, but not can_update_custom_view")
+ val createOnlyViewName = "_create_only"
+ val createOnlyView = Views.views.vend.createCustomView(
+ BankIdAccountId(BankId(bankId), AccountId(accountId)),
+ CreateViewJson(
+ name = createOnlyViewName,
+ description = "create only",
+ metadata_view = createOnlyViewName,
+ is_public = false,
+ which_alias_to_use = "",
+ hide_metadata_if_alias_used = false,
+ allowed_actions = CAN_CREATE_CUSTOM_VIEW :: putCustomViewJson.allowed_permissions
+ )
+ ).openOrThrowException(attemptedToOpenAnEmptyBox)
+ Views.views.vend.grantAccessToCustomView(createOnlyView.uid, resourceUser1)
+
+ When("We update a custom view through it")
+ val request510 = (v5_1_0_Request / "banks" / bankId / "accounts" / accountId / "views" / createOnlyView.viewId.value / "target-views" / targetViewId).PUT <@ (user1)
+ val response510 = makePutRequest(request510, write(putCustomViewJson))
+ Then("We should get a 400 naming can_update_custom_view")
+ response510.code should equal(400)
+ response510.body.extract[ErrorMessage].message contains (ViewDoesNotPermitAccess) shouldBe (true)
+ response510.body.extract[ErrorMessage].message contains ("can_update_custom_view") shouldBe (true)
+ }
+
scenario("We will call the endpoint with user credentials", ApiEndpoint1, ApiEndpoint2, ApiEndpoint3, ApiEndpoint4, VersionOfApi) {
When("We make a request v5.1.0")
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala
index 0a00d2f6dd..edcae5ca8f 100644
--- a/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala
+++ b/obp-api/src/test/scala/code/api/v7_0_0/GroupMembershipSyncTest.scala
@@ -54,6 +54,7 @@ class GroupMembershipSyncTest extends V600ServerSetup {
object SyncGroupMember extends Tag("syncGroupMember")
object SyncUserGroups extends Tag("syncUserGroups")
object AddEntitlement extends Tag("addEntitlement")
+ object DeleteGroup extends Tag("deleteGroup")
private def bankId = testBankId1.value
private def grant(role: ApiRole, bank: String = "") =
@@ -176,6 +177,31 @@ class GroupMembershipSyncTest extends V600ServerSetup {
}
}
+ // Deleting a Group takes back the Roles it granted, so its former members cannot keep them.
+ feature("Delete Group") {
+ scenario("a Role only the deleted Group granted is revoked, a Role another Group grants is kept", DeleteGroup, VersionOfApi) {
+ grant(canAddUserToGroupAtAllBanks)
+ grant(canDeleteGroupAtAllBanks)
+ val d1 = "CanTestDeleteGroupRole1"
+ val d2 = "CanTestDeleteGroupRole2"
+ val f = GroupTrait.group.vend.createGroup(Some(bankId), "delete-F", "", List(d1, d2), isEnabled = true).openOrThrowException("F")
+ val h = GroupTrait.group.vend.createGroup(Some(bankId), "delete-H", "", List(d2), isEnabled = true).openOrThrowException("H")
+ val addUser2 = (v6_0_0_Request / "users" / resourceUser2.userId / "group-entitlements").POST <@ (user1)
+ makePostRequest(addUser2, membership(f.groupId)).code should equal(201)
+ makePostRequest(addUser2, membership(h.groupId)).code should equal(201)
+ roleGroup(d2) should equal(Some(f.groupId))
+
+ When("F is deleted")
+ makeDeleteRequest((v6_0_0_Request / "management" / "groups" / f.groupId) <@ (user1))
+ .code should (equal(200) or equal(204))
+ Then("d1, which only F granted, is gone, and d2 is kept, now recorded against H")
+ roleGroup(d1) should equal(None)
+ roleGroup(d2) should equal(Some(h.groupId))
+ Entitlement.entitlement.vend.getEntitlementsByUserId(resourceUser2.userId).toList.flatten
+ .flatMap(_.groupId) should not contain f.groupId
+ }
+ }
+
feature("Sync Group Member") {
scenario("one member of a changed Group, and a user who is not a member", SyncGroupMember, VersionOfApi) {
grant(canAddUserToGroupAtAllBanks)
diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
index d1e37a3402..3f820be760 100644
--- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
+++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala
@@ -619,6 +619,92 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
}
}
+ // ─── market orders scoped to the account in the path ─────────────────────────
+
+ /** Store a market order straight in the connector, settled on `settlementAccountId`. Returns its id. */
+ private def createTestMarketOrder(settlementAccountId: String): String =
+ scala.concurrent.Await.result(
+ code.bankconnectors.Connector.connector.vend.createMarketOrder(
+ testBankId1, testAccountId0, "BUY", BigDecimal("25.0"), BigDecimal("10.0"), settlementAccountId, None),
+ scala.concurrent.duration.Duration(10, "seconds")
+ )._1.openOrThrowException("Expected market order to be created").orderId
+
+ private def marketOrderPath(orderId: String): String =
+ s"/obp/v7.0.0/banks/${testBankId1.value}/accounts/${testAccountId0.value}/views/$SYSTEM_OWNER_VIEW_ID/market/orders/$orderId"
+
+ feature("Http4s700 market orders are scoped to the account in the path") {
+
+ scenario("Refuse to cancel an order of another account (404 OrderNotFound)", Http4s700RoutesTag) {
+ val orderId = createTestMarketOrder(settlementAccountId = "another-account")
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithMethod("DELETE", marketOrderPath(orderId), headers)
+ statusCode shouldBe 404
+ messageOf(json) should include(code.api.util.ErrorMessages.OrderNotFound)
+ }
+
+ scenario("Refuse to get an order of another account (404 OrderNotFound)", Http4s700RoutesTag) {
+ val orderId = createTestMarketOrder(settlementAccountId = "another-account")
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequest(marketOrderPath(orderId), headers)
+ statusCode shouldBe 404
+ messageOf(json) should include(code.api.util.ErrorMessages.OrderNotFound)
+ }
+
+ scenario("Cancel an order of the account in the path", Http4s700RoutesTag) {
+ val orderId = createTestMarketOrder(settlementAccountId = testAccountId0.value)
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithMethod("DELETE", marketOrderPath(orderId), headers)
+ statusCode shouldBe 200
+ (json \ "status") shouldBe JString("cancelled")
+ }
+
+ /** Store a trade straight in the connector between two orders settled on `settlementAccountId`. Returns its id. */
+ def createTestMarketTrade(settlementAccountId: String): String = {
+ val buyOrderId = createTestMarketOrder(settlementAccountId)
+ val sellOrderId = createTestMarketOrder(settlementAccountId)
+ scala.concurrent.Await.result(
+ code.bankconnectors.Connector.connector.vend.createMarketMatch(
+ testBankId1, testAccountId0, buyOrderId, sellOrderId, BigDecimal("5.0"), BigDecimal("25.0"), None),
+ scala.concurrent.duration.Duration(10, "seconds")
+ )
+ // the trade is stored with the match but its id is not returned, so find it by its buy order in the connector's in-memory store
+ val field = code.bankconnectors.LocalMappedConnector.getClass.getDeclaredFields.find(_.getName.endsWith("marketTrades")).get
+ field.setAccessible(true)
+ val trades = field.get(code.bankconnectors.LocalMappedConnector)
+ .asInstanceOf[java.util.concurrent.ConcurrentHashMap[String, com.openbankproject.commons.model.MarketTrade]]
+ trades.values.toArray(Array.empty[com.openbankproject.commons.model.MarketTrade]).find(_.buyOrderId == buyOrderId).get.tradeId
+ }
+
+ def marketTradePath(tradeId: String): String =
+ s"/obp/v7.0.0/banks/${testBankId1.value}/accounts/${testAccountId0.value}/views/$SYSTEM_OWNER_VIEW_ID/market/trades/$tradeId"
+
+ scenario("Refuse to get a trade of another account (404 TradeNotFound)", Http4s700RoutesTag) {
+ val tradeId = createTestMarketTrade(settlementAccountId = "another-account")
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequest(marketTradePath(tradeId), headers)
+ statusCode shouldBe 404
+ messageOf(json) should include(code.api.util.ErrorMessages.TradeNotFound)
+ }
+
+ scenario("Get a trade of the account in the path", Http4s700RoutesTag) {
+ val tradeId = createTestMarketTrade(settlementAccountId = testAccountId0.value)
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequest(marketTradePath(tradeId), headers)
+ statusCode shouldBe 200
+ (json \ "trade_id") shouldBe JString(tradeId)
+ }
+
+ // a settlement needs a trade of the account in the path, so an unknown trade_id is refused as not found
+ scenario("Refuse to request settlement of an unknown trade (404 TradeNotFound)", Http4s700RoutesTag) {
+ val headers = Map("DirectLogin" -> s"token=${token1.value}")
+ val (statusCode, json, _) = makeHttpRequestWithBody("POST",
+ s"/obp/v7.0.0/banks/${testBankId1.value}/accounts/${testAccountId0.value}/views/$SYSTEM_OWNER_VIEW_ID/market/settlements",
+ s"""{"trade_id": "${APIUtil.generateUUID()}", "step": "step1"}""", headers)
+ statusCode shouldBe 404
+ messageOf(json) should include(code.api.util.ErrorMessages.TradeNotFound)
+ }
+ }
+
// ─── message outbox (operator) ────────────────────────────────────────────────
private def seedOutboxRow(status: String): code.messageoutbox.MessageOutbox = {
@@ -2013,6 +2099,33 @@ class Http4s700RoutesTest extends ServerSetupWithTestData {
case _ => fail("Expected JSON object")
}
}
+
+ // The rail filter must apply before paging, so every page holds matches and total counts only that rail.
+ scenario("With rail set, pages and total count only schemes of that rail", Http4s700RoutesTag) {
+ val rail = s"RAIL_${APIUtil.generateUUID().take(6).toUpperCase}"
+ (1 to 3).foreach { i =>
+ RoutingSchemes.routingScheme.vend.createRoutingScheme(
+ scheme = freshSchemeName(s"RL$i"), country = "TZ", category = "ACCOUNT",
+ addressPattern = "^[0-9]{3,20}$", secondaryAddressPattern = None, exampleAddress = "12345678",
+ description = "Rail paging test", downstreamRails = List(rail), status = "ACTIVE",
+ createdByUserId = resourceUser1.userId
+ )
+ }
+ (0 to 2).foreach { offset =>
+ val (statusCode, json, _) = makeHttpRequest(s"/obp/v7.0.0/routing-schemes?rail=$rail&limit=1&offset=$offset")
+ statusCode shouldBe 200
+ json match {
+ case JObject(fields) =>
+ val map = toFieldMap(fields)
+ map.get("routing_schemes") match {
+ case Some(JArray(items)) => items.size shouldBe 1
+ case _ => fail("Expected routing_schemes array")
+ }
+ (json \ "pagination" \ "total") shouldBe JInt(3)
+ case _ => fail("Expected JSON object")
+ }
+ }
+ }
}
feature("Http4s700 getRoutingScheme endpoint") {
diff --git a/obp-api/src/test/scala/code/obp/grpc/SignalChannelsGrpcTest.scala b/obp-api/src/test/scala/code/obp/grpc/SignalChannelsGrpcTest.scala
index b026a65198..0bb1339f3c 100644
--- a/obp-api/src/test/scala/code/obp/grpc/SignalChannelsGrpcTest.scala
+++ b/obp-api/src/test/scala/code/obp/grpc/SignalChannelsGrpcTest.scala
@@ -262,5 +262,53 @@ class SignalChannelsGrpcTest extends ServerSetupWithTestData {
code.api.cache.RedisMessaging.deleteChannel(channelName)
}
+
+ scenario("Subscribe still streams after Redis drops the bus's subscriber connection", GrpcSignal) {
+ if (!redisReachable) cancel("Redis is not reachable from this test JVM")
+ SignalEventBus.isRunning should equal(true)
+ val channelName = s"grpc-reconnect-${java.util.UUID.randomUUID().toString.take(8)}"
+
+ // Addresses of the pattern subscribers (the event buses) currently connected to Redis.
+ def patternSubscribers: Seq[String] = {
+ val jedis = Redis.jedisPool.getResource
+ try jedis.clientList().split("\n").toSeq.filter(_.contains("cmd=psubscribe")).flatMap(_.split(" ").find(_.startsWith("addr="))).map(_.stripPrefix("addr="))
+ finally jedis.close()
+ }
+ val before = patternSubscribers
+ before should not be empty
+ // Drop them, as a Redis restart or network blip would: each bus's subscribe call ends with an error.
+ before.foreach { addr =>
+ val jedis = Redis.jedisPool.getResource
+ try Try(jedis.clientKill(addr)) finally jedis.close()
+ }
+
+ // The buses open new connections after their backoff.
+ val deadline = System.currentTimeMillis() + 15000
+ while (patternSubscribers.forall(before.contains) && System.currentTimeMillis() < deadline) Thread.sleep(100)
+ patternSubscribers.exists(a => !before.contains(a)) should equal(true)
+
+ val received = new java.util.concurrent.ConcurrentLinkedQueue[SignalMessage]()
+ val latch = new CountDownLatch(1)
+ val observer = new StreamObserver[SignalMessage] {
+ override def onNext(value: SignalMessage): Unit = { received.add(value); latch.countDown() }
+ override def onError(t: Throwable): Unit = ()
+ override def onCompleted(): Unit = ()
+ }
+ asyncStub(tokenOf(user1)).subscribe(SubscribeRequest(channelName), observer)
+ val registered = System.currentTimeMillis() + 10000
+ while (SignalEventBus.subscriberCount(channelName) == 0 && System.currentTimeMillis() < registered) Thread.sleep(50)
+ SignalEventBus.subscriberCount(channelName) should equal(1)
+
+ // Several buses reconnect independently: publish until the Signal bus's new subscription delivers.
+ val publishDeadline = System.currentTimeMillis() + 15000
+ while (latch.getCount > 0 && System.currentTimeMillis() < publishDeadline) {
+ blockingStub(tokenOf(user2)).publish(PublishRequest(channelName, "", "hello", """{"after":"reconnect"}"""))
+ latch.await(500, TimeUnit.MILLISECONDS)
+ }
+ latch.getCount should equal(0L)
+ received.peek().payloadJson should equal("""{"after":"reconnect"}""")
+
+ code.api.cache.RedisMessaging.deleteChannel(channelName)
+ }
}
}
diff --git a/scripts/resource_doc_baseline/parity_allowlist.json b/scripts/resource_doc_baseline/parity_allowlist.json
index 2476f76c2e..5e47f4f95b 100644
--- a/scripts/resource_doc_baseline/parity_allowlist.json
+++ b/scripts/resource_doc_baseline/parity_allowlist.json
@@ -131,6 +131,30 @@
}
],
"field_mismatches": [
+ {
+ "version": "v4_0_0",
+ "endpoint": "getAtms",
+ "field": "errorResponseBodies",
+ "reason": "Adds $AuthenticatedUserIsRequired: with apiOptions.getAtmsIsPublic=false the ATM list needs a logged-in user (401), as Lift's inline authenticatedAccess did",
+ "lift_digest": "ad61d5a742fbbdb23c6f9c1b5a094c268b229b3489f9a36a5d38c24a42e7b39d",
+ "http4s_digest": "8ffb72b0f6f2bd94179e19b1bd970a051ad05b0f6bf0050671b0e87026f74da4"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "getProductFees",
+ "field": "description",
+ "reason": "Description uses userAuthenticationMessage(!getProductsIsPublic), as getProduct does, so it says login is required when apiOptions.getProductsIsPublic=false",
+ "lift_digest": "2be1d91bf1dfb9a6360bd5df2d2f23fd87dfa1dc7c904fec19c9d62dde91a131",
+ "http4s_digest": "f59874a7284aa07461182a2a5022edc026149daf084a550491fbae61f1c7fbb3"
+ },
+ {
+ "version": "v4_0_0",
+ "endpoint": "getProductFees",
+ "field": "errorResponseBodies",
+ "reason": "Added AuthenticatedUserIsRequired: the fees need a logged-in user when apiOptions.getProductsIsPublic=false, as Lift's authenticatedAccess required",
+ "lift_digest": "ad61d5a742fbbdb23c6f9c1b5a094c268b229b3489f9a36a5d38c24a42e7b39d",
+ "http4s_digest": "258e59c1dbd6bc624b2c26bc1db7b62eedebccc8a159cc0446075809fbe9fc71"
+ },
{
"version": "v4_0_0",
"endpoint": "deleteExplicitCounterparty",
@@ -627,6 +651,14 @@
"lift_digest": "7ff8ab9d5f5380b422cd7e8e46bc1a4beaa86e5eec2aea467285384a2593404a",
"http4s_digest": "04e78c3b02fef79fb86ea5555090669801dc4b0dec7ea8fdb1d95de1553d472b"
},
+ {
+ "version": "v1_2_1",
+ "endpoint": "updateViewForBankAccount",
+ "field": "errorResponseBodies",
+ "reason": "Added SourceViewHasLessPermission (403): the updated view may only get actions the caller already has on one of their views of the account, as v5.1.0 does",
+ "lift_digest": "4e76cff480673ec28f47a65e0c4b12813faf2f4908ed9db4289b69d82cd415a8",
+ "http4s_digest": "d9336c4032a9807b5124b916d49a67e156cac3705b15d8e9406af542570ed2d1"
+ },
{
"version": "v1_4_0",
"endpoint": "getTransactionRequestTypes",
@@ -683,6 +715,14 @@
"lift_digest": "7ff8ab9d5f5380b422cd7e8e46bc1a4beaa86e5eec2aea467285384a2593404a",
"http4s_digest": "cf5294172b6fea51500e1502a5dcb8efc1818f5c5216d0eb022e2fce18149f92"
},
+ {
+ "version": "v2_2_0",
+ "endpoint": "updateViewForBankAccount",
+ "field": "errorResponseBodies",
+ "reason": "Added SourceViewHasLessPermission (403): the updated view may only get actions the caller already has on one of their views of the account, as v5.1.0 does",
+ "lift_digest": "6c121e45a137edd8e68f1b7446a0482718dea19bcf5889f42dcff4bf01b7dd91",
+ "http4s_digest": "d146b526eaa03066229214dbe23bdc7e4e751f3c4029839962006f09d68d9ea7"
+ },
{
"version": "v3_0_0",
"endpoint": "createViewForBankAccount",
@@ -723,6 +763,22 @@
"lift_digest": "7ff8ab9d5f5380b422cd7e8e46bc1a4beaa86e5eec2aea467285384a2593404a",
"http4s_digest": "cf5294172b6fea51500e1502a5dcb8efc1818f5c5216d0eb022e2fce18149f92"
},
+ {
+ "version": "v3_0_0",
+ "endpoint": "updateViewForBankAccount",
+ "field": "errorResponseBodies",
+ "reason": "Added SourceViewHasLessPermission (403): the updated view may only get actions the caller already has on one of their views of the account, as v5.1.0 does",
+ "lift_digest": "6c121e45a137edd8e68f1b7446a0482718dea19bcf5889f42dcff4bf01b7dd91",
+ "http4s_digest": "d146b526eaa03066229214dbe23bdc7e4e751f3c4029839962006f09d68d9ea7"
+ },
+ {
+ "version": "v3_1_0",
+ "endpoint": "getAccountApplication",
+ "field": "errorResponseBodies",
+ "reason": "Added AccountApplicationNotFound: the application is answered as not found when its customer is at another bank than BANK_ID",
+ "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
+ "http4s_digest": "a2e469898f8038fa40a0400093a93eb90c53622ae413dffe76a4be561177bbcc"
+ },
{
"version": "v3_1_0",
"endpoint": "createAccount",
@@ -2195,6 +2251,14 @@
"lift_digest": "17b115e7d973045ce988c14715fa934e8dbb506b02316d0736dbc19c82ce75b3",
"http4s_digest": "26b2f283fe3019f4c20bec2d8666686efcc376f0250fcfe43933a1c4236c2747"
},
+ {
+ "version": "v1_4_0",
+ "endpoint": "addCustomerMessage",
+ "field": "errorResponseBodies",
+ "reason": "Added UserHasMissingRoles (now requires CanCreateCustomerMessage, as v4.0.0), CustomerNotFoundByCustomerId (also for a customer at another bank) and UserCustomerLinksNotFoundForUser, which the handler returns",
+ "lift_digest": "2c2d0133f9eb6a9b028b5019313ddc8b0a33581a972270282862500abd5485ea",
+ "http4s_digest": "e7397945e5480fb1ddab83233c7561b3bb52a7933f0cd653b655911f49b37c71"
+ },
{
"version": "v2_0_0",
"endpoint": "getTransactionTypes",
@@ -2299,6 +2363,22 @@
"lift_digest": "4da376be772a5df5ca18eb378056a8b6cd06359b9e1b716e39590cddc035bd6b",
"http4s_digest": "d11d7022ff409e6f65b2d8fe4f56b8dd54bfd7744dd946806347c91d3ed355b6"
},
+ {
+ "version": "v2_1_0",
+ "endpoint": "createTransactionType",
+ "field": "errorResponseBodies",
+ "reason": "Added InvalidJsonValue: 400 when body bank_id is not BANK_ID, or body id is a Transaction Type at another bank, so the BANK_ID role cannot write other banks' types",
+ "lift_digest": "61eb67f7b7bd4520f770bd0981aeb6f4a138f6b1fba1f7b31a5d97c83d0e6c0d",
+ "http4s_digest": "cae4be89c9ce78670b474a1cd141ae716a3156064734b11af0c5d223cb8f1043"
+ },
+ {
+ "version": "v2_1_0",
+ "endpoint": "answerTransactionRequestChallenge",
+ "field": "errorResponseBodies",
+ "reason": "Added InvalidTransactionRequestId (404 when the request does not pay from the account in the URL) and MakerCheckerSameUser/MakerCheckerUnknownMaker (maker-checker enforced as in v4.0.0)",
+ "lift_digest": "27cca89e482e0af6a8c21c03bf4729087cead31ee344683dcdcb803e51daf83f",
+ "http4s_digest": "b682cd279b9bc04570517e2e32fcb9b85b73ce9d89452454f5daddc48730a99a"
+ },
{
"version": "v2_0_0",
"endpoint": "createAccount",
@@ -2371,6 +2451,22 @@
"lift_digest": "647479a642a49ee3a80a4bfc28ab2ae3413796d26a3eecbc48411d6e05620ad5",
"http4s_digest": "3e88e7991ab67e6003f7b61db2d636c2d235017e02312ef328559958dc8dd1c3"
},
+ {
+ "version": "v3_1_0",
+ "endpoint": "getCustomerAddresses",
+ "field": "errorResponseBodies",
+ "reason": "Added CustomerNotFoundByCustomerId: a customer at another bank than the one in the path now answers 404, so the bank-scoped Role does not reach other banks' customer addresses",
+ "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
+ "http4s_digest": "8923a626c8aa4e1bc5ac5399e873623126f9fd0cf38da3d11b320d01033719fc"
+ },
+ {
+ "version": "v3_1_0",
+ "endpoint": "getTaxResidence",
+ "field": "errorResponseBodies",
+ "reason": "Added CustomerNotFoundByCustomerId (404): the customer must be at BANK_ID, else a Role at one bank reads tax residences of customers at any bank",
+ "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
+ "http4s_digest": "8923a626c8aa4e1bc5ac5399e873623126f9fd0cf38da3d11b320d01033719fc"
+ },
{
"version": "v3_1_0",
"endpoint": "createTaxResidence",
@@ -2403,6 +2499,14 @@
"lift_digest": "e411ab351074a0055e9e38d197a6c0e27be6eddcc2158d1026e23c6735b9fbd9",
"http4s_digest": "f32444f850ad7c50c97d8f9fa4e9aac93757d7b16e708393903ee64f4e0e7a76"
},
+ {
+ "version": "v3_1_0",
+ "endpoint": "getProductAttribute",
+ "field": "errorResponseBodies",
+ "reason": "Added ProductAttributeNotFound (404): the attribute must belong to the BANK_ID and PRODUCT_CODE in the path, else a role at one bank reads any bank's product attribute",
+ "lift_digest": "f0e3fc093d8749570c7520dabdb672bbb7a6f84d2f03fa9c138762e8c976981a",
+ "http4s_digest": "cbff97326a2c683036eca4d241b6bcc82b9a45d7cee49516184d63fc450c50b7"
+ },
{
"version": "v3_1_0",
"endpoint": "deleteTaxResidence",
@@ -2411,6 +2515,14 @@
"lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
"http4s_digest": "82d851ecfb266d07ca6bcd51e2de6fe66f14941401e4dabdf3a0d2617607b6f1"
},
+ {
+ "version": "v3_1_0",
+ "endpoint": "deleteCustomerAddress",
+ "field": "errorResponseBodies",
+ "reason": "Added CustomerNotFoundByCustomerId and CustomerAddressNotFound (404): the customer must be at BANK_ID and the address must be the customer's, else a bank-level Role deletes addresses at any bank",
+ "lift_digest": "f06cd9a2114625e9b6a37b8e57fa91e7bdb6586b9899fcd9265713aed3e873a4",
+ "http4s_digest": "dd24b8929132c699a27a54ec97308a8aefe2c396aa0c03243d0051fe0ad6d730"
+ },
{
"version": "v5_0_0",
"endpoint": "createUserAuthContextUpdateRequest",