Skip to content

Add Java language support to DynamicResourceDoc - #96

Open
hongwei1 wants to merge 65 commits into
develop-obpfrom
feature/dynamicresourcedoc-java-support
Open

Add Java language support to DynamicResourceDoc#96
hongwei1 wants to merge 65 commits into
develop-obpfrom
feature/dynamicresourcedoc-java-support

Conversation

@hongwei1

Copy link
Copy Markdown
Owner

Summary

  • method_body for a DynamicResourceDoc endpoint can now be Scala (default, unchanged) or Java, mirroring the existing precedent used by ConnectorMethod/DynamicMessageDoc.
  • Compiles Java method_body via the JSR-223 java engine into a native Http4sEndpointIO, reusing the same javax.tools.JavaCompiler backend as createJavaFunction.
  • Validates the actual compiled Java class (not its Scala wrapper) before it is ever invoked, closing a gap the existing ConnectorMethod Java path still has.
  • Converts Java-native return values (Map/List/String/number/boolean) to JValue directly, since Extraction.decompose only reflects Scala types.
  • Rejects unsupported programming_lang values with a 400 before attempting compilation.
  • Fixes two pre-existing call sites (DynamicResourceDocsEndpointGroup, the v6.0.0 dynamic-resource-docs/validate endpoint) that were still constructing CompiledObjects without the new field.

Test plan

  • DynamicUtilJavaHttp4sEndpointTest (3 scenarios): compiled-endpoint adapter unit tests, isolated from the HTTP round trip.
  • DynamicResourceDocJavaTest (3 scenarios): role-gated Java doc 401/403/200, reject-unsupported-language, backward-compat with programming_lang omitted.
  • DynamicResourceDocTest (existing Scala-only suite): unchanged, all 8 scenarios still pass.
  • FrozenClassTest: regenerated frozen_type_meta_data for the new programming_lang field on JsonDynamicResourceDoc.
  • Full local suite (./run_all_tests.sh, 4 shards, 3522 tests): green.

The shipped fixtures could not be imported into a fresh database at all:
POST /obp/v2.1.0/sandbox/data-import answered 400 "Cannot import the sandbox
data" on both example_import.json files. Seven IBANs in the first and one in
the second were each attached to two accounts at two different banks, and
createAccounts rejects a duplicate IBAN.

The rejection is right. An IBAN is globally unique by ISO 13616 -- the bank
identifier is encoded inside the string, so two banks cannot hold the same one
-- and OBP depends on that rather than merely assuming it. LocalMappedConnector's
getBankAccountByRoutingLegacy refuses outright when a routing address matches
more than one account ("Routing MUST be unique"), and that is the lookup a
payment destination resolves through: BulkPaymentHandler and three v7.0.0
transaction paths all call it with bankId = None. Admitting a duplicate would
not produce a working account, it would produce one that any global-routing
payment then fails on, far from the import that caused it.

So the data was wrong, not the check. The strings were not IBANs in the first
place: 27 characters where Bosnia's format is 20, and mod-97 values of 36, 52,
50, 65, 79, 57, 45 and 0 where a valid IBAN gives 1. All sixteen are regenerated
here as structurally valid, globally unique numbers, with a distinct bank code
per bank -- which is the actual mechanism behind that uniqueness rather than a
suffix bolted on to make the strings differ.

The suites that normally cover sandbox import run against a clone of an existing
database, where the accounts already exist and the duplicate branch is never the
one that fires; only a genuinely fresh database reaches it. Hence a new test for
the direction that had none: two accounts at different banks sharing an IBAN must
be rejected, and the same two import cleanly once their IBANs differ.
Of the 850 endpoints a caller can reach, 384 are referenced by no test at all,
and of those that are tested only about a third carry an anonymous-access
scenario. Writing the rest by hand is several hundred near-identical files that
then rot one endpoint at a time; driving them off the ResourceDoc registry means
an endpoint added tomorrow is swept the day it is registered.

Three sweeps, each asserting something the others cannot see:

  AuthSweepTest      anonymous call is refused where the doc says it must be,
                     and is NOT refused where the doc says it is public; a
                     role-gated endpoint refuses a user holding no entitlements
  FailureSweepTest   a fully-entitled caller asking for something that does not
                     exist gets 4xx, never 5xx
  SuccessSweepTest   the endpoints that need no setup at all actually answer

EndpointCatalog is the single place that answers "what exists and what does each
one claim", so that SweepCoverageTest can check an exact identity: swept plus
skipped equals the catalog, with every skip carrying one of three enumerated
reasons. There is no fourth bucket for an endpoint to fall into quietly.

Three things about the source data are easy to get wrong and all three are
load-bearing, so they are written down in EndpointCatalog rather than discovered
again later. The docs live on the Http4s objects; every APIMethods*.scala is now
a stub whose Lift registrations are commented out, so reading those files for a
catalog finds nothing. "Needs authentication" is derived, not declared, and the
predicate has to be evaluated on the constructed ResourceDoc because the
constructor rewrites errorResponseBodies and several docs compute theirs from a
prop. And not every ALL_CAPS URL segment is a placeholder -- SANDBOX_TAN and
EMAIL are real literals, so substitution is driven by what the name says it is
rather than by a copy of the framework's private literal list.

Requests run in-process against Http4sApp.httpApp: no TCP, no server startup,
single-digit milliseconds each. Scenarios are one per version rather than one per
endpoint because ServerSetupWithTestData rebuilds its fixtures for every scenario
and at ~1600 assertions that cost, not the assertions, would dominate. Each
scenario collects every mismatch and fails once with the whole list.
Neither the cache's wire format nor its key derivation appears in any document
the API publishes, so a clean contract diff says nothing about either. The unit
suite is no better placed: RedisDeserializeMissTest round-trips through encode
and decode, and both run on whichever chill is on the classpath, so a format
change is invisible to it by construction -- the new encoder and the new decoder
agree with each other no matter what they agree on.

KryoGoldenCompatTest therefore reads bytes produced OUTSIDE this build.
kryo_golden_chill_0_9_3.txt holds ten values encoded by chill 0.9.3, taken from
a pre-upgrade classpath; it cannot be regenerated once that version is gone from
every tree, and regenerating it with the new chill would turn the file into a
test that chill can read itself. It is force-added past
obp-api/src/test/resources/** for that reason: the rule exists to keep generated
artefacts out, and this one is the opposite -- an input that no longer has a
generator.

What it asserts is narrow on purpose. Not that every value still decodes -- the
upgrade note accepts that some will not, and the consequence is a recompute.
What must never happen is the third outcome: decoding "successfully" into
something different, which is not a cold cache but wrong data served for a full
TTL with nothing in any log to say so. Measured here: nine of ten still decode
correctly, one recomputes, none misreads.

CacheKeyFormatTest pins the whole derived key rather than a substring of it.
The existing assertion checks that the caller's string survives INTO the key,
which passes for any prefix, separator or argument rendering; but invalidation
is pattern matching over the entire key -- NewStyle's
deleteKeysByPattern("*getMethodRoutings*") and the rate-limit patterns in
Caching -- and deleteKeysByPattern returns 0 and swallows a miss, so a broken
pattern reports nothing at all while the cache keeps serving stale routings.
The expected value is written down rather than derived, because a check that
computes its expectation the same way the code does cannot fail. If it breaks
after a library change, the fix is to re-read every deleteKeysByPattern call
site against the new shape first -- the failure IS that review being demanded.
Three of the concurrency and cache-invalidation scenarios guard themselves with
assume(Redis.isRedisReady) and cancel where no Redis answers. CI has declared a
redis service since the job was written, but nothing ever failed when that
service was absent: a cancelled test reports as a pass, so dropping the services
block, or a container that never became healthy, would have taken the rate
limiter's Redis fast path and MethodRouting's cache invalidation out of the run
without changing a single line of any report. Both are shared-state races, which
is the class of defect a green suite is least able to rule out.

RedisTestTarget turns that cancellation into a failure wherever
OBP_TEST_REDIS_REQUIRED is set, which the workflows now set alongside the service
they already had. Developers leave it unset and keep the skip. `required` is a
parameter rather than a direct environment read so that both branches are
reachable from a test -- the environment cannot be changed from inside a running
JVM, and an unreachable branch in a guard is exactly what this is here to stop.

Two smaller holes in the same family:

The zero-test floor in run_tests_parallel.sh was 2000 against a suite its own
comment called "~2900", and the real figure is 3571. A run could have lost a
fifth of its tests and still passed it. Raised to 3200, and both numbers now come
from a measurement rather than from memory.

compile and report had no timeout-minutes while test has had one since it was
written, so a hung Maven resolve blocked the build until GitHub's own six-hour
ceiling rather than the job's.
…dler

Extract the delegate-to-stub, route-to-connector, and metric/trace
recording logic in the StarConnector InvocationHandler into named
local functions. Behaviour is unchanged; this addresses SonarCloud
scala:S3776 (cognitive complexity 39, limit 15) flagged on the
InvocationHandler introduced when replacing CGLib's MethodInterceptor.
…nnot read another's bytes

An empty List, written to Redis by chill 0.9.3 on Scala 2.12, decodes under chill
0.9.5 on 2.13 into a scala.collection.immutable.Queue. The decode SUCCEEDS. It is
the call site, whose signature says List, that dies:

  class scala.collection.immutable.Queue cannot be cast to
  class scala.collection.immutable.List

Measured on GET /management/dynamic-message-docs and GET /management/connector-methods:
200 on 2.12, 500 on 2.13 reading the entry 2.12 wrote, and correct in either version
running alone. The 500 lasts the whole TTL, because a read that throws does not evict
the key. A rolling upgrade, or any upgrade against a warm Redis, produces exactly this.

The migration note anticipated stale entries and described the consequence as a cold
cache. For values that fail to decode that is right. This is the case that does not
fail: it returns the wrong type, and no log line anywhere says so.

Prefixing the key is the fix rather than casting at the call sites. There are eight
List-returning memoized methods today and the same drift can hit any other type, but
more to the point, no amount of care at a call site makes bytes already in Redis
readable. With the prefix, another build's entries are not addressable at all and age
out on their own TTL -- which is what "cold cache after rollout" was supposed to mean.

The prefix carries the Scala binary version, the axis that moved here, plus
obp.cache.serialization.version for the case it does not cover: a dependency upgrade
that changes the encoding while the Scala version stays put, which is what chill 0.9.3
to 0.9.5 would have been on its own.

CacheSerializationNamespaceTest pins the property, not the string. Asserting
"obpser1-scala2.13" would say nothing about whether isolation holds and would turn
every legitimate bump into a test edit. It also asserts the reverse control -- one
namespace must still read its OWN entries -- because an isolation that isolated
everything would pass while disabling the cache.

Verified end to end against two instances sharing one Redis: the two reproductions
now answer 200, the reverse direction still works, and a second call within one
version reuses its key rather than writing a new one.
IdempotencyMiddleware was mounted on one of nineteen route trees. The other
eighteen carry 73 mutating payment endpoints between them -- UK Open Banking v3.1
and v4.0.1, Berlin Group v1.3 and v2, and the v4/v5.1/v6 core versions -- so a
client retrying a payment on any of them repeated the payment.

Tests first, because the middleware had none. IdempotencyMiddlewareTest pins the
four properties a caller relies on when retrying: a same-key retry does not execute
twice, the same key with a different body is refused rather than served the first
response, one consumer's key cannot reach another's, and a 5xx is not cached so a
retry actually retries.

Wiring it up then failed end to end, and the way it failed is the reason this commit
also changes the middleware. Http4sApp composes the version trees with `.orElse`,
where OptionT.none means "not mine, try the next one". runRoutes called
getOrElseF(404), turning a miss into an answer and terminating the chain: measured on
POST /obp/v3.1.0/management/method_routings, which returned 201 without an
Idempotency-Key and 404 with one. That was invisible while the middleware lived only
on v7, the last link. It now returns the miss unchanged, and gives back the lock it
took before running the routes -- otherwise a path this tree does not serve would
hold the key for the lock's full 60s and refuse the request entitled to use it.

Both new properties are pinned. Neither was reachable from the original unit tests:
their inner routes are hand-built and have no fallthrough chain, so only running a
real instance behind the real router could show it.

Installation now has two documented requirements, both tested: inside
ResourceDocMiddleware, because the body hash comes from CallContext.httpBody and
without it every payload hashes alike -- which would return the first caller's
receipt for a second, different payment -- and on every tree, because of the above.

Verified against a live instance on three non-v7 payment paths:
v3.1.0 method_routings, UK OB v4.0.1 domestic-payments and domestic-payment-consents,
and Berlin Group v2 sepa-credit-transfers. Each: first call executes, replay returns
the cached response with Idempotency-Replay: true and no second write, a changed body
under the same key gives 409, and a call without a key is unaffected.
The sweep found ten endpoints whose ResourceDoc disagreed with their behaviour.
They are not one defect; they are four, and only six of the ten needed a product
change. The other four were the sweep being wrong.

Six docs corrected:

  getAllProductsV600, getAllApiProductsV600 -- the description still interpolated
  userAuthenticationMessage(!getProductsIsPublic), so with the prop true it
  published "authentication optional" while the route called withUser
  unconditionally. The route is deliberate: its own comment reads "(all banks;
  auth-required; cached)", and the api-products bucket records why -- "the v6 Lift
  conditional public-access path (getApiProductsIsPublic) is simplified -- public
  gating would be a Phase 3 follow-up if needed". So the doc is what was out of
  step. Restoring the conditional route would have been implementing that follow-up,
  which is a product decision and makes two endpoints public by default.

  getConfigProps, getConnectorTraces -- hand-written "Authentication is Required."
  The constructor matches userAuthenticationMessage(true) verbatim and this is not
  it, so nothing reached errorResponseBodies and both published as public.

  getMyApiCollectionEndpoint, getApiCollectionEndpoints -- these contradicted
  themselves: errorResponseBodies listed $AuthenticatedUserIsRequired while the
  description said userAuthenticationMessage(false). The description wins -- the
  constructor's second branch REMOVES the error body when the text says optional --
  so an explicit declaration was being deleted by a text match.

Four needed no product change, and AuthSweepTest is corrected instead:

  createConsentRequest, getConsentRequest and createVRPConsentRequest require an
  APPLICATION, not a user. All three answer OBP-20200 "The application cannot be
  identified", and createVRPConsentRequest says so in its own prose: "Client,
  Consumer or Application Authentication is mandatory for this endpoint".
  userAuthenticationMessage(false) is accurate. EndpointCatalog.needsAuthentication
  reproduces the middleware's predicate, which reads only errorResponseBodies and
  roles -- both about the user -- so it classified them public and then failed them
  for behaving as documented. A 401 now only fails the check when it is the user
  one; an application 401 is reported as an observation, named rather than
  swallowed, since resource-docs cannot express "needs an application" without
  authMode.

  verifyRequestSignResponse refuses with OBP-20311 "The Request is not signed" -- a
  third mechanism, which authMode cannot model either. Left failing on purpose.

AuthSweepTest: 9 failures before, 2 after. The two that remain are
verifyRequestSignResponse and createTransactionRequestFreeForm answering 500 where
403 was due, both already catalogued.
KryoGoldenCompatTest could not have caught the defect it was written to catch, for
two independent reasons, and both were found the hard way -- by the defect reaching
a running instance.

Its ten golden values were Java collections. What OBP-API memoizes is Scala
collections; java.util.ArrayList appears in none of the providers. And it compared
with ==, under which an empty Scala List EQUALS an empty Queue -- both are Seq, and
Seq equality is element-wise. So an empty List written by chill 0.9.3, decoding
under 0.9.5 as a scala.collection.immutable.Queue, passed a test whose whole purpose
was to notice exactly that, while every call site declaring List failed with
ClassCastException.

Adds kryo_scala_golden_chill_0_9_3.tsv: eleven Scala values encoded on the
pre-migration 2.12 classpath, each recorded with the runtime class it was written
as. That third column is the point -- the assertion is now on the class, because the
class is what a call site depends on. Force-added past .gitignore for the same
reason as the first fixture: it cannot be regenerated once every checkout carries
0.9.5.

Subclassing is not drift. A Vector read back as Vector1 is assignable to every
signature that named Vector and nothing can tell, so the check is isInstance rather
than name equality.

The Nil-to-Queue drift is recorded in a knownDrift baseline rather than left red.
It is a property of two third-party libraries, not something this branch changes,
and the mitigation is elsewhere and tested: Redis.serializationNamespace means a
2.13 instance cannot address a 2.12 entry at all. A permanently red suite is one
people learn to ignore; a baseline entry costs a written reason. Anything not listed
still fails, and a listed name that stops drifting also fails -- a baseline that
outlives its hazard reads as still true to whoever comes next.

Run: java 9/10 decode correctly, scala 9/11 decode into an assignable class,
scala-list-empty and scala-option-none cold on rollout.
…se its skips

Two ways this suite reported green over things it was not checking.

It held a character-for-character copy of the expression that builds the
dependency-whitelist source, rather than calling it. The two only stayed in step
because whoever edited one happened to see the other -- and this is the one compile
that happens reflectively at boot, so a divergence would not have failed at compile
time either. The expression is now
DynamicUtil.Validation.dependenciesScalaCode, named so both callers can reach it,
and the test calls that.

Three sandbox scenarios have been cancelling on every run, everywhere, since the
build moved to a JDK past 17: SecurityManager enforcement was removed by JEP 411 and
finished off by JEP 486, so DynamicUtil.Sandbox is a no-op and `assume` cancels. A
cancelled check reads as a passing one in the summary line people actually look at,
which is how "canceled 0" and three unrun scenarios coexisted. What goes unchecked
is not incidental -- the sandbox is the only thing covering what runtime-compiled
endpoint code may touch.

OBP_TEST_SANDBOX_REQUIRED=true turns the cancellation into a failure, the same lever
RedisTestTarget gives the Redis-dependent checks. Verified both ways: unset gives
succeeded 6 / canceled 3, set gives succeeded 6 / failed 3 with a message naming what
cannot run and why.

The skip still stands by default, because no JDK on this build can enforce. The
difference is that it is now somebody's decision rather than a silence.
A 5xx tells a caller the server broke and the request is worth retrying. Each of
these is a client-side condition, so the retry can never succeed -- and one of them
sits on a payment path, where that loop is the expensive kind.

  getUserInvitation                 secretLink.toLong threw NumberFormatException on
                                    any non-numeric segment -> 400 (InvalidNumber,
                                    which already existed and says exactly this)
  createConsumerDynamicRegistration verifyJwt does not return false for a missing or
                                    unparseable PSD2-CERT, it THROWS ("No PEM-encoded
                                    keys found"), and booleanToFuture only guards the
                                    false case -> 400
  getSignalChannelInfo              a plain RuntimeException for "no such channel"
                                    -> 404, with a new SignalChannelNotFound
                                    (OBP-39021, continuing the Signal series)
  createTransactionRequest          four raw throws -- not authenticated, no such
                                    bank, no such account, no such view -> 401/404
  createTestEmail                   two "server is not configured" checks answered
                                    500. The server is not broken, it is unconfigured,
                                    and neither resolves without an operator editing
                                    props -> 503

getConnectorMethodNames is different in kind: it is a regression this branch's own
base introduced, and it is fixed at the source rather than at the endpoint.

java.lang.reflect.Proxy passes null for a method declaring no parameters; cglib,
which the ByteBuddy swap replaced, passed a zero-length array. Everything downstream
treats args as a collection -- `.zip(args)`, `args.collectFirst`,
`extractKeyParams(args)`. isInheritedMember covers members Connector does not
declare, but a NO-ARGUMENT method that Connector DOES declare slips past it and
lands in routeToConnector. `callableMethods` is exactly that.

Confirmed by running both builds with the same grants against the same request:
GET /obp/v6.0.0/system/connector-method-names answers 200 on the 2.12/cglib build
and 500 on this one, with `Cannot invoke "scala.collection.IterableOnce.knownSize()"
because "that" is null` -- which is `zip` being handed the null. Normalising args at
the handler entry restores what cglib did, which is what a toolchain migration owes
its callers, and closes the whole family rather than this one endpoint.

Sweep after these: 57 tests, 0 failures, 0 suites aborted (was 47/9).
… deviations

Three of the sweep's findings turned out to be the sweep's own defects, and they are
the same defect twice over: a rule copied instead of called.

SuccessSweepTest kept its own placeholder rule, and it had drifted from
EndpointCatalog's -- the catalog substitutes any segment ending in ID, _CODE or
_NAME, this one knew _ID and _CODE and had never learnt _NAME. So
/signal/channels/CHANNEL_NAME/info was a placeholder to the catalog, which duly
replaced it with a channel that does not exist, and NOT a placeholder here, so this
suite selected it as an endpoint needing nothing created first and then failed it
for answering 404. The first half of its condition was vacuous as well:
`concretePath(doc) == concretePath(doc, Map.empty)` compares a default argument with
the same value passed explicitly. Now calls EndpointCatalog.hasPlaceholder.

EndpointCatalog resolved VIEW_ID but not GRANT_VIEW_ID, so an endpoint that looks up
the view before it checks roles answered on the view and the role gate never ran.
That is the same reason a real bank id is already passed for the role assertion.
createTransactionRequestFreeForm was reported as "expected 403, got 500" -- two
defects stacked, the endpoint's raw throw and this placeholder never resolving.

Two deviations remain and are recorded rather than left red, with the reason each is
not a defect:

  verifyRequestSignResponse          refuses with OBP-20311, JWS request signing --
                                     a third mechanism that authMode cannot express,
                                     so neither the doc nor this sweep can declare it
  createTransactionRequestFreeForm   answers 400 rather than 403; the endpoint
                                     deliberately delegates the decision to the
                                     connector and says so in its own comment, and an
                                     existing test depends on it. Whether an
                                     authorisation failure should be 400 at all is a
                                     product question

The exemption list expires on its own: an entry whose endpoint stops deviating fails
the run, and so does one naming an operationId no longer in the catalog. A baseline
that outlives its hazard reads as still true to whoever comes next.

One process note, because it nearly went the other way. The stale-entry scenario was
first written inside the per-version loop, so it registered once per API version and
the duplicate name aborted the whole suite at construction. Maven reported
"Tests: succeeded 31, failed 0" and BUILD SUCCESS over a suite that ran nothing --
the same shape as the cancelled-check and the zero-test cases already fixed on this
branch. Reading `Suites: completed N, aborted 0` alongside the test counts is what
caught it.
…attribute

The audit line read `skipped="N"` off <testsuite>. ScalaTest's JUnit reporter does
not emit that attribute -- it puts a <skipped/> child inside each cancelled
<testcase> -- so the counter was structurally always zero. A run in which an entire
suite cancelled every one of its tests would still have printed "0 skipped/canceled",
and that is the number somebody checks precisely when they suspect tests are not
running.

Measured on the run that prompted this: the audit said 0, the reports held 15 across
eight suites. Twelve were news. NginxForwarderTest (4), Http4sServerIntegrationTest,
EmbeddedRabbitMQ, two v6 integration suites, RootAndBanksTest (2),
BankAccountCreationListenerTest (2) -- integration and infrastructure tests, the
category where a silent skip costs the most. Only DynamicUtilTest's three were
already known, and only because somebody had looked at them by hand.

Counts the child elements, keeping the attribute path first for reporters that do
emit it. Why each one skips, and whether it should, needs environment decisions and
is not part of this change.

Fourth instance of one family on this branch: a cancelled check reporting as a pass,
a zero-test run reporting as a pass, an aborted suite reporting BUILD SUCCESS, and
now a skip counter that could not count. Each sat in the layer whose job is to notice
that tests did not run.
CacheKeyFromArguments renders every parameter that is not annotated
@CacheKeyOmit. CallContext carries per-request state (startTime,
correlationId, url, verb, ipAddress, user), so both keys were unique per
request: the cache could never hit, and getCurrentFxRateCached wrote a fresh
Redis entry per call that lived out its TTL.

getEndpointMappings additionally cached the (mappings, callContext) tuple.
chill/Kryo cannot encode the lambda reachable through
CallContext.resourceDocument, so every write failed and cachePut swallowed it
as "result served uncached" - endpointMapping.cache.ttl.seconds bought nothing
but a WARN per call. A hit would also have handed the caller the originating
request's CallContext.

Split the memoized half into getEndpointMappingsCached(bankId) rather than
annotating callContext on the caller: CacheKeyFromArguments reads the
parameters of the method whose body ends in buildCacheKey, so binding the
result to a val first leaves it with no parameters and it emits
Nil.mkString("_") - an empty argument segment, i.e. every bankId sharing one
entry. Verified with javap that the key now renders bankId :: Nil, and that
getCurrentFxRateCached renders bankId :: from :: to :: Nil.

Add invalidateEndpointMappingCache() on create/update/delete, mirroring
invalidateMethodRoutingCache: while callContext was in the key nothing could
hit, so a stale entry was unreachable by construction; now that the cache
works, writes have to publish themselves.
The scope key was derived from the consumer id alone, with no method, path,
or resolved-operation component. Reusing one Idempotency-Key across two
different endpoints (same or empty request body on both, e.g. two DELETEs)
made the second call replay the first's cached response instead of
executing: the caller was told an operation succeeded that never ran.

This risk existed narrowly within v7.0.0 before the middleware was wired
onto every version tree; wiring it onto all ~17 trees widened it to the
entire API surface, and the middleware's own test only covered
consumer-scoping, not endpoint-scoping.

Fold the resolved operation id into the scope hash. operationId is set by
ResourceDocMiddleware once it matches a ResourceDoc, and is stable across
path-template placeholders and bridge-cascade path rewrites, unlike the raw
request path. A tree that finds no ResourceDoc match falls back to
method+path; that fallback only ever feeds a lock-then-release cycle a miss
discards, so it does not need to be canonical, only present.

Added a regression test reproducing the collision (two different endpoints,
same consumer, same key, empty bodies on both sides) and updated the
in-flight test's manually-planted lock key, whose comment already said "same
derivation the middleware uses" -- it now is.
createTransactionRequest wrapped the whole view lookup (Views.views.vend
.systemView(...).or(.customView(...))) inside tryons(..., 404, ...), which
catches any Exception the block raises and reports it via the given failCode
regardless of cause. A connection-pool exhaustion, a transient SQL error, or
a Mapper bug during that lookup was therefore indistinguishable from a
genuine "no such view" -- both produced the same 404, telling a client with
retry logic to stop retrying a payment request that would in fact succeed
once the backend recovered.

Split the lookup out of the exception-swallowing block: only a lookup that
completes successfully and returns an empty Box is a genuine client-side
not-found and maps to 404; anything the lookup itself throws now propagates
untouched, so it falls through to ErrorResponseConverter's catch-all (500)
like any other unexpected server-side failure.

Extracted into resolveCreateTransactionRequestView so the distinction is
unit-testable without a live Mapper connection.
createConsumerDynamicRegistration wrapped the whole JwtUtil.verifyJwt call
in tryons(PostJsonIsNotSigned, 400, ...), which catches any Exception the
block raises regardless of cause. verifyJwt goes through Nimbus JOSE
(JWK.parseFromPEMEncodedObjects, SignedJWT.parse, RSASSAVerifier), and a
missing signature algorithm in the JVM's registered security providers (a
hardened/FIPS JRE, a stripped provider list, a provider-registration bug)
surfaces there as a JOSEException wrapping NoSuchAlgorithmException -- caught
by the same blanket tryons and reported to the caller as "your JSON is not
signed" (400), even though nothing about their certificate or JWT is wrong
and every other caller would fail identically until an operator fixes the
JVM.

Walk the exception's cause chain for NoSuchAlgorithmException/
NoSuchProviderException before deciding the status code: that shape
propagates untouched (500, via ErrorResponseConverter's catch-all), while
everything else -- a malformed PEM, an unparseable JWT, an actual signature
mismatch -- still maps to 400 as before.

Extracted into resolveJwtSignatureValid so the distinction is unit-testable
without live PEM/JWT material.
resetPasswordUrl reported a missing public_obp_portal_url/portal_external_url
as 400 -- a bare Future.failed(new Exception(s"$IncompleteServerConfiguration
...")) whose message starts with "OBP-10056: ", which
ErrorResponseConverter's OBP-prefix path promotes only to
{401,403,408,429} and defaults everything else to 400. An admin resetting a
user's password was told their request was bad when the actual problem is
an operator who hasn't configured the portal URL yet.

This is the identical condition Http4s700's createTestEmail already reports
as 503 in this same codebase, with the reasoning that a 500 (or, worse, a
400) tells a caller with retry logic the fault is transient or their own,
when neither resolves without an operator editing props. Route the same
message through tryons with an explicit 503 instead of a bare exception, so
it bypasses the 400 default entirely.

Extracted into resolveResetPasswordPortalUrl so the distinction is
unit-testable without touching Props.
SweepCoverageTest's "the failure sweep covers the same set the auth sweep
does" scenario computed authScope and failureScope from the exact same
literal expression, typed out twice
(catalog.filter(EndpointCatalog.skipReason(_).isEmpty).map(_.operationId).toSet).
Two independent copies of one expression are equal by construction: the
scenario could never fail, even after a real future divergence where one
sweep grows a filter of its own and the endpoints that fall between the two
are covered by neither -- silently, forever, because the guard was
comparing two copies of itself rather than each sweep's actual coverage.

Expose each sweep's own definition of what it covers as
AuthSweepTest.scope / FailureSweepTest.scope, and have both the sweep's own
byVersion grouping and SweepCoverageTest's comparison read that single
definition. A change to either sweep's filtering is now automatically
reflected on both sides of the comparison instead of needing to be kept in
sync by hand.

SweepCoverageDriftCheckTest scans SweepCoverageTest.scala's source rather
than asserting at runtime: a value-equality check on the current catalog
cannot distinguish "read from the real source" from "coincidentally equal
duplicate" -- both produce the identical Set today, and the difference only
matters for whether a FUTURE divergence gets caught, which no runtime
assertion against today's catalog can exercise.
operationId is set on the CallContext only when ResourceDocMiddleware's
matcher finds a ResourceDoc for the current version tree; every other
tree in the .orElse fallthrough chain sees it absent. Since this
middleware is installed on every tree, a miss still acquired and
released a lock keyed on a method+path fallback before falling through
-- and two requests racing that same miss-tier lock (two genuinely
concurrent copies of a request destined for a later tier, or two
different endpoints whose fallback happened to collide) could have the
loser answered a definite 409 by a tier that was never going to serve
either of them, before the request ever reached its real destination.

Gate the lock/response-key path on operationId being present. A
miss-tier now passes straight through to the wrapped routes with no
Redis round trip at all, removing both the false-conflict window and
the redundant lookups on every tier a mutating request passes through
before reaching the one that serves it. Key-format validation stays
unconditional, since a malformed header is a client error regardless
of which tier eventually resolves the path.
…ep placeholders

isPlaceholder's ends-with-ID/_CODE/_NAME heuristic left these three
ALL_CAPS segments untouched, treating them as literals. All three are
enumerated values a live endpoint validates inline, not literals:
Http4sBGv2PIS's payment-service branches guard on
Set("payments","bulk-payments","periodic-payments").contains(paymentService),
and the auth-context-updates/consent SCA branches guard on
List(SMS, EMAIL[, IMPLICIT]).contains(scaMethod). Sent verbatim as
"PAYMENT_SERVICE"/"SCA_METHOD", both guards fail and the sweep reports
the resulting 404/400 as the endpoint's own defect -- reproducing today
for SCA_METHOD via OBPv5.0.0-createUserAuthContextUpdateRequest.

Add all three to isPlaceholder and give each a real accepted value in
concretePath's defaultValue, the same way VIEW_ID resolves to "owner"
so an endpoint's real logic runs instead of failing at an entity check
before the assertion under test ever gets exercised.
Now that CallContext is out of getEndpointMappingsCached's key, the
memoized entry can actually be hit -- which means the single delete in
invalidateEndpointMappingCache leaves a real window: a reader that
fetched the pre-write value from the provider a moment earlier can
still finish its own cache write after the delete completes, silently
reintroducing the stale entry for the rest of endpointMapping.cache.ttl.seconds
with nothing left to clear it before the next write.

Schedule a second delete shortly after the first, delay configurable
via endpointMapping.cache.invalidation.delay.ms (default 500ms). Any
straggler write that lands in the gap gets cleared moments later
instead of surviving the full TTL.
… checks

The floor comparison reads "${SF_TOTAL:-0}" -lt 3200, but the message
printed on failure still said "(< 2000 floor)" -- left over from before
the threshold was raised. A run producing e.g. 2500 tests is correctly
failed by the check, but the printed diagnostic reads as
self-contradictory (2500 is not less than 2000) to whoever is reading
the CI log to find the cause.

Add a source-scan test pinning that the two numbers match, the same
drift-guard shape SweepCoverageDriftCheckTest already uses for a Scala
file, so a future threshold change can't silently leave the message
behind again.
FailureSweepTest.omniscientUser and SuccessSweepTest.entitledCaller
were byte-for-byte identical bodies (grant every ApiRole to
resourceUser1, build a DirectLogin header), and AuthSweepTest,
FailureSweepTest and SuccessSweepTest each looked up
LocalMappedConnector.getBanksLegacy(None) independently. None of it
lived in EndpointCatalog, the module this package already treats as
the one place shared sweep logic belongs.

Add SweepFixtures with realBankId and omniscientCaller, mixed into all
three test classes; AuthSweepTest's realEntities now calls the shared
realBankId instead of repeating the lookup. A source-scan test pins
each construction to exactly one occurrence across the package, the
same drift-guard shape SweepCoverageDriftCheckTest already uses.
SonarCloud flagged "code.example.Provider.getAll(Some(bank))" (3x) and
"obpser1-scala2.13" (4x) as duplicated string literals. Named as
SampleCallerKey and CurrentNamespace; no behavior change.
SonarCloud flagged "Idempotency-Key" (4x), "Bearer c1" (15x) and
"/not-served-by-this-tree" (3x) as duplicated string literals. Named
as IdempotencyKeyHeaderName, Consumer1Auth and UnservedPath; the three
uri"..." literal constructions became Uri.unsafeFromString(UnservedPath)
so they could reference the constant. No behavior change.
SonarCloud flagged "a check" as duplicated 6 times. Named as
CheckLabel; no behavior change.
…registry

The resource-docs dispatcher serves the BG v1.3 alias (active only when
berlin_group_v1_3_alias_path is set) through its ScannedApis registration,
but APIUtil.allStaticResourceDocs never included it. Its docs carry their
own operation ids, re-derived from the alias version string, so alias
operation ids failed the getAllResourceDocs membership check used by
api-collection-endpoint creation and other operation-id lookups -- the
same gap BGv2 had before it was added to this union. Reproduced against a
running instance with the alias prop set (OBP-40048 on a valid alias
operation id) and confirmed the fix resolves it.
The alias surface is gated by berlin_group_v1_3_alias_path, which is
unset in the default test environment, so its operation-id list is
legitimately empty there -- skip the non-empty assertion for it while
still running the membership check against getAllResourceDocs.
…ocs dispatcher

Every one of its ~19 arms was `case X => resourceDocs`, unchanged --
a leftover from the pre-http4s Lift route-filter era that stopped doing
any filtering once the corresponding version moved fully onto http4s.
getResourceDocsList now feeds resourceDocs directly into
activePlusLocalResourceDocs, with identical output.
hongwei1 and others added 29 commits August 31, 2026 17:24
…urce docs

DynamicResourceDocJavaTest's role-gated scenario duplicated the same
17-line 401/403/200 assertion block already in DynamicResourceDocTest,
which SonarCloud's new-code duplication gate flagged. Both now share
V400ServerSetup.assertRoleGated401Then403Then200.
…evel coverage

berlin_group_v1_3_alias_path could not be toggled per-test at runtime:
its ScannedApiVersion identity is captured once by ScannedApis.
versionMapScannedApis' process-wide classpath scan (a lazy val, shared
across the whole JVM/shard), which gets forced by the first unrelated
request that falls through Http4sApp's route chain -- almost always
long before any test-specific setPropsValues call. The only way to
exercise a real alias operation id end to end is to have the prop
already set before the JVM boots.

Set berlin_group_v1_3_alias_path=0.6/v1 in test.default.props (local)
and both CI workflows' generated test.default.props (build_pull_request.
yml, build_container.yml). Add a regression test in
ApiCollectionEndpointTest mirroring the existing per-standard coverage
(OBPv6.0.0/UK Open Banking/Berlin Group v1.3 canonical) for the alias's
BGv1-getPaymentInitiationStatus operation id, and pin the same
operation id in ResourceDocRegistryParityTest alongside the existing
BGv2-getAccountDetails pin.
Kept in sync with frozen_type_meta_data (regenerated by the previous
commit) via code.util.FrozenMetaDataText -- FrozenMetaDataTextTest
fails when the two disagree.
…ift instance

The global operation-id union used to be built from the v6.0.0
aggregation, so operation ids belonging to endpoints that exist only in
v7.0.0 were absent from it and could not be added to an API collection.
That drift instance had no regression test: the OBPv6.0.0-* cases in
ApiCollectionEndpointTest pass under both the old v6-based union and the
current v7-based one, so they cannot detect it.

Pin OBPv7.0.0-getMyMetrics (v7-only -- not part of
Http4sResourceDocAggregation.v600) as a real api-collection-endpoint
request, and add the matching named pin in ResourceDocRegistryParityTest
alongside the BGv2 and Berlin Group v1.3 alias ones, so all three
historical drift instances now have both HTTP-level and registry-level
coverage.
Two bugs made createJavaHttp4sEndpoint's validation a no-op in
practice, discovered by manually enabling dynamic_code_compile_validate_enable
against a real Java method_body that calls a non-whitelisted OBP method:

- Javassist's LoaderClassPath reads a class's bytecode via
  classLoader.getResourceAsStream(...), but the compiler's
  ch.obermuhlner.scriptengine.java.MemoryClassLoader only overrides
  loadClass() and never exposes compiled bytes as a classpath resource.
  getDynamicCodeDependentMethods silently found nothing to check no
  matter what the Java code called. Fix: read the bytes from the
  classloader's private byte map and register them with Javassist
  directly via ByteArrayClassPath.

- Once dependency lookup worked, a rejection was still swallowed:
  JsonResponseException never sets a Throwable message, so wrapping
  the validation call in Box.tryo turned it into
  Failure(null, Full(theException), Empty), and
  DynamicEndpoints.scala's `case Failure(msg: String, ...)` pattern
  fails to match a null msg -- silently falling through to a generic
  "compiled code return nothing" error and discarding the real
  rejection reason. Fix: run validation outside any Box.tryo, mirroring
  how the Scala path's CompiledObjects.validateDependency() (also never
  tryo-wrapped) already lets the exception propagate uncaught.

Manually verified end-to-end: a Java method_body calling
code.api.util.APIUtil.getPropsValue (not on the dependency whitelist)
is now rejected with 400 OBP-40046, naming the exact forbidden call.
…efined order

Two defects from the registry refactor, both in how allStaticResourceDocs
was assembled.

Folding every per-version aggregation into the union added 287 operation
ids it never carried (the older aggregations are not subsets of the v7
one -- an endpoint dropped after v4 keeps its operation id there), and 234
of those collide on partialFunctionName with an entry already present.
Http4s600's top-apis and popular-apis and JSONFactory6.0.0's metrics all
build `partialFunctionName -> operationId` with `.toMap`, where the last
entry wins, so with v1.2.1 sorting last the reported operation_id flipped
to the oldest id: getBanks became OBPv1.2.1-getBanks, root became
OBPv1.2.1-root. Restrict the union to obpUnionVersion (the current OBP
aggregation) plus every non-OBP standard. Consequence, deliberate and
documented at the constant: an operation id living only in a superseded
aggregation stays unresolvable, exactly as before the refactor.

The scanned half of the registry was a plain Map, so the same `.toMap`
consumers resolved a partialFunctionName shared by two scanned standards
according to hash iteration order -- undefined, and free to shift when a
standard is added or removed. The Berlin Group v1.3 alias re-stamps the
canonical BG v1.3 docs and so collides with BG v2 on getAccountDetails
and four other names, and test.default.props now activates that alias for
every test run. Sort it by fullyQualifiedVersion into a ListMap; BG v2
then wins those names, matching the behaviour before this branch.

ResourceDocRegistryParityTest follows the narrowed union and regains the
per-surface non-empty assertion, without which a standard whose docs stop
being registered passes as a trivial subset. A new scenario pins
obpUnionVersion as the newest OBP-standard version in the registry, so
adding a v8 aggregation without moving it fails instead of silently
dropping v8-only operation ids.

Verified against a running instance: OBPv1.2.1-getBanks and
OBPv3.0.0-getAggregateMetrics are rejected with OBP-40048 again, while
BGv2-getAccountDetails, BGv1-getPaymentInitiationStatus and
OBPv7.0.0-getMyMetrics still resolve. Full local suite 3573/0.
DynamicUtil.Validation's dependency whitelist and Constant.SHOW_USED_CONNECTOR_METHODS
were final vals, computed once when first touched (in practice during
server boot, before any test scenario runs). setPropsValues -- the
standard per-scenario props override used throughout this test suite --
mutates Props.lockedProviders, which only reaches code that re-reads
Props on each call; it cannot un-freeze an already-computed field.
That made this security path untestable without a real server restart,
which is how the earlier manual verification (see prior commit) had
to prove it.

Changed the whole SHOW_USED_CONNECTOR_METHODS / Validation dependency
chain from val to def so it re-evaluates from live props on each call.
This costs nothing extra in production: the one expensive step,
DynamicUtil.compileScalaCodeUnchecked, is already memoized by the exact
source string, so re-evaluating is a cache hit unless the underlying
props value actually changed.

DynamicResourceDocJavaSecurityValidationTest exercises the resulting
create-time HTTP path directly: a Java method_body calling
code.api.util.APIUtil.getPropsValue (not on the dependency whitelist)
is rejected with 400 OBP-40046, naming the exact forbidden call --
the same assertion the manual verification made by hand.
"".split("/") returns Array(""), not an empty array, so berlinGroupV13AliasPath
was List("") on a default instance -- nonEmpty. Every downstream
`if (berlinGroupV13AliasPath.nonEmpty)` guard therefore took its ACTIVE branch
with an empty prefix: Http4sBGv13Alias published 55 docs stamped with the
degenerate ScannedApiVersion("", "", ""), whose operation ids came out as
`BG-<name>`, and its route bridge matched the prefix "/" (every request) only
to fall through again.

That was invisible while the alias sat outside the global operation-id union.
Now that this branch folds it in, those 55 junk ids became resolvable: verified
against a running default instance that api-collection-endpoint creation
accepted BG-getAccountDetails and BG-getPaymentInitiationStatus with 201,
naming endpoints no route serves. Filtering empty segments makes "unset" mean
"inactive" again -- both now return 400, while BGv1.3, BGv2, UK and OBP ids are
unaffected and /resource-docs/BGv1.3/obp still serves its 55 docs.

OBP_BERLIN_GROUP_1_3_Alias.apiVersion has to guard .head/.last against the now
genuinely empty list: the ScannedApis classpath scan catches a throwing
companion and only logs a warning, so an unguarded NoSuchElementException would
drop the alias silently. Inactive registrations keep the empty-string version,
which deliberately does not equal ConstantsBG.berlinGroupVersion1 -- colliding
there would let this doc-less object win ScannedApis' .toMap and blank out the
canonical BG v1.3 resource docs.

The alias assertions in both tests no longer depend on a prop that only exists
in a gitignored file. test.default.props is excluded by .gitignore:21, so the
CI workflows carried berlin_group_v1_3_alias_path while a fresh clone or an IDE
runner did not: deleting the line locally reproduced two failures whose
messages gave no hint a prop was missing. They now cancel with an explanatory
message when the alias is inactive, and read the expected operation id back
from the alias's own docs instead of hard-coding the BGv1- prefix, which is
derived from the configured path. Verified both ways: with the prop set 13/13
pass, without it 11 pass and 2 cancel. Full local suite 3573/0.
…passed

Two follow-ups from reviewing the registry work itself.

The scanned half was sorted by fullyQualifiedVersion, which concatenates
apiStandard.toUpperCase and apiShortVersion and can therefore collide across
distinct keys -- ("BG", "v1.3") and ("BGV", "1.3") both render "BGV1.3", and
berlin_group_v1_3_alias_path lets a deployment choose the alias's half of such
a pair. sortBy is only stable with respect to its input, and the input is the
unordered ScannedApis.versionMapScannedApis, so a tie would hand the order back
to hash iteration and with it the `.toMap` winner for a shared
partialFunctionName. Sort by (apiStandard, apiShortVersion) instead: that pair
is exactly ScannedApiVersion's equals/hashCode key, so two distinct keys of
that Map always differ in it and the order is total. The resulting sequence is
unchanged -- alias, BG v1.3, BG v2, UK 2.0/3.1/4.0.1 -- so BG v2 keeps winning
the names it shares with the alias.

The obpUnionVersion guard ranked versions with ApiVersionUtils.versions.indexOf,
which returns -1 for anything absent from that equally hand-maintained list. A
-1 loses every maxBy comparison, so adding a v8.0.0 aggregation to the registry
while forgetting ApiVersionUtils.versions left v7 as the maximum and the
scenario green -- precisely the two-places-to-edit slip it was written to catch.
Assert first that every OBP version in the registry can be ranked at all.
Verified by injecting an unregistered OBPv8.0.0: the guard now fails with
"OBP versions in the registry but missing from ApiVersionUtils.versions:
OBPv8.0.0", where before it passed. Full local suite 3573/0.
any grant targeting a consent user redirects to its granting human with
a warn-log; sole exemption is createdByProcess == consent_user.
ConsentUtil.addEntitlements tags its writes accordingly.
  - process column retired: parameter and accessor removed from the
    Entilement trait and implementation
    group queries now key on group_id; GroupEntitlementJsonV600 exposes
    created_by_process instead of process.
  - Explicit-target guards (400, "…names a consent user…"):
    addEntitlement v2.0.0 + v7.0.0; addUserToGroup v6;
    grantUserAccessToViewById v5.1; createAccountAccessRequest v6
    (reject at creation) plus repeated check at its approval;
    createAccount endpoints v2.0.0, v3.1.0, v4.0.0 (regular +
    settlement), v5.0.0, v7.0.0.
  - Implicit-target resolution to the accountable user (currently a
    human): bank-creator grants (v2.2, v5, v6, v7 incl. the
    generated-bank endpoint), v6
    dynamic-entity creator roles, v3.0.0 entitlement-request requester,
    createAccount owner fallbacks, and the connector-internal
    HOLDING-account holder.
  - Rename: effectiveHumanUserId → accountableUserId
createJavaHttp4sEndpoint memoized the whole Box[Http4sEndpointIO] --
including whatever Validation.validateDependency decided -- keyed only
by the exact method_body string. Once dynamic_code_compile_validate_enable
and the whitelist became live-reloadable (previous commit), this became
a bypass: compile a Java source while validation is off, then turn
validation on and tighten the whitelist, then resubmit the identical
source in a new create/update call -- the cached Full(...) from the
first, unvalidated compile is returned directly, and validateDependency
never runs a second time.

Split the memoization so only the actual javax.tools.JavaCompiler
invocation is cached (deterministic given the source, and the one
genuinely expensive step); dependency validation now runs on every
call, unmemoized, exactly like the Scala path's
CompiledObjects.validateDependency() already does.

Also corrected an overclaiming comment: the val-to-def change makes
Validation.allowedRuntimePermissions itself always current, but
Sandbox.sandbox(bankId) separately caches the whole Sandbox it builds
per bankId, so a sandbox built before a permissions-prop change keeps
the old snapshot. Left that cache alone -- SecurityManager enforcement
is already a no-op on this JDK (JEP 486), so neither the stale nor the
fresh permission list is actually enforced either way.

DynamicResourceDocJavaSecurityValidationTest gains a regression
scenario reproducing the exact bypass sequence: compile the malicious
source once with validation off, enable strict validation, resubmit
the identical source under a new doc, and assert it is still rejected.
…I version

Two defects found reviewing the registry against the union it replaced.

Berlin Group and UK Open Banking both publish getBalances, getAccountList and
getAccountBalances. Http4s600's top-apis/popular-apis and JSONFactory6.0.0's
metrics resolve a partialFunctionName with `.toMap`, which keeps the LAST
matching entry, so registry order decides the operation_id they report. The
hand-written union listed UK before BG, giving Berlin Group all three; sorting
the scanned standards alphabetically put UK last and silently flipped them to
UKv4.0.1-getBalances, UKv2.0-getAccountList and UKv2.0-getAccountBalances.
Replace the alphabetical sort with an explicit standardPrecedence (UK Open
Banking, then Berlin Group) and move Berlin Group v1.3 out of the explicit
block so it is ordered by that precedence rather than pinned ahead of it. A
standard absent from the list -- including the alias, whose apiStandard is
whatever berlin_group_v1_3_alias_path names -- ranks below all of them and can
never override a first-class standard. Verified against a running instance:
the three names resolve to BGv1.3-getBalances, BGv2-getAccountList and
BGv2-getAccountBalances again, matching the values measured before this branch.

A configuration-gated standard that is switched off reports
ScannedApiVersion("", "", ""), whose fullyQualifiedVersion is "" as well. While
ScannedApis kept that registration, ApiVersionUtils.valueOf("") resolved
successfully and, because the resource-docs route tolerates an empty path
segment, GET /obp/v7.0.0/resource-docs//obp answered 200 with an empty document
list where any other unknown version string gets 400 InvalidApiVersionString.
Drop unaddressable registrations in ScannedApis.versionMapScannedApis, which
fixes ApiVersionUtils, ResourceDocRegistry and Boot's version enablement in one
place. Verified: that request now returns 400, and BG v1.3, BG v2, UK 4.0.1 and
OBP v7.0.0 still serve 55, 22, 89 and 1031 docs. With the alias no longer
registered while inactive it is not a registry surface at all, so the parity
test's now-unreachable "cancel when unconfigured" branch is removed.

Both defects reached a green CI because nothing asserted either value; two
scenarios now pin them. Full local suite 3575/0.
enableStrictValidation() wrapped setPropsValues with an expression
body (def ... = setPropsValues(...)), which .github/scripts/
check_test_isolation.py's brace-based scanner does not recognize as a
safe "helper" scope -- it only classifies a def as safe when it finds
an opening brace immediately following the def name. The call was
flagged as running at class-instantiation time and failed CI's lint
step before compilation even started. Verified locally with
python3 .github/scripts/check_test_isolation.py.
…r_methods

getDynamicCodeDependentMethods and APIUtil.getDependentMethods were both
gated by SHOW_USED_CONNECTOR_METHODS, an unrelated introspection/reporting
toggle that defaults to false. This meant dynamic_code_compile_validate_enable
alone did nothing: with the reporting prop left at its default, the bytecode
scan always returned an empty dependency list, so every dynamic-code call
passed the whitelist check regardless of what it actually invoked.

Add a force parameter that Validation.validateDependency sets to true,
making security validation controlled solely by
dynamic_code_compile_validate_enable as documented. The existing caller in
DynamicCompileEndpoint keeps its prior behaviour unchanged.
createJavaHttp4sEndpoint re-registered the compiled class's bytes into the
shared Javassist ClassPool on every call, including compile-cache hits.
ClassPool.appendClassPath has no dedup, so a long-running process serving
the same dynamic endpoint repeatedly (e.g. on each resourceDocs-list
rebuild) grew the ClassPool's classpath chain without bound.

Move the registration inside the compile memoization block so it runs
exactly once per distinct source string, tied to the same cache lifetime
as the compiled class itself.
…lumn

getJsonDynamicResourceDoc passed Lang.get straight through as a
constructor argument. Rows created before the Lang column existed have a
genuine SQL NULL there, and an explicit null argument bypasses
JsonDynamicResourceDoc's own "Scala" default -- that default only applies
when the argument is omitted. Legacy rows therefore reported
programming_lang as null/empty instead of the documented default.

Fall back explicitly with Option(...).filter(isNotBlank).getOrElse("Scala"),
matching the pattern already used for the other nullable text fields in
the same method. Added a regression test that forces a genuine NULL via
raw SQL (bypassing the ORM, which always writes "Scala") and asserts GET
still reports "Scala".
…red name

standardPrecedence ranked a version by its apiStandard string, and the alias
takes that string from the first segment of berlin_group_v1_3_alias_path. A
deployment may point it at a name an existing standard already uses:
configured as "BG/v9" the alias reports ScannedApiVersion("BG", "BG", "v9"),
ranks alongside Berlin Group, and -- sorting after "v2" on the tie-breaker --
comes last, so its re-stamped copies won getBalances, getAccountList and
getAccountBalances away from the canonical docs it had copied. Metrics,
top-apis and popular-apis would then report BGv9-getBalances instead of
BGv1.3-getBalances. The comment on standardPrecedence claimed the opposite,
that the alias "can never override a first-class standard no matter how a
deployment configures it".

Match the alias by identity instead and rank it below every listed standard,
which makes that claim true for any configuration. sortKey takes the derived
alias version as a curried parameter and is package-private so the guarantee
can be tested against a synthetic alias, rather than only under whichever
berlin_group_v1_3_alias_path the JVM happens to have booted with.

Verified both directions with the new scenario: reverting to the string-based
rank fails it with "(1,BG,v9) was not less than (1,BG,v2)" -- the mechanism
itself -- and it passes with the fix. Full local suite 3576/0.
… dynamic resource doc

Two compounding bugs, both required to reproduce and both required to fix,
found by running the feature against a real packaged jar and a real
Postgres database instead of the in-process test harness:

1. Every Java method_body implements Supplier<Function<Object[], Object>>
   per convention. javac always erases that generic Supplier.get() to a
   synthetic bridge method (Object get()) whose body just invokevirtual-calls
   the real, properly-typed get() -- an ordinary same-class call. The
   dependency scanner's same-class exemption only recognised a
   Scala-specific mangled-name convention, so this call was treated as a
   dependency on a forbidden method: the dynamically-compiled class lives
   under the OBP-owned code.* package, but its randomly-generated name can
   never appear in a static whitelist.

2. CompiledObjects.validateDependency() re-validated this.partialFunction a
   second time after DynamicUtil.createJavaHttp4sEndpoint had already
   validated the real compiled Java class internally. For Java,
   this.partialFunction is OBP's own Http4sEndpointIO wrapper, not user
   code, and its bytecode legitimately calls internal OBP helpers
   (javaValueToJValue, logger, CustomJsonFormats.formats, compactRender)
   that were never meant to be whitelisted.

Together these meant dynamic_code_compile_validate_enable=true rejected
every Java doc unconditionally, benign or not -- invisible to the existing
test suite because no scenario combined a genuinely benign Java body with
strict validation turned on.

Fix getDynamicCodeDependentMethods to recurse through any same-class
self-call rather than treat it as a leaf dependency, and make
CompiledObjects.validateDependency() a no-op for the Java language (the
real validation already happened inside createJavaHttp4sEndpoint).
…alias-resource-doc-registry

refactor: single source of truth for resource-doc registry (closes BG v1.3 alias gap)
…weep-and-cache-contracts

test: endpoint sweeps and serialization contracts, and the defects they found
…ourcedoc-java-support

# Conflicts:
#	obp-api/src/main/scala/code/api/util/DynamicUtil.scala
…scan

The Supplier.get() bridge-method exemption added for Java dynamic resource
docs only unrolled one level: it appended a same-class method's own callees
as raw dependency tuples instead of recursing into them. A Java method_body
that factors logic into its own private helper methods (fully ordinary Java)
reintroduces the exact same false rejection one level deeper -- the
un-recursed helper's callees get flagged as calls to the compiled class's
own randomly-generated, unwhitelistable name.

Replace the one-level unroll with a proper recursive expansion that follows
same-class calls to arbitrary depth until a genuinely foreign dependency is
reached, with a visited-set cycle guard: direct or mutual recursion between
same-class private methods (e.g. a factorial/fibonacci helper) is ordinary
Java and must not recurse forever, and a cycle correctly contributes nothing
further rather than falling back to a leaf that gets rejected the same way.

Applies uniformly to both the new Java same-class case and the pre-existing
Scala nested-closure case, which had the identical one-level limitation.
POST /obp/v6.0.0/management/dynamic-resource-docs/validate re-implemented
the request_verb and example_request_body preconditions the v4 create
endpoint checks, but not the programming_lang check added alongside Java
support. CompiledObjects falls through to the Scala compile path for any
value it doesn't recognise as "java"/"Java", so validate would report
valid=true for a body that happens to compile as Scala under a bogus or
misspelled programming_lang -- a verdict that contradicts what the create
endpoint would actually do (400 DynamicCodeLangNotSupport), defeating the
point of a dry-run validation endpoint.

Add the same language check, mirroring Http4s400's
validateDynamicResourceDocBody. No prior test exercised this endpoint at
all; added coverage for the rejection and for both supported languages.
resourceDocs' per-row catch swallows any exception from toResourceDoc and
logs it as "likely stored under the deprecated Lift contract -- re-author
the body against the new native contract", regardless of cause. That is
accurate for a genuine compile failure, but CompiledObjects.validateDependency
now runs fresh on every construction (not just at create/update time), so a
previously-registered doc can start throwing JsonResponseException here too
-- purely because dynamic_code_compile_validate_dependencies was tightened
after it was registered. The endpoint silently drops from the listing
either way, but the log sends whoever reads it toward rewriting a body that
was never the problem.

Catch JsonResponseException separately and report the actual whitelist
rejection reason (via the same JsonResponseExtractor pattern Http4s600's
validate endpoint already uses for this exception shape), falling back to
the existing generic message for every other cause.

No dedicated test: this only changes log wording for a rejection path
already covered end-to-end by DynamicResourceDocJavaSecurityValidationTest;
asserting on log content here would need capture infrastructure this
codebase doesn't otherwise use for a diagnostics-only change.
allowedCompilationMethods was changed from val to def so it observes a
live props override (needed for setPropsValues-driven tests), but
validateDependency's collect guard reads it twice per dependency tuple --
Map.get and a separate .exists scan. Each read re-derives the whitelist
from scratch: a prop read, a regex-based rewrite of the whitelist source
into compilable Scala, and a ConcurrentHashMap lookup keyed on that
~1.5KB string. For N dependency tuples that is up to 2N re-derivations
where one would do.

Bind it to a val at the top of the method instead. Still observes props
changes between calls (each validateDependency invocation re-binds), just
not multiple times within the same call.
createJavaHttp4sEndpoint reflectively reads the compiled class's bytes out
of MemoryClassLoader's private mapClassBytes map to hand them to Javassist
for dependency validation. The lookup result was passed straight to
ByteArrayClassPath unchecked: if a future java-scriptengine version keys
that map differently (internal name vs binary name, or drops the field
entirely), get() returns null, and the failure only surfaces later as an
opaque NPE inside Javassist -- nothing at the actual point of breakage
indicates the cause was this reflective coupling to an internal field.

Check for null and throw immediately with a message that names the field
and the class it was looked up for. The throw happens inside the same
Box tryo the rest of the compile step already runs in, so it is reported
the same way any other compile failure is -- no behavior change on the
success path.

Also documents (no functional change) the pre-existing, unbounded-by-design
memoClassPool/dynamicCompileResult caching tradeoff that the Java compile
path now shares: each distinct compiled method_body retains its ClassLoader
and ClassPool for the life of the process. This is consistent with the
existing Scala compile cache's design (dynamic resource doc creation is
gated behind operator-only roles, not open to arbitrary callers) rather
than a new gap introduced here.
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants