diff --git a/.github/scripts/test_speed_report.py b/.github/scripts/test_speed_report.py new file mode 100644 index 0000000000..25a9d5744d --- /dev/null +++ b/.github/scripts/test_speed_report.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Parse surefire XML reports from all shards and print an http4s-vs-Lift +per-test speed table to stdout (plain text) and, if GITHUB_STEP_SUMMARY +is set, append a markdown version to that file. + +Usage: + python3 test_speed_report.py + + should contain the extracted artifacts from all shards, +e.g. after downloading test-reports-shard{1,2,3} into one directory. +""" + +import os +import sys +import glob +import xml.etree.ElementTree as ET +from collections import defaultdict + +# --------------------------------------------------------------------------- +# Classification +# --------------------------------------------------------------------------- + +# These suites run a real embedded server — they pay the same DB/HTTP cost +# as Lift integration tests. +HTTP4S_INTEGRATION_SUITES = { + "code.api.v7_0_0.Http4s700RoutesTest", + "code.api.v7_0_0.Http4s700TransactionTest", + "code.api.http4sbridge.Http4sLiftBridgePropertyTest", + "code.api.http4sbridge.Http4sServerIntegrationTest", + "code.api.v5_0_0.Http4s500SystemViewsTest", +} + + +def categorize(suite_name: str) -> str | None: + """Return a display category or None to exclude from the table.""" + # http4s integration (real server) + if suite_name in HTTP4S_INTEGRATION_SUITES: + return "http4s v7 — integration" + + # http4s unit/pure (no server) — everything http4s-flavoured that isn't + # in the integration set above + if ( + "Http4s" in suite_name + or "http4s" in suite_name + or "v7_0_0" in suite_name + or suite_name.startswith("code.api.util.http4s.") + or suite_name.startswith("code.api.berlin.group.v2.Http4sBGv2") + ): + return "http4s v7 — unit/pure" + + # Lift versions + for v in ("v6_0_0", "v5_1_0", "v5_0_0", "v4_0_0", "v3_1_0", "v3_0_0", + "v2_2_0", "v2_1_0", "v2_0_0", "v1_4_0", "v1_3_0", "v1_2_1"): + if v in suite_name: + major = v[1] # "1" … "6" + return f"Lift v{major}" + + return None # exclude (util, berlin group non-http4s, etc.) + + +# --------------------------------------------------------------------------- +# Parse +# --------------------------------------------------------------------------- + +def collect(reports_root: str) -> dict: + stats = defaultdict(lambda: {"tests": 0, "time": 0.0}) + + pattern = os.path.join(reports_root, "**", "TEST-*.xml") + for path in glob.glob(pattern, recursive=True): + try: + root = ET.parse(path).getroot() + name = root.get("name", "") + tests = int(root.get("tests", 0)) + time = float(root.get("time", 0)) + if tests == 0: + continue + cat = categorize(name) + if cat is None: + continue + stats[cat]["tests"] += tests + stats[cat]["time"] += time + except Exception: + pass + + return stats + + +# --------------------------------------------------------------------------- +# Render +# --------------------------------------------------------------------------- + +CATEGORY_ORDER = [ + "http4s v7 — unit/pure", + "http4s v7 — integration", + "Lift v6", + "Lift v5", + "Lift v4", + "Lift v3", + "Lift v2", + "Lift v1", +] + + +def render_plain(stats: dict) -> str: + col_w = [25, 7, 12, 10] + sep = "+-" + "-+-".join("-" * w for w in col_w) + "-+" + hdr = "| " + " | ".join( + h.center(w) for h, w in zip( + ["Category", "Tests", "Total time", "Avg/test"], col_w + ) + ) + " |" + + lines = [sep, hdr, sep] + for cat in CATEGORY_ORDER: + if cat not in stats: + continue + d = stats[cat] + avg = d["time"] / d["tests"] if d["tests"] else 0 + row = "| " + " | ".join([ + cat.ljust(col_w[0]), + str(d["tests"]).rjust(col_w[1]), + f"{d['time']:.1f}s".rjust(col_w[2]), + f"{avg:.3f}s".rjust(col_w[3]), + ]) + " |" + lines.append(row) + lines.append(sep) + return "\n".join(lines) + + +def render_markdown(stats: dict) -> str: + rows = ["## http4s v7 vs Lift — per-test speed", + "", + "| Category | Tests | Total time | Avg/test |", + "|---|---:|---:|---:|"] + for cat in CATEGORY_ORDER: + if cat not in stats: + continue + d = stats[cat] + avg = d["time"] / d["tests"] if d["tests"] else 0 + rows.append(f"| {cat} | {d['tests']} | {d['time']:.1f}s | {avg:.3f}s |") + + # Highlight ratio + u = stats.get("http4s v7 — unit/pure") + lift_times = [stats[c]["time"] for c in CATEGORY_ORDER if c.startswith("Lift") and c in stats] + lift_tests = [stats[c]["tests"] for c in CATEGORY_ORDER if c.startswith("Lift") and c in stats] + if u and lift_tests: + lift_avg = sum(lift_times) / sum(lift_tests) + unit_avg = u["time"] / u["tests"] + rows += [ + "", + f"> **Unit/pure tests are {lift_avg/unit_avg:.0f}× faster than Lift integration tests** " + f"({unit_avg:.3f}s vs {lift_avg:.3f}s per test).", + ] + return "\n".join(rows) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + + stats = collect(sys.argv[1]) + if not stats: + print("No matching surefire XML reports found.", file=sys.stderr) + sys.exit(0) + + print(render_plain(stats)) + + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if summary_path: + with open(summary_path, "a") as f: + f.write("\n") + f.write(render_markdown(stats)) + f.write("\n") diff --git a/.github/workflows/build_container.yml b/.github/workflows/build_container.yml index 3329c22773..47e5166cae 100644 --- a/.github/workflows/build_container.yml +++ b/.github/workflows/build_container.yml @@ -8,32 +8,201 @@ env: DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} DOCKER_HUB_REPOSITORY: obp-api +# --------------------------------------------------------------------------- +# compile — compiles everything once, packages the JAR, uploads classes +# test — 4-way matrix downloads compiled output and runs a shard of tests +# docker — downloads compiled output, builds and pushes the container image +# +# Wall-clock target: +# compile ~10 min (parallel with setup of test shards) +# tests ~8 min (4 shards in parallel after compile finishes) +# docker ~3 min (after all shards pass) +# total ~21 min (vs ~30 min single-job) +# --------------------------------------------------------------------------- + jobs: - build: + + # -------------------------------------------------------------------------- + # Job 1: compile + # -------------------------------------------------------------------------- + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: "11" + distribution: "adopt" + cache: maven # caches ~/.m2/repository keyed on pom.xml hash + + - name: Setup production props + run: | + cp obp-api/src/main/resources/props/sample.props.template \ + obp-api/src/main/resources/props/production.default.props + + - name: Compile and install (skip test execution) + run: | + # -DskipTests — compile test sources but do NOT run them + # Test classes must be in target/test-classes for the test shards + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \ + mvn clean install -T 4 -Pprod -DskipTests + + - name: Upload compiled output + uses: actions/upload-artifact@v4 + with: + name: compiled-output + retention-days: 1 + # Upload full target dirs — test shards and docker job download these + path: | + obp-api/target/ + obp-commons/target/ + + - name: Save .jar artifact + run: mkdir -p ./push && cp obp-api/target/obp-api.jar ./push/ + + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.sha }} + path: push/ + + # -------------------------------------------------------------------------- + # Job 2: test (4-way matrix) + # + # Shard assignment (based on actual clean-run timings): + # Shard 1 ~258s v4_0_0(258) + # Shard 2 ~267s v6_0_0(122) v5_0_0(42) v3_0_0(39) v2_1_0(35) v2_2_0(12) … + # Shard 3 ~252s v1_2_1(137) ResourceDocs(67) berlin(34) util(12) … + # Shard 4 ~232s v5_1_0(79) v3_1_0(65) http4sbridge(52) v7_0_0(45) … + catch-all + # -------------------------------------------------------------------------- + test: + needs: compile runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - shard: 1 + name: "v4 only" + # ~258s of test work + test_filter: >- + code.api.v4_0_0 + - shard: 2 + name: "v6 + v5_0 + v3_0 + v2 + small" + # ~267s of test work + test_filter: >- + code.api.v6_0_0 + code.api.v5_0_0 + code.api.v3_0_0 + code.api.v2_1_0 + code.api.v2_2_0 + code.api.v2_0_0 + code.api.v1_4_0 + code.api.v1_3_0 + code.api.UKOpenBanking + code.atms + code.branches + code.products + code.crm + code.accountHolder + code.entitlement + code.bankaccountcreation + code.bankconnectors + code.container + - shard: 3 + name: "v1_2_1 + ResourceDocs + berlin + util + small" + # ~252s of test work + test_filter: >- + code.api.v1_2_1 + code.api.ResourceDocs1_4_0 + code.api.util + code.api.berlin + code.management + code.metrics + code.model + code.views + code.usercustomerlinks + code.customer + code.errormessages + - shard: 4 + name: "v5_1 + v3_1 + http4sbridge + v7 + code.api + util + connector" + # ~232s of test work + catch-all for any new packages + # Root-level code.api tests use class-name prefix matching (lowercase classes) + test_filter: >- + code.api.v5_1_0 + code.api.v3_1_0 + code.api.http4sbridge + code.api.v7_0_0 + code.api.Authentication + code.api.dauthTest + code.api.DirectLoginTest + code.api.gateWayloginTest + code.api.OBPRestHelperTest + code.util + code.connector + services: redis: image: redis ports: - 6379:6379 - # Set health checks to wait until redis has started options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: - uses: actions/checkout@v4 + - name: Set up JDK 11 uses: actions/setup-java@v4 with: java-version: "11" distribution: "adopt" cache: maven - - name: Build with Maven + + - name: Download compiled output + uses: actions/download-artifact@v4 + with: + name: compiled-output + + - name: Touch artifact files (prevent Zinc recompilation) + run: | + # actions/download-artifact preserves original compile-job timestamps. + # actions/checkout gives source files the current (later) time. + # Zinc sees sources newer than classes → full recompile (~215 s wasted). + # Touching everything in target/ makes all artifact files appear + # just-downloaded (current time) → newer than sources → Zinc skips. + find obp-api/target obp-commons/target -type f -exec touch {} + 2>/dev/null || true + echo "Touched $(find obp-api/target obp-commons/target -type f 2>/dev/null | wc -l) files" + + - name: Install local artifacts into Maven repo + run: | + # The compile runner's ~/.m2 is discarded after that job completes. + # Install the two local multi-module artifacts so scalatest:test can + # resolve com.tesobe:* without hitting remote repos. + # + # 1. Parent POM — obp-commons' pom.xml declares obp-parent as its + # ; Maven fetches it when reading transitive deps. + mvn install:install-file \ + -Dfile=pom.xml \ + -DgroupId=com.tesobe \ + -DartifactId=obp-parent \ + -Dversion=1.10.1 \ + -Dpackaging=pom \ + -DgeneratePom=false + # 2. obp-commons JAR with its full POM (lists compile deps inherited + # by obp-api at test classpath resolution time). + mvn install:install-file \ + -Dfile=obp-commons/target/obp-commons-1.10.1.jar \ + -DpomFile=obp-commons/pom.xml + + - name: Setup props run: | - set -o pipefail - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props + cp obp-api/src/main/resources/props/sample.props.template \ + obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props @@ -54,51 +223,106 @@ jobs: echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn clean package -T 4 -Pprod > maven-build.log 2>&1 + echo hikari.maximumPoolSize=20 >> obp-api/src/main/resources/props/test.default.props + echo write_metrics=false >> obp-api/src/main/resources/props/test.default.props + + - name: Run tests — shard ${{ matrix.shard }} (${{ matrix.name }}) + run: | + # wildcardSuites requires comma-separated package prefixes (-w per entry). + # The YAML >- scalar collapses newlines to spaces, so we convert here. + FILTER=$(echo "${{ matrix.test_filter }}" | tr ' ' ',') - - name: Report failing tests (if any) + # Shard 4 is the catch-all: append any test package not explicitly + # assigned to shards 1–3, so new packages are never silently skipped. + if [ "${{ matrix.shard }}" = "4" ]; then + SHARD1="code.api.v4_0_0" + SHARD2="code.api.v6_0_0 code.api.v5_0_0 code.api.v3_0_0 code.api.v2_1_0 \ + code.api.v2_2_0 code.api.v2_0_0 code.api.v1_4_0 code.api.v1_3_0 \ + code.api.UKOpenBanking code.atms code.branches code.products code.crm \ + code.accountHolder code.entitlement code.bankaccountcreation \ + code.bankconnectors code.container" + SHARD3="code.api.v1_2_1 code.api.ResourceDocs1_4_0 \ + code.api.util code.api.berlin code.management code.metrics \ + code.model code.views code.usercustomerlinks code.customer \ + code.errormessages" + ASSIGNED="$SHARD1 $SHARD2 $SHARD3 ${{ matrix.test_filter }}" + + # Discover all packages that contain at least one .scala test file + ALL_PKGS=$(find obp-api/src/test/scala obp-commons/src/test/scala \ + -name "*.scala" 2>/dev/null \ + | sed 's|.*/test/scala/||; s|/[^/]*\.scala$||; s|/|.|g' \ + | sort -u) + + EXTRAS="" + for pkg in $ALL_PKGS; do + covered=false + for prefix in $ASSIGNED; do + if [[ "$pkg" == "$prefix" || "$pkg" == "$prefix."* || "$prefix" == "$pkg."* ]]; then + covered=true; break + fi + done + [ "$covered" = "false" ] && EXTRAS="$EXTRAS,$pkg" + done + + [ -n "$EXTRAS" ] && echo "Catch-all extras added to shard 4:$EXTRAS" + FILTER="${FILTER}${EXTRAS}" + fi + + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \ + mvn test \ + -DwildcardSuites="$FILTER" \ + > maven-build-shard${{ matrix.shard }}.log 2>&1 + + - name: Report failing tests — shard ${{ matrix.shard }} if: always() run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 + echo "Checking shard ${{ matrix.shard }} log for failing tests..." + if [ ! -f maven-build-shard${{ matrix.shard }}.log ]; then + echo "No build log found."; exit 0 + fi + echo "=== RECOMPILATION CHECK ===" + if grep -c "Compiling " maven-build-shard${{ matrix.shard }}.log > /dev/null 2>&1; then + echo "WARNING: Scala recompilation occurred on this shard:" + grep "Compiling " maven-build-shard${{ matrix.shard }}.log | head -10 + else + echo "OK: no recompilation (Zinc used pre-compiled classes)" fi - if grep -C 3 -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." + echo "" + echo "=== BRIDGE / UNCAUGHT EXCEPTIONS ===" + grep -n "\[BRIDGE\] Exception\|Uncaught exception in dispatch\|requestScopeProxy=" \ + maven-build-shard${{ matrix.shard }}.log | head -200 || true + echo "" + echo "=== FAILING TEST SCENARIOS (with 30 lines context) ===" + if grep -C 30 -n "\*\*\* FAILED \*\*\*" maven-build-shard${{ matrix.shard }}.log; then + echo "Failing tests detected in shard ${{ matrix.shard }}." exit 1 else - echo "No failing tests detected in maven-build.log." + echo "No failing tests detected in shard ${{ matrix.shard }}." fi - - name: Upload Maven build log + - name: Upload Maven build log — shard ${{ matrix.shard }} if: always() uses: actions/upload-artifact@v4 with: - name: maven-build-log + name: maven-build-log-shard${{ matrix.shard }} if-no-files-found: ignore - path: | - maven-build.log + path: maven-build-shard${{ matrix.shard }}.log - - name: Upload test reports + - name: Upload test reports — shard ${{ matrix.shard }} if: always() uses: actions/upload-artifact@v4 with: - name: test-reports + name: test-reports-shard${{ matrix.shard }} if-no-files-found: ignore path: | obp-api/target/surefire-reports/** @@ -107,24 +331,53 @@ jobs: **/target/site/surefire-report.html **/target/site/surefire-report/* - - name: Save .jar artifact - continue-on-error: true - run: | - mkdir -p ./push - cp obp-api/target/obp-api.jar ./push/ - - uses: actions/upload-artifact@v4 + # -------------------------------------------------------------------------- + # Job 3: report — http4s v7 vs Lift per-test speed table + # -------------------------------------------------------------------------- + report: + needs: test + runs-on: ubuntu-latest + if: always() + steps: + - uses: actions/checkout@v4 + + - name: Download test reports — all shards + uses: actions/download-artifact@v4 with: - name: ${{ github.sha }} - path: push/ + pattern: test-reports-shard* + path: all-reports + merge-multiple: true + + - name: http4s v7 vs Lift — per-test speed + run: python3 .github/scripts/test_speed_report.py all-reports + + # -------------------------------------------------------------------------- + # Job 4: docker — build and push container image (runs after all shards pass) + # -------------------------------------------------------------------------- + docker: + needs: test + runs-on: ubuntu-latest + if: vars.ENABLE_CONTAINER_BUILDING == 'true' + steps: + - uses: actions/checkout@v4 + + - name: Download compiled output + uses: actions/download-artifact@v4 + with: + name: compiled-output - name: Build the Docker image - if: vars.ENABLE_CONTAINER_BUILDING == 'true' run: | echo "${{ secrets.DOCKER_HUB_TOKEN }}" | docker login -u "${{ secrets.DOCKER_HUB_USERNAME }}" --password-stdin docker.io if [ "${{ github.ref }}" == "refs/heads/develop" ]; then - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} + docker build . --file .github/Dockerfile_PreBuild \ + --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA \ + --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest \ + --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} else - docker build . --file .github/Dockerfile_PreBuild --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} + docker build . --file .github/Dockerfile_PreBuild \ + --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA \ + --tag docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} fi docker push docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }} --all-tags echo docker done @@ -132,19 +385,17 @@ jobs: - uses: sigstore/cosign-installer@4d14d7f17e7112af04ea6108fbb4bfc714c00390 - name: Write signing key to disk (only needed for `cosign sign --key`) - if: vars.ENABLE_CONTAINER_BUILDING == 'true' run: echo "${{ secrets.COSIGN_PRIVATE_KEY }}" > cosign.key - name: Sign container image - if: vars.ENABLE_CONTAINER_BUILDING == 'true' run: | cosign sign -y --key cosign.key \ docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:${GITHUB_REF##*/} cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:$GITHUB_SHA if [ "${{ github.ref }}" == "refs/heads/develop" ]; then cosign sign -y --key cosign.key \ - docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest + docker.io/${{ env.DOCKER_HUB_ORGANIZATION }}/${{ env.DOCKER_HUB_REPOSITORY }}:latest fi env: COSIGN_PASSWORD: "${{secrets.COSIGN_PASSWORD}}" diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 79591235d4..db72079e2d 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -4,40 +4,205 @@ on: pull_request: branches: - "**" + env: - ## Sets environment variable DOCKER_HUB_ORGANIZATION: ${{ vars.DOCKER_HUB_ORGANIZATION }} +# --------------------------------------------------------------------------- +# compile — compiles everything once, packages the JAR, uploads classes +# test — 3-way matrix downloads compiled output and runs a shard of tests +# +# Wall-clock target: +# compile ~10 min (parallel with setup of test shards) +# tests ~8 min (3 shards in parallel after compile finishes) +# total ~18 min (vs ~27 min single-job) +# --------------------------------------------------------------------------- + jobs: - build: + + # -------------------------------------------------------------------------- + # Job 1: compile + # -------------------------------------------------------------------------- + compile: runs-on: ubuntu-latest if: github.repository == 'OpenBankProject/OBP-API' + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 11 + uses: actions/setup-java@v4 + with: + java-version: "11" + distribution: "adopt" + cache: maven # caches ~/.m2/repository keyed on pom.xml hash + + - name: Setup production props + run: | + cp obp-api/src/main/resources/props/sample.props.template \ + obp-api/src/main/resources/props/production.default.props + + - name: Compile and install (skip test execution) + run: | + # -DskipTests — compile test sources but do NOT run them + # Test classes must be in target/test-classes for the test shards + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \ + mvn clean install -T 4 -Pprod -DskipTests + + - name: Upload compiled output + uses: actions/upload-artifact@v4 + with: + name: compiled-output + retention-days: 1 + # Upload full target dirs — test shards download and run surefire:test + # without recompiling (surefire:test goal bypasses compile lifecycle) + path: | + obp-api/target/ + obp-commons/target/ + + - name: Save .jar artifact + run: mkdir -p ./pull && cp obp-api/target/obp-api.jar ./pull/ + + - uses: actions/upload-artifact@v4 + with: + name: ${{ github.sha }} + path: pull/ + + # -------------------------------------------------------------------------- + # Job 2: test (4-way matrix) + # + # Shard assignment (based on actual clean-run timings): + # Shard 1 ~258s v4_0_0(258) + # Shard 2 ~267s v6_0_0(122) v5_0_0(42) v3_0_0(39) v2_1_0(35) v2_2_0(12) … + # Shard 3 ~252s v1_2_1(137) ResourceDocs(67) berlin(34) util(12) … + # Shard 4 ~232s v5_1_0(79) v3_1_0(65) http4sbridge(52) v7_0_0(45) … + catch-all + # -------------------------------------------------------------------------- + test: + needs: compile + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - shard: 1 + name: "v4 only" + # ~258s of test work + test_filter: >- + code.api.v4_0_0 + - shard: 2 + name: "v6 + v5_0 + v3_0 + v2 + small" + # ~267s of test work + test_filter: >- + code.api.v6_0_0 + code.api.v5_0_0 + code.api.v3_0_0 + code.api.v2_1_0 + code.api.v2_2_0 + code.api.v2_0_0 + code.api.v1_4_0 + code.api.v1_3_0 + code.api.UKOpenBanking + code.atms + code.branches + code.products + code.crm + code.accountHolder + code.entitlement + code.bankaccountcreation + code.bankconnectors + code.container + - shard: 3 + name: "v1_2_1 + ResourceDocs + berlin + util + small" + # ~252s of test work + test_filter: >- + code.api.v1_2_1 + code.api.ResourceDocs1_4_0 + code.api.util + code.api.berlin + code.management + code.metrics + code.model + code.views + code.usercustomerlinks + code.customer + code.errormessages + - shard: 4 + name: "v5_1 + v3_1 + http4sbridge + v7 + code.api + util + connector" + # ~232s of test work + catch-all for any new packages + # Root-level code.api tests use class-name prefix matching (lowercase classes) + test_filter: >- + code.api.v5_1_0 + code.api.v3_1_0 + code.api.http4sbridge + code.api.v7_0_0 + code.api.Authentication + code.api.dauthTest + code.api.DirectLoginTest + code.api.gateWayloginTest + code.api.OBPRestHelperTest + code.util + code.connector + services: - # Label used to access the service container redis: - # Docker Hub image image: redis ports: - # Opens tcp port 6379 on the host and service container - 6379:6379 - # Set health checks to wait until redis has started options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 + steps: - uses: actions/checkout@v4 + - name: Set up JDK 11 uses: actions/setup-java@v4 with: java-version: "11" distribution: "adopt" cache: maven - - name: Build with Maven + + - name: Download compiled output + uses: actions/download-artifact@v4 + with: + name: compiled-output + + - name: Touch artifact files (prevent Zinc recompilation) + run: | + # actions/download-artifact preserves original compile-job timestamps. + # actions/checkout gives source files the current (later) time. + # Zinc sees sources newer than classes → full recompile (~215 s wasted). + # Touching everything in target/ makes all artifact files appear + # just-downloaded (current time) → newer than sources → Zinc skips. + find obp-api/target obp-commons/target -type f -exec touch {} + 2>/dev/null || true + echo "Touched $(find obp-api/target obp-commons/target -type f 2>/dev/null | wc -l) files" + + - name: Install local artifacts into Maven repo run: | - set -o pipefail - cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/resources/props/production.default.props + # The compile runner's ~/.m2 is discarded after that job completes. + # Install the two local multi-module artifacts so scalatest:test can + # resolve com.tesobe:* without hitting remote repos. + # + # 1. Parent POM — obp-commons' pom.xml declares obp-parent as its + # ; Maven fetches it when reading transitive deps. + mvn install:install-file \ + -Dfile=pom.xml \ + -DgroupId=com.tesobe \ + -DartifactId=obp-parent \ + -Dversion=1.10.1 \ + -Dpackaging=pom \ + -DgeneratePom=false + # 2. obp-commons JAR with its full POM (lists compile deps inherited + # by obp-api at test classpath resolution time). + mvn install:install-file \ + -Dfile=obp-commons/target/obp-commons-1.10.1.jar \ + -DpomFile=obp-commons/pom.xml + + - name: Setup props + run: | + cp obp-api/src/main/resources/props/sample.props.template \ + obp-api/src/main/resources/props/production.default.props echo connector=star > obp-api/src/main/resources/props/test.default.props echo starConnector_supported_types=mapped,internal >> obp-api/src/main/resources/props/test.default.props echo hostname=http://localhost:8016 >> obp-api/src/main/resources/props/test.default.props @@ -58,51 +223,106 @@ jobs: echo openredirects.hostname.whitlelist=http://127.0.0.1,http://localhost >> obp-api/src/main/resources/props/test.default.props echo remotedata.secret = foobarbaz >> obp-api/src/main/resources/props/test.default.props echo allow_public_views=true >> obp-api/src/main/resources/props/test.default.props - echo SIMPLE_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo ACCOUNT_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo SEPA_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo FREE_FORM_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo COUNTERPARTY_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props echo SEPA_CREDIT_TRANSFERS_OTP_INSTRUCTION_TRANSPORT=dummy >> obp-api/src/main/resources/props/test.default.props - echo allow_oauth2_login=true >> obp-api/src/main/resources/props/test.default.props echo oauth2.jwk_set.url=https://www.googleapis.com/oauth2/v3/certs >> obp-api/src/main/resources/props/test.default.props - echo ResetPasswordUrlEnabled=true >> obp-api/src/main/resources/props/test.default.props - echo consents.allowed=true >> obp-api/src/main/resources/props/test.default.props - MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" mvn clean package -T 4 -Pprod > maven-build.log 2>&1 + echo hikari.maximumPoolSize=20 >> obp-api/src/main/resources/props/test.default.props + echo write_metrics=false >> obp-api/src/main/resources/props/test.default.props + + - name: Run tests — shard ${{ matrix.shard }} (${{ matrix.name }}) + run: | + # wildcardSuites requires comma-separated package prefixes (-w per entry). + # The YAML >- scalar collapses newlines to spaces, so we convert here. + FILTER=$(echo "${{ matrix.test_filter }}" | tr ' ' ',') + + # Shard 4 is the catch-all: append any test package not explicitly + # assigned to shards 1–3, so new packages are never silently skipped. + if [ "${{ matrix.shard }}" = "4" ]; then + SHARD1="code.api.v4_0_0" + SHARD2="code.api.v6_0_0 code.api.v5_0_0 code.api.v3_0_0 code.api.v2_1_0 \ + code.api.v2_2_0 code.api.v2_0_0 code.api.v1_4_0 code.api.v1_3_0 \ + code.api.UKOpenBanking code.atms code.branches code.products code.crm \ + code.accountHolder code.entitlement code.bankaccountcreation \ + code.bankconnectors code.container" + SHARD3="code.api.v1_2_1 code.api.ResourceDocs1_4_0 \ + code.api.util code.api.berlin code.management code.metrics \ + code.model code.views code.usercustomerlinks code.customer \ + code.errormessages" + ASSIGNED="$SHARD1 $SHARD2 $SHARD3 ${{ matrix.test_filter }}" + + # Discover all packages that contain at least one .scala test file + ALL_PKGS=$(find obp-api/src/test/scala obp-commons/src/test/scala \ + -name "*.scala" 2>/dev/null \ + | sed 's|.*/test/scala/||; s|/[^/]*\.scala$||; s|/|.|g' \ + | sort -u) + + EXTRAS="" + for pkg in $ALL_PKGS; do + covered=false + for prefix in $ASSIGNED; do + if [[ "$pkg" == "$prefix" || "$pkg" == "$prefix."* || "$prefix" == "$pkg."* ]]; then + covered=true; break + fi + done + [ "$covered" = "false" ] && EXTRAS="$EXTRAS,$pkg" + done - - name: Report failing tests (if any) + [ -n "$EXTRAS" ] && echo "Catch-all extras added to shard 4:$EXTRAS" + FILTER="${FILTER}${EXTRAS}" + fi + + MAVEN_OPTS="-Xmx3G -Xss2m -XX:MaxMetaspaceSize=1G" \ + mvn test \ + -DwildcardSuites="$FILTER" \ + > maven-build-shard${{ matrix.shard }}.log 2>&1 + + - name: Report failing tests — shard ${{ matrix.shard }} if: always() run: | - echo "Checking build log for failing tests via grep..." - if [ ! -f maven-build.log ]; then - echo "No maven-build.log found; skipping failure scan." - exit 0 + echo "Checking shard ${{ matrix.shard }} log for failing tests..." + if [ ! -f maven-build-shard${{ matrix.shard }}.log ]; then + echo "No build log found."; exit 0 + fi + echo "=== RECOMPILATION CHECK ===" + if grep -c "Compiling " maven-build-shard${{ matrix.shard }}.log > /dev/null 2>&1; then + echo "WARNING: Scala recompilation occurred on this shard:" + grep "Compiling " maven-build-shard${{ matrix.shard }}.log | head -10 + else + echo "OK: no recompilation (Zinc used pre-compiled classes)" fi - if grep -n "\*\*\* FAILED \*\*\*" maven-build.log; then - echo "Failing tests detected above." + echo "" + echo "=== BRIDGE / UNCAUGHT EXCEPTIONS ===" + grep -n "\[BRIDGE\] Exception\|Uncaught exception in dispatch\|requestScopeProxy=" \ + maven-build-shard${{ matrix.shard }}.log | head -200 || true + echo "" + echo "=== FAILING TEST SCENARIOS (with 30 lines context) ===" + if grep -C 30 -n "\*\*\* FAILED \*\*\*" maven-build-shard${{ matrix.shard }}.log; then + echo "Failing tests detected in shard ${{ matrix.shard }}." exit 1 else - echo "No failing tests detected in maven-build.log." + echo "No failing tests detected in shard ${{ matrix.shard }}." fi - - name: Upload Maven build log + - name: Upload Maven build log — shard ${{ matrix.shard }} if: always() uses: actions/upload-artifact@v4 with: - name: maven-build-log + name: maven-build-log-shard${{ matrix.shard }} if-no-files-found: ignore - path: | - maven-build.log + path: maven-build-shard${{ matrix.shard }}.log - - name: Upload test reports + - name: Upload test reports — shard ${{ matrix.shard }} if: always() uses: actions/upload-artifact@v4 with: - name: test-reports + name: test-reports-shard${{ matrix.shard }} if-no-files-found: ignore path: | obp-api/target/surefire-reports/** @@ -111,11 +331,22 @@ jobs: **/target/site/surefire-report.html **/target/site/surefire-report/* - - name: Save .jar artifact - run: | - mkdir -p ./pull - cp obp-api/target/obp-api.jar ./pull/ - - uses: actions/upload-artifact@v4 + # -------------------------------------------------------------------------- + # Job 3: report — http4s v7 vs Lift per-test speed table + # -------------------------------------------------------------------------- + report: + needs: test + runs-on: ubuntu-latest + if: always() + steps: + - uses: actions/checkout@v4 + + - name: Download test reports — all shards + uses: actions/download-artifact@v4 with: - name: ${{ github.sha }} - path: pull/ \ No newline at end of file + pattern: test-reports-shard* + path: all-reports + merge-multiple: true + + - name: http4s v7 vs Lift — per-test speed + run: python3 .github/scripts/test_speed_report.py all-reports diff --git a/CLAUDE.md b/CLAUDE.md index 3edee67170..0e0e4127d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,3 +2,219 @@ ## Working Style - Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve. + +## Architecture (Onboarding) + +v7.0.0 is a Lift Web → http4s migration. Not a replacement for v6.0.0 yet — 27 of 633 endpoints migrated. + +**Request priority chain** (Http4sServer): `corsHandler` (OPTIONS) → StatusPage → Http4s500 → Http4s700 → Http4sBGv2 → Http4sLiftWebBridge (Lift fallback). Unhandled `/obp/v7.0.0/*` paths fall through silently to Lift — they do not 404. + +**Key files**: `Http4s700.scala` (endpoints), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `RequestScopeConnection.scala` (DB transaction propagation to Futures). + +**Migrated endpoints** (27): root, getBanks, getCards, getCardsForBank, getResourceDocsObpV700, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, deleteEntitlement, addEntitlement, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getUserByUserId, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces. + +**Tests**: `Http4s700RoutesTest` (93 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. + +## Migrating a v6.0.0 Endpoint to v7.0.0 + +### Rule 1 — ResourceDoc registration +```scala +// Declare val FIRST, then register — see Rule 5 why order matters +val myEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { ... } + +resourceDocs += ResourceDoc( + null, // always null — no Lift endpoint ref + implementedInApiVersion, + nameOf(myEndpoint), + "GET", "/some/path", "Summary", """Description""", + EmptyBody, responseJson, + List(UnknownError), + apiTagFoo :: Nil, + Some(List(canDoThing)), + http4sPartialFunction = Some(myEndpoint) +) +``` + +### Rule 2 — Endpoint signature +```scala +val myEndpoint: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "some" / "path" => + EndpointHelpers.executeAndRespond(req) { cc => + for { ... } yield json // no HttpCode wrapper + } +} +``` +Drop `implicit val ec = EndpointContext(Some(cc))` — not needed in http4s path. + +### Rule 3 — What middleware replaces + +| v6.0.0 inline | v7.0.0 | Available as | +|---|---|---| +| `authenticatedAccess(cc)` | `$AuthenticatedUserIsRequired` in error list | `user` via `withUser` | +| `hasEntitlement(...)` | `Some(List(canXxx))` in ResourceDoc roles | — (middleware 403s) | +| `getBank(bankId, cc)` | `BANK_ID` in URL template | `cc.bank.get` | +| `checkBankAccountExists(...)` | `ACCOUNT_ID` in URL template | `cc.bankAccount.get` | +| `checkViewAccessAndReturnView(...)` | `VIEW_ID` in URL template | `cc.view.get` | +| `getCounterpartyTrait(...)` | `COUNTERPARTY_ID` in URL template | `cc.counterparty.get` | + +Middleware resolves only these 4 uppercase segments. Non-standard path vars (USER_ID, ENTITLEMENT_ID, etc.) must be extracted from the route pattern directly. + +### Rule 4 — EndpointHelper selection + +**GET → 200** +```scala +EndpointHelpers.executeAndRespond(req) { cc => ... } // no auth +EndpointHelpers.withUser(req) { (user, cc) => ... } // user only +EndpointHelpers.withBank(req) { (bank, cc) => ... } // bank only +EndpointHelpers.withUserAndBank(req) { (user, bank, cc) => ... } // user + bank +EndpointHelpers.withBankAccount(req) { (user, account, cc) => ... } // + ACCOUNT_ID +EndpointHelpers.withView(req) { (user, account, view, cc) => ... } // + VIEW_ID +EndpointHelpers.withCounterparty(req) { (user, account, view, cp, cc) => ... } // + COUNTERPARTY_ID +``` +**POST → 201**: `executeFutureWithBodyCreated[B,A]` / `withUserAndBodyCreated[B,A]` / `withUserAndBankAndBodyCreated[B,A]` +**PUT → 200**: `executeFutureWithBody[B,A]` / `withUserAndBody[B,A]` / `withUserAndBankAndBody[B,A]` +**DELETE → 204**: `executeDelete` / `withUserDelete` / `withUserAndBankDelete` + +### Rule 5 — `allRoutes` ordering invariant (critical) +`val myEndpoint` MUST be declared BEFORE its `resourceDocs +=` line. If reversed, Scala's initializer stores `Some(null)` → NPE kills the entire `baseServices` chain → every request returns 500, including v6 fallback routes. + +## Tricky Parts (Gotchas) + +**View permissions**: `view.canGetCounterparty` (MappedBoolean) always returns `false` for system views. Use `view.allowed_actions.exists(_ == CAN_GET_COUNTERPARTY)` instead. + +**BankExtended**: `privateAccountsFuture`, `privateAccounts`, `publicAccounts` are on `code.model.BankExtended`, not `commons.Bank`. Wrap: `code.model.BankExtended(bank).privateAccountsFuture(...)`. + +**Query params in v7**: Use `req.uri.renderString` in place of `cc.url`. For raw map: `req.uri.query.multiParams.map { case (k, vs) => k -> vs.toList }` — `.toList` required; don't use `req.uri.query.pairs` (wrong shape). + +**Response field names** (non-obvious): +- `getBank` → `bank_id` (not `id`), `full_name` (not `short_name`) +- `getCoreAccountById` → `account_id` (not `id`); also: `bank_id`, `label`, `number`, `product_code`, `balance`, `account_routings`, `views_basic` +- `getPrivateAccountByIdFull` → `id` (correct); also: `views_available`, `balance` +- `getCurrentUser` → `user_id`, `username`, `email` + +**Counterparty test setup**: `createCounterparty` only creates `MappedCounterparty`. Must also call `Counterparties.counterparties.vend.getOrCreateMetadata(bankId, accountId, counterpartyId, counterpartyName)` or endpoint returns 400 `CounterpartyNotFoundByCounterpartyId`. + +**`StoredProcedureUtils` in tests**: `StoredProcedureUtils` has a constructor block that requires `stored_procedure_connector.*` props. In the test environment these aren't set, so the first access to the object (inside `Future { StoredProcedureUtils.getHealth() }`) throws and returns 500. Only test the 401/403 scenarios for `getStoredProcedureConnectorHealth` — skip the 200 scenario. + +**`resource-docs` version dispatch**: `GET /obp/v7.0.0/resource-docs/API_VERSION/obp` accepts any valid API version string. Delegates to `ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion)` which dispatches per version (v7.0.0 → `Http4s700.resourceDocs`, v6.0.0 → `OBPAPI6_0_0.allResourceDocs`, etc.). An invalid/unknown version string returns 400. + +**System owner view** (`"owner"`) has `CAN_GET_COUNTERPARTY` and is granted to `resourceUser1` on all test accounts — safe to use as VIEW_ID in tests. + +**`Full(user)` wrapping**: `NewStyle.function.moderatedBankAccountCore` takes `Box[User]` — pass `Full(user)`. + +**ResourceDoc example body**: never pass `null` to a factory method — use an inline literal or `EmptyBody`. + +**Users import clash**: `code.users.{Users => UserVend}` to avoid clash with `commons.model.User`. + +**Test helper placement**: `private def createTestCustomer(...)` must be at class level, never inside a `feature` block (invalid Scala). + +**Standard 3-scenario pattern** for role-gated endpoints: +1. Unauthenticated → 401 (`AuthenticatedUserIsRequired`) +2. Authenticated, no role → 403 (`UserHasMissingRoles` + role name) +3. Authenticated with role + test data → 200 with field shape check + +**Creating test data**: use provider directly — e.g. `CustomerX.customerProvider.vend.addCustomer(...)`. Do not call v6 endpoints via HTTP in v7 tests. + +**CI**: Tests run with `mvn test -DwildcardSuites="..."`. `hikari.maximumPoolSize=20` required in test props for concurrent tests (`withRequestTransaction` holds 1 connection per request; rate-limit queries need a 2nd → pool of 10 exhausts at 5 concurrent requests). + +## CI Performance Profile + +Measured from a 3-shard run (2691 tests total, all passing). Numbers are stable across shards. + +### Time budget per shard (~9–11 min total) + +| Phase | Time | % of total | +|---|---|---| +| Main compile (Zinc) | ~130s | ~22% | +| Test compile (Zinc) | ~68s | ~11% | +| Test discovery (ScalaTest) | ~20s | ~3% | +| **Test execution** | **~340–420s** | **~60–64%** | + +Compile times are consistent across all three shards — Zinc cache restores correctly. Test execution is the dominant cost. + +### http4s v7 vs Lift — per-test speed + +| Category | Tests | Avg/test | +|---|---|---| +| http4s v7 — unit/pure (no server) | 172 | **0.008s** | +| http4s v7 — integration (real server) | 160 | 0.418s | +| Lift v4 | 515 | 0.448s | +| Lift v3 | 269 | 0.446s | +| Lift v5 | 337 | 0.432s | +| Lift v1 | 431 | 0.425s | +| Lift v2 | 124 | 0.414s | +| Lift v6 | 314 | 0.411s | + +At the integration level both frameworks are similarly server/DB-bound (~0.32–0.45 s/test). The real http4s gain is the **unit/pure tier** — tests that don't need a running server are 54× faster. As more logic moves into pure functions (request parsing, response building, auth checks) these unit tests replace integration tests and the savings compound. + +The 5 integration suites (160 tests, 66.9s total): +- `obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala` — 51 tests, 31.9s +- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala` — 75 tests, 23.8s +- `obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala` — 16 tests, 5.0s +- `obp-api/src/test/scala/code/api/v5_0_0/Http4s500SystemViewsTest.scala` — 13 tests, 4.4s +- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700TransactionTest.scala` — 5 tests, 1.9s + +The 12 pure-unit suites (172 tests, 1.3s total): +- `obp-api/src/test/scala/code/api/util/http4s/Http4sCallContextBuilderTest.scala` +- `obp-api/src/test/scala/code/api/util/http4s/Http4sResponseConversionTest.scala` +- `obp-api/src/test/scala/code/api/util/http4s/Http4sResponseConversionPropertyTest.scala` +- `obp-api/src/test/scala/code/api/util/http4s/Http4sRequestConversionPropertyTest.scala` +- `obp-api/src/test/scala/code/api/util/http4s/ResourceDocMatcherTest.scala` +- `obp-api/src/test/scala/code/api/util/http4s/Http4sConfigUtilTest.scala` +- `obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala` +- `obp-api/src/test/scala/code/api/berlin/group/v2/Http4sBGv2AISTest.scala` +- `obp-api/src/test/scala/code/api/berlin/group/v2/Http4sBGv2PISTest.scala` +- `obp-api/src/test/scala/code/api/berlin/group/v2/Http4sBGv2ResourceDocTest.scala` +- `obp-api/src/test/scala/code/api/berlin/group/v2/Http4sBGv2PIISTest.scala` +- `obp-api/src/test/scala/code/api/v5_0_0/Http4s500RoutesTest.scala` + +### Known bottlenecks + +**`API1_2_1Test`** (Lift v1) — 143s for 323 tests, 36% of shard2's entire test time. Larger than the full http4s v7 budget. The first test in the suite (`"base line URL works"`) takes 0.97s — Lift's lazy init cost. Moving this suite to its own shard would reduce pipeline wall-clock by ~90s. + +**`Http4sLiftBridgePropertyTest`** — 31.9s for 51 tests. Property 7 ("Session and Context Adapter Correctness") accounts for 13.4s of that: three ScalaCheck properties exercise concurrent requests through the Lift/http4s bridge, hitting real lock contention between Lift's session manager and the http4s fiber scheduler. Property 7.4 alone is 8.54s. These are the most meaningful slow tests — they exercise a genuine concurrency boundary. + +**`ResourceDocsTest` / `SwaggerDocsTest`** — 34s + 24s = 58s, averaging 0.85s/test — the slowest per-test cost in the suite. Each test serializes the entire API surface (633+ endpoints) into JSON/Swagger. Cost scales linearly with endpoint count. Will worsen as the http4s migration adds endpoints unless ResourceDoc serialization is cached or the heavy tests are isolated. + +### Shard assignment + +Shards are defined by explicit package-prefix allowlists in `.github/workflows/build_pull_request.yml` (lines 89–143). Shard 4 also runs a **catch-all**: any `.scala` test file whose package is not covered by shards 1–3 is appended automatically at runtime — new packages are never silently skipped. Extras are printed in the step log under `"Catch-all extras added to shard 4:"`. + +| Package prefix | Shard | +|---|---| +| `code.api.v4_0_0` | 1 | +| `code.api.v6_0_0`, `code.api.v5_0_0`, `code.api.v3_0_0`, `code.api.v2_*`, `code.api.v1_[34]_0`, `code.api.UKOpenBanking`, `code.atms`, `code.branches`, `code.products`, `code.crm`, `code.accountHolder`, `code.entitlement`, `code.bankaccountcreation`, `code.bankconnectors`, `code.container` | 2 | +| `code.api.v1_2_1`, `code.api.ResourceDocs1_4_0`, `code.api.util`, `code.api.berlin`, `code.management`, `code.metrics`, `code.model`, `code.views`, `code.usercustomerlinks`, `code.customer`, `code.errormessages` | 3 | +| `code.api.v5_1_0`, `code.api.v3_1_0`, `code.api.http4sbridge`, `code.api.v7_0_0`, `code.api.Authentication*`, `code.api.DirectLoginTest`, `code.api.dauthTest`, `code.api.gateWayloginTest`, `code.api.OBPRestHelperTest`, `code.util`, `code.connector` | 4 | +| anything else | **4** (catch-all) | + +To explicitly move a package to a different shard, add it to that shard's `test_filter` block — it will be excluded from the catch-all automatically. + +### Implication for the migration + +Per-endpoint integration test cost stays roughly constant as endpoints move Lift → http4s (both bound by DB + HTTP). Gains appear from: (1) pure unit tests replacing integration tests, (2) eventual removal of Lift endpoint tests when v6 is retired. ResourceDocs overhead is the one cost that compounds — needs caching before the migration is complete. + +## TODO / Phase Progress + +### Phase 1 — Simple GETs (98 remaining in v6.0.0) +GET + no body. Purely mechanical — 1:1 copy of `NewStyle.function.*` calls, pick helper from Rule 4 matrix, 3 test scenarios per endpoint (401 / 403 / 200). + +| Batch | Endpoints | Status | +|---|---|---| +| Batches 1–3 | 9 endpoints | ✓ done | +| Batch 4 | getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces | ✓ done | +| Remaining | 98 GETs | todo | + +### Phase 2 — Account/View/Counterparty GETs (subset of the 98 above) +`withBankAccount` / `withView` / `withCounterparty` helpers ready. Same mechanical pattern. + +### Phase 3 — POST / PUT / DELETE (57 + 33 + 26 = 116 endpoints in v6.0.0) +Body helpers and DELETE 204 helpers ready. Velocity: 6–8 endpoints/day. + +### Phase 4 — Complex endpoints (~50 endpoints) +Dynamic entities, ABAC rules, mandate workflows, polymorphic bodies. ~45–60 min each. + +### Other TODOs +- **OBP-Trading** (at `/home/marko/Tesobe/GitHub/constantine2nd/OBP-Trading`): pending team decision — port trading endpoints into `Http4s700.scala` or keep as a separate service that OBP-API proxies to. Connectors (`ObpApiUserConnector`, `ObpPaymentsConnector`) are currently in-memory stubs. +- **CI speed-up** (not done): two-tier fast gate + full suite; surefire parallel forks. +- **Disabled tests to fix**: `Http4s500RoutesTest` (@Ignore, in-process issue), `RootAndBanksTest` (@Ignore), `V500ContractParityTest` (@Ignore), `CardTest` (fully commented out). `v5_0_0`: 13 skipped tests (setup cost paid, no value). diff --git a/development/docker/Dockerfile b/development/docker/Dockerfile index 55a6d87f59..d4b110e8ba 100644 --- a/development/docker/Dockerfile +++ b/development/docker/Dockerfile @@ -8,6 +8,6 @@ RUN cp obp-api/src/main/resources/props/sample.props.template obp-api/src/main/r RUN --mount=type=cache,target=$HOME/.m2 MAVEN_OPTS="-Xmx3G -Xss2m" mvn install -pl .,obp-commons RUN --mount=type=cache,target=$HOME/.m2 MAVEN_OPTS="-Xmx3G -Xss2m" mvn install -DskipTests -pl obp-api -FROM jetty:9.4-jdk11-alpine - -COPY --from=maven /usr/src/OBP-API/obp-api/target/obp-api-1.*.war /var/lib/jetty/webapps/ROOT.war \ No newline at end of file +FROM eclipse-temurin:11-jre-alpine +COPY --from=maven /usr/src/OBP-API/obp-api/target/obp-api.jar /app/obp-api.jar +ENTRYPOINT ["java", "-jar", "/app/obp-api.jar"] \ No newline at end of file diff --git a/development/docker/entrypoint.sh b/development/docker/entrypoint.sh index b35048478a..dc20d7dddf 100644 --- a/development/docker/entrypoint.sh +++ b/development/docker/entrypoint.sh @@ -6,4 +6,4 @@ export MAVEN_OPTS="-Xss128m \ --add-opens=java.base/java.lang=ALL-UNNAMED \ --add-opens=java.base/java.lang.reflect=ALL-UNNAMED" -exec mvn jetty:run -pl obp-api +exec java $MAVEN_OPTS -jar /app/obp-api.jar diff --git a/obp-api/src/main/resources/props/test.default.props.template b/obp-api/src/main/resources/props/test.default.props.template index 511bf99803..483bdfd656 100644 --- a/obp-api/src/main/resources/props/test.default.props.template +++ b/obp-api/src/main/resources/props/test.default.props.template @@ -141,3 +141,11 @@ allow_public_views =true # Enable /Disable Create password reset url endpoint #ResetPasswordUrlEnabled=true + +# HikariCP pool size for tests. +# withRequestTransaction (v7 native endpoints) holds 1 connection per concurrent request. +# ScalaCache rate-limit queries (RateLimiting.findAll) fire concurrently on the OBP EC on +# cache miss, needing additional connections from the same pool. Worst case: N concurrent +# requests + N background queries = 2*N connections needed. Default of 10 is exhausted by +# the 10-thread concurrency tests. Set to 20 to provide headroom. +hikari.maximumPoolSize=20 diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index d64022e342..c487692034 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -248,7 +248,8 @@ class Boot extends MdcLoggable { logger.debug("Boot says:Using database driver: " + APIUtil.driver) - DB.defineConnectionManager(net.liftweb.util.DefaultConnectionIdentifier, APIUtil.vendor) + DB.defineConnectionManager(net.liftweb.util.DefaultConnectionIdentifier, + new code.api.util.http4s.RequestAwareConnectionManager(APIUtil.vendor)) /** * Function that determines if foreign key constraints are diff --git a/obp-api/src/main/scala/code/api/util/JwsUtil.scala b/obp-api/src/main/scala/code/api/util/JwsUtil.scala index 57df7733c7..f067114846 100644 --- a/obp-api/src/main/scala/code/api/util/JwsUtil.scala +++ b/obp-api/src/main/scala/code/api/util/JwsUtil.scala @@ -61,7 +61,8 @@ object JwsUtil extends MdcLoggable { val timeDifferenceInNanos = (timeDifference.abs.getSeconds * 1000000000L) + timeDifference.abs.getNano val criteriaOneOk = signingTime.isBefore(verifyingTime) || // Signing Time > Verifying Time otherwise (signingTime.isAfter(verifyingTime) && timeDifferenceInNanos < (2 * 1000000000L)) // IF "Verifying Time > Signing Time" THEN "Verifying Time - Signing Time < 2 seconds" - val criteriaTwoOk = timeDifferenceInNanos < (60 * 1000000000L) // Signing Time - Verifying Time < 60 seconds + val validitySeconds = APIUtil.getPropsAsLongValue("jws.signing_time_validity_seconds", 60L) + val criteriaTwoOk = timeDifferenceInNanos < (validitySeconds * 1000000000L) // Signing Time - Verifying Time < validitySeconds criteriaOneOk && criteriaTwoOk case None => false } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala index 39e965a03b..3b9c41a82a 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sLiftWebBridge.scala @@ -54,6 +54,16 @@ object Http4sLiftWebBridge extends MdcLoggable { case JsonResponseException(jsonResponse) => jsonResponse case e if e.getClass.getName == "net.liftweb.http.rest.ContinuationException" => resolveContinuation(e) + case e: Throwable => + logger.error( + s"[BRIDGE] Exception inside S.init for $method $uri" + + s" | thread=${Thread.currentThread().getName}" + + s" | exceptionClass=${e.getClass.getName}" + + s" | message=${e.getMessage}" + + s" | requestScopeProxy=${code.api.util.http4s.RequestScopeConnection.currentProxy.get()}", + e + ) + throw e } } } @@ -92,7 +102,7 @@ object Http4sLiftWebBridge extends MdcLoggable { case Some(run) => try { run() match { - case Full(resp) => + case Full(resp) => logger.debug(s"Http4sLiftBridge handler returned Full response") resp case ParamFailure(_, _, _, apiFailure: APIFailure) => @@ -110,6 +120,17 @@ object Http4sLiftWebBridge extends MdcLoggable { case JsonResponseException(jsonResponse) => jsonResponse case e if e.getClass.getName == "net.liftweb.http.rest.ContinuationException" => resolveContinuation(e) + case e: Throwable => + logger.error( + s"[BRIDGE] Exception in handler run() for ${req.request.method} ${req.request.uri}" + + s" | thread=${Thread.currentThread().getName}" + + s" | exceptionClass=${e.getClass.getName}" + + s" | message=${e.getMessage}" + + s" | requestScopeProxy=${code.api.util.http4s.RequestScopeConnection.currentProxy.get()}" + + s" | stackTrace=${e.getStackTrace.take(10).mkString(" <- ")}", + e + ) + throw e } case None => logger.debug(s"Http4sLiftBridge no handler found - returning JSON 404 for: ${req.request.method} ${req.request.uri}") diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index e162670626..d76b576362 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -17,6 +17,7 @@ import java.util.{Date, UUID} import scala.collection.mutable.ArrayBuffer import scala.concurrent.Future import scala.language.higherKinds +import code.api.util.http4s.RequestScopeConnection /** * Http4s support utilities for OBP API. @@ -120,7 +121,7 @@ object Http4sRequestAttributes { */ def executeAndRespond[A](req: Request[IO])(f: CallContext => Future[A])(implicit formats: Formats): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext - IO.fromFuture(IO(f(cc))).attempt.flatMap { + RequestScopeConnection.fromFuture(f(cc)).attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) } @@ -134,7 +135,7 @@ object Http4sRequestAttributes { implicit val cc: CallContext = req.callContext val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) - result <- IO.fromFuture(IO(f(user, cc))) + result <- RequestScopeConnection.fromFuture(f(user, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -150,7 +151,7 @@ object Http4sRequestAttributes { implicit val cc: CallContext = req.callContext val io = for { bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext")) - result <- IO.fromFuture(IO(f(bank, cc))) + result <- RequestScopeConnection.fromFuture(f(bank, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -167,7 +168,7 @@ object Http4sRequestAttributes { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bank, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bank, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -195,7 +196,7 @@ object Http4sRequestAttributes { parseBody[B](cc) match { case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) case Right(body) => - IO.fromFuture(IO(f(body, cc))).attempt.flatMap { + RequestScopeConnection.fromFuture(f(body, cc)).attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) } @@ -211,7 +212,7 @@ object Http4sRequestAttributes { parseBody[B](cc) match { case Left(msg) => BadRequest(msg).flatTap(recordMetric(msg, _)) case Right(body) => - IO.fromFuture(IO(f(body, cc))).attempt.flatMap { + RequestScopeConnection.fromFuture(f(body, cc)).attempt.flatMap { case Right(result) => val jsonString = prettyRender(Extraction.decompose(result)) Created(jsonString).flatTap(recordMetric(result, _)) @@ -231,7 +232,7 @@ object Http4sRequestAttributes { case Right(body) => val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) - result <- IO.fromFuture(IO(f(user, body, cc))) + result <- RequestScopeConnection.fromFuture(f(user, body, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -251,7 +252,7 @@ object Http4sRequestAttributes { case Right(body) => val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) - result <- IO.fromFuture(IO(f(user, body, cc))) + result <- RequestScopeConnection.fromFuture(f(user, body, cc)) } yield result io.attempt.flatMap { case Right(result) => @@ -274,7 +275,7 @@ object Http4sRequestAttributes { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bank, body, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bank, body, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -295,7 +296,7 @@ object Http4sRequestAttributes { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bank, body, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bank, body, cc)) } yield result io.attempt.flatMap { case Right(result) => @@ -315,7 +316,7 @@ object Http4sRequestAttributes { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) bankAccount <- IO.fromOption(cc.bankAccount)(new RuntimeException("BankAccount not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bankAccount, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bankAccount, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -333,7 +334,7 @@ object Http4sRequestAttributes { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) bankAccount <- IO.fromOption(cc.bankAccount)(new RuntimeException("BankAccount not found in CallContext")) view <- IO.fromOption(cc.view)(new RuntimeException("View not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bankAccount, view, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bankAccount, view, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -352,7 +353,7 @@ object Http4sRequestAttributes { bankAccount <- IO.fromOption(cc.bankAccount)(new RuntimeException("BankAccount not found in CallContext")) view <- IO.fromOption(cc.view)(new RuntimeException("View not found in CallContext")) counterparty <- IO.fromOption(cc.counterparty)(new RuntimeException("Counterparty not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bankAccount, view, counterparty, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bankAccount, view, counterparty, cc)) } yield result io.attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) @@ -367,7 +368,7 @@ object Http4sRequestAttributes { */ def executeFuture[A](req: Request[IO])(f: => Future[A])(implicit formats: Formats): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext - IO.fromFuture(IO(f)).attempt.flatMap { + RequestScopeConnection.fromFuture(f).attempt.flatMap { case Right(result) => toJsonOk(result).flatTap(recordMetric(result, _)) case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) } @@ -379,7 +380,7 @@ object Http4sRequestAttributes { */ def executeFutureCreated[A](req: Request[IO])(f: => Future[A])(implicit formats: Formats): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext - IO.fromFuture(IO(f)).attempt.flatMap { + RequestScopeConnection.fromFuture(f).attempt.flatMap { case Right(result) => val jsonString = prettyRender(Extraction.decompose(result)) Created(jsonString).flatTap(recordMetric(result, _)) @@ -393,7 +394,7 @@ object Http4sRequestAttributes { */ def executeDelete(req: Request[IO])(f: CallContext => Future[_]): IO[Response[IO]] = { implicit val cc: CallContext = req.callContext - IO.fromFuture(IO(f(cc))).attempt.flatMap { + RequestScopeConnection.fromFuture(f(cc)).attempt.flatMap { case Right(_) => NoContent().flatTap(recordMetric("", _)) case Left(err) => ErrorResponseConverter.toHttp4sResponse(err, cc).flatTap(recordMetric(err.getMessage, _)) } @@ -407,7 +408,7 @@ object Http4sRequestAttributes { implicit val cc: CallContext = req.callContext val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) - result <- IO.fromFuture(IO(f(user, cc))) + result <- RequestScopeConnection.fromFuture(f(user, cc)) } yield result io.attempt.flatMap { case Right(_) => NoContent().flatTap(recordMetric("", _)) @@ -424,7 +425,7 @@ object Http4sRequestAttributes { val io = for { user <- IO.fromOption(cc.user.toOption)(new RuntimeException("User not found in CallContext")) bank <- IO.fromOption(cc.bank)(new RuntimeException("Bank not found in CallContext")) - result <- IO.fromFuture(IO(f(user, bank, cc))) + result <- RequestScopeConnection.fromFuture(f(user, bank, cc)) } yield result io.attempt.flatMap { case Right(_) => NoContent().flatTap(recordMetric("", _)) @@ -549,7 +550,7 @@ object Http4sCallContextBuilder { * This matcher finds the corresponding ResourceDoc for a given request * and extracts path parameters. */ -object ResourceDocMatcher { +object ResourceDocMatcher extends code.util.Helper.MdcLoggable { // API prefix pattern: /obp/vX.X.X private val apiPrefixPattern = """^/obp/v\d+\.\d+\.\d+""".r @@ -582,12 +583,22 @@ object ResourceDocMatcher { path: Uri.Path, index: ResourceDocIndex ): Option[ResourceDoc] = { - val pathString = path.renderString - val apiVersion = pathString.split("/").filter(_.nonEmpty).drop(1).headOption.getOrElse("") + val pathString = path.renderString + val apiVersion = pathString.split("/").filter(_.nonEmpty).drop(1).headOption.getOrElse("") val strippedPath = apiPrefixPattern.replaceFirstIn(pathString, "") - val segCount = strippedPath.split("/").count(_.nonEmpty) - index.getOrElse((verb.toUpperCase, apiVersion, segCount), Nil) - .find(doc => matchesUrlTemplate(strippedPath, doc.requestUrl)) + val segCount = strippedPath.split("/").count(_.nonEmpty) + val lookupKey = (verb.toUpperCase, apiVersion, segCount) + val candidates = index.getOrElse(lookupKey, Nil) + val result = candidates.find(doc => matchesUrlTemplate(strippedPath, doc.requestUrl)) + if (result.isEmpty) { + logger.debug( + s"[ResourceDocMatcher] No match for $verb $pathString. " + + s"lookupKey=$lookupKey strippedPath='$strippedPath'. " + + s"Candidates with that key: ${if (candidates.isEmpty) "(none)" else candidates.map(d => s"${d.requestVerb} ${d.requestUrl}(${d.implementedInApiVersion})").mkString(", ")}. " + + s"Index keys for apiVersion=$apiVersion: ${index.keys.filter(_._2 == apiVersion).mkString(", ")}" + ) + } + result } /** diff --git a/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala b/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala new file mode 100644 index 0000000000..c8a87c3e14 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/RequestScopeConnection.scala @@ -0,0 +1,200 @@ +package code.api.util.http4s + +import cats.effect.{IO, IOLocal} +import cats.effect.unsafe.IORuntime +import com.alibaba.ttl.TransmittableThreadLocal +import net.liftweb.common.{Box, Full} +import net.liftweb.db.ConnectionManager +import net.liftweb.util.ConnectionIdentifier + +import code.util.Helper.MdcLoggable +import java.lang.reflect.{InvocationHandler, Method, Proxy => JProxy} +import java.sql.Connection +import scala.concurrent.Future + +/** + * Request-scoped transaction support for v7 http4s endpoints. + * + * PROBLEM: Lift Mapper uses a plain ThreadLocal for connection tracking, while + * cats-effect IO switches compute threads across flatMap / IO.fromFuture boundaries. + * A single DB.use scope opened on thread T is invisible on thread T2 after a + * thread switch, so each mapper call would normally open its own connection and + * commit independently — no request-level atomicity. + * + * SOLUTION (two-layer): + * + * Layer 1 — IOLocal (fiber-local, survives IO thread switches): + * Stores the request-scoped proxy for the duration of the request fiber. + * Always readable from any IO step in the same fiber regardless of which + * compute thread is currently executing. + * + * Layer 2 — TransmittableThreadLocal (thread-local, propagated to Futures): + * Set on the compute thread immediately before each IO(Future { }) submission. + * The global ExecutionContext wraps every Runnable with TtlRunnable, which + * captures TTL values from the submitting thread and restores them on the + * worker thread — so the Future body sees the same proxy as the IO fiber. + * + * FLOW per request (ResourceDocMiddleware): + * 1. Borrow a real Connection from HikariCP. + * 2. Wrap it in a non-closing proxy (commit/rollback/close are no-ops). + * 3. Store the proxy in requestProxyLocal (IOLocal) only — currentProxy (TTL) is + * NOT set here to avoid leaving compute threads dirty. + * 4. Run validateRequest + routes.run inside withRequestTransaction. + * 5. Each IO.fromFuture call site uses RequestScopeConnection.fromFuture, which in + * a single synchronous IO.defer block on compute thread T: + * a. Sets currentProxy (TTL) on T. + * b. Evaluates `fut` — the Future is submitted; TtlRunnable captures T's TTL. + * c. Removes currentProxy from T immediately after submission (T is clean). + * d. Awaits the already-submitted future asynchronously. + * 6. Inside the Future, Lift Mapper calls DB.use(DefaultConnectionIdentifier). + * RequestAwareConnectionManager.newConnection reads currentProxy (TTL — set by + * TtlRunnable on the worker thread) and returns the proxy → all mapper calls + * share one underlying Connection. + * 7. The proxy's no-op commit/close prevents Lift from committing or releasing + * the connection at the end of each individual DB.use scope. + * 8. At request end: commit (or rollback on exception) and close the real connection. + * + * METRIC WRITES: recordMetric runs in IO.blocking (blocking pool, no TTL from compute + * thread). currentProxy.get() returns null there, so RequestAwareConnectionManager + * falls back to the pool — metric writes use a separate connection and commit + * independently, matching v6 behaviour. + * + * NON-V7 PATHS (v6 via bridge, background tasks): requestProxyLocal is not set, + * currentProxy is null — RequestAwareConnectionManager delegates to APIUtil.vendor + * as before. DB.buildLoanWrapper (v6) continues to manage its own transaction. + */ +object RequestScopeConnection extends MdcLoggable { + + /** + * Fiber-local proxy reference. Readable from any IO step in the request fiber + * regardless of which compute thread runs it. This is the source of truth. + */ + val requestProxyLocal: IOLocal[Option[Connection]] = + IOLocal[Option[Connection]](None).unsafeRunSync()(IORuntime.global) + + /** + * Thread-local proxy reference, propagated to Future workers via TtlRunnable. + * Set from requestProxyLocal immediately before each IO(Future { }) submission. + */ + val currentProxy: TransmittableThreadLocal[Connection] = + new TransmittableThreadLocal[Connection]() + + /** + * Wrap a real Connection in a proxy that no-ops commit, rollback, and close. + * All other methods delegate to the real connection. + * + * This prevents Lift's per-DB.use lifecycle from committing or returning the + * connection to the pool before the request transaction scope ends. + */ + def makeProxy(real: Connection): Connection = + JProxy.newProxyInstance( + classOf[Connection].getClassLoader, + Array(classOf[Connection]), + new InvocationHandler { + def invoke(proxy: Any, method: Method, args: Array[AnyRef]): AnyRef = + method.getName match { + case "commit" | "rollback" | "close" => null + case _ => + try { + val result = + if (args == null || args.isEmpty) method.invoke(real) + else method.invoke(real, args: _*) + if (result == null || method.getReturnType == Void.TYPE) null else result + } catch { + case e: java.lang.reflect.InvocationTargetException + if Option(e.getCause).exists(_.isInstanceOf[java.sql.SQLException]) => + logger.error( + s"[RequestScopeProxy] method=${method.getName} failed on closed/returned connection. " + + s"This means the request-scoped proxy was handed to code that ran AFTER withRequestTransaction " + + s"committed and closed the underlying connection. " + + s"Likely cause: v7 path fell through to Http4sLiftWebBridge without a transaction scope — " + + s"currentProxy was still set on this thread from a previous fiber or was not cleared. " + + s"Cause: ${e.getCause.getMessage}", + e.getCause + ) + throw e + } + } + } + ).asInstanceOf[Connection] + + /** + * Drop-in replacement for IO.fromFuture(IO(fut)). + * + * Reads the request proxy from the IOLocal (reliable across IO thread switches), + * then — in a single synchronous IO.defer block on the current compute thread T: + * 1. Sets TTL on T so TtlRunnable captures it at Future-submission time. + * 2. Evaluates `fut`, which submits the Future to the OBP EC; the TtlRunnable + * wraps the submitted task and carries the proxy to the Future's worker thread. + * 3. Removes the TTL from T immediately, so T is clean after this step. + * 4. Returns IO.fromFuture(IO.pure(f)) to await the already-submitted future. + * + * Steps 1-3 are synchronous within IO.defer, guaranteeing they all run on T before + * any fiber scheduling can switch threads. The Future worker still receives the + * proxy via the TtlRunnable captured in step 2. + */ + def fromFuture[A](fut: => Future[A]): IO[A] = + requestProxyLocal.get.flatMap { proxyOpt => + IO.defer { + proxyOpt.foreach(currentProxy.set) // (1) set TTL on current thread T + val f = fut // (2) submit Future; TtlRunnable captures proxy from T + currentProxy.remove() // (3) clear TTL on T — T is clean after this point + IO.fromFuture(IO.pure(f)) // await the already-submitted future + } + } +} + +/** + * ConnectionManager that returns the request-scoped proxy when a transaction is + * active, delegating to the original vendor otherwise. + * + * Registered in Boot.scala instead of APIUtil.vendor directly: + * DB.defineConnectionManager(..., new RequestAwareConnectionManager(APIUtil.vendor)) + * + * Used by: + * - v7 native endpoints (gets proxy from TTL, set right before Future submission) + * - v6 via bridge / background tasks (TTL is null → delegates to vendor as before) + */ +class RequestAwareConnectionManager(delegate: ConnectionManager) extends ConnectionManager with MdcLoggable { + + override def newConnection(name: ConnectionIdentifier): Box[Connection] = { + val proxy = RequestScopeConnection.currentProxy.get() + if (proxy != null) { + // Guard: if the underlying connection is already closed, the proxy is stale — it + // was captured in a TtlRunnable submitted during a prior request and that request's + // withRequestTransaction has already committed and closed the real connection. + // Returning a stale proxy would throw "Connection is closed" inside the caller's + // DB.use and, if that caller is inside authenticate, would be caught as Left(_) + // and silently turned into a 401 response. + val proxyIsClosed = try { proxy.isClosed() } catch { case e: Exception => + logger.warn(s"[RequestAwareConnectionManager] isClosed() threw on proxy: ${e.getClass.getName}: ${e.getMessage}") + true + } + if (!proxyIsClosed) Full(proxy) + else { + logger.warn( + s"[RequestAwareConnectionManager] newConnection: stale proxy (underlying connection already " + + s"closed) — falling back to fresh vendor connection" + ) + delegate.newConnection(name) + } + } else { + delegate.newConnection(name) + } + } + + /** + * If conn is our request proxy, skip release — it is managed by withRequestTransaction. + * Otherwise delegate to the original vendor (which does HikariCP ProxyConnection.close()). + * + * Reference equality is safe: one proxy instance per request, same object throughout. + */ + override def releaseConnection(conn: Connection): Unit = { + val proxy = RequestScopeConnection.currentProxy.get() + if (proxy != null && (conn eq proxy.asInstanceOf[AnyRef])) { + // Skip release — this connection is managed by withRequestTransaction. + } else { + delegate.releaseConnection(conn) + } + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index 1fb0a168a8..990579b56a 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -17,6 +17,7 @@ import org.http4s._ import org.http4s.headers.`Content-Type` import scala.collection.mutable.ArrayBuffer +import scala.util.control.NonFatal /** * ResourceDoc-driven validation middleware for http4s. @@ -98,17 +99,63 @@ object ResourceDocMiddleware extends MdcLoggable { case Some(resourceDoc) => val ccWithDoc = ResourceDocMatcher.attachToCallContext(cc, resourceDoc) val pathParams = ResourceDocMatcher.extractPathParams(req.uri.path, resourceDoc) - // Run full validation chain - OptionT(validateRequest(req, resourceDoc, pathParams, ccWithDoc, routes).map(Option(_))) + // Wrap in a request-scoped transaction, then run full validation chain + OptionT(withRequestTransaction( + validateRequest(req, resourceDoc, pathParams, ccWithDoc, routes) + ).map(Option(_))) case None => - // No matching ResourceDoc: fallback to original route + // No matching ResourceDoc: fallback to original route (NO transaction scope opened). + // ResourceDocMatcher.findResourceDoc already logged a WARN with full key/index detail. + // Any background DB calls triggered by the Lift bridge for this request will use + // RequestAwareConnectionManager, which now falls back to a fresh vendor connection + // when the TTL-stale proxy is detected as closed. routes.run(req) } } } } + /** + * Wraps an IO[Response[IO]] in a request-scoped DB transaction. + * + * Borrows a Connection from HikariCP, wraps it in a non-closing proxy (so Lift's + * internal DB.use lifecycle cannot commit or return it to the pool prematurely), + * and stores it in requestProxyLocal (IOLocal — fiber-local source of truth). + * + * currentProxy (TTL) is NOT set here. Every DB call goes through + * RequestScopeConnection.fromFuture, which atomically sets + submits + clears the + * TTL within a single IO.defer block on the compute thread, so the thread is never + * left dirty after the fromFuture call returns. + * + * On success: commits and closes the real connection. + * On exception: rolls back and closes the real connection. + * + * Metric writes (IO.blocking in recordMetric) run on the blocking pool where + * currentProxy is not set — they get their own pool connection and commit + * independently, matching v6 behaviour. + */ + private def withRequestTransaction(io: IO[Response[IO]]): IO[Response[IO]] = { + for { + realConn <- IO.blocking(APIUtil.vendor.HikariDatasource.ds.getConnection()) + proxy = RequestScopeConnection.makeProxy(realConn) + _ <- RequestScopeConnection.requestProxyLocal.set(Some(proxy)) + // Note: currentProxy (TTL) is NOT set here. Every DB call goes through + // RequestScopeConnection.fromFuture, which atomically sets + submits + clears + // the TTL within a single IO.defer block on the compute thread. Setting it + // here would leave the compute thread's TTL dirty if guaranteeCase runs on a + // different thread. + result <- io.guaranteeCase { + case Outcome.Succeeded(_) => + RequestScopeConnection.requestProxyLocal.set(None) *> + IO.blocking { try { realConn.commit() } finally { realConn.close() } } + case _ => + RequestScopeConnection.requestProxyLocal.set(None) *> + IO.blocking { try { realConn.rollback() } finally { realConn.close() } } + } + } yield result + } + /** * Executes the full validation chain for the request. * Returns either an error Response or enriched request routed to the handler. @@ -160,8 +207,8 @@ object ResourceDocMiddleware extends MdcLoggable { logger.debug(s"[ResourceDocMiddleware] needsAuthentication for ${resourceDoc.partialFunctionName}: $needsAuth") val io = - if (needsAuth) IO.fromFuture(IO(APIUtil.authenticatedAccess(ctx.callContext))) - else IO.fromFuture(IO(APIUtil.anonymousAccess(ctx.callContext))) + if (needsAuth) RequestScopeConnection.fromFuture(APIUtil.authenticatedAccess(ctx.callContext)) + else RequestScopeConnection.fromFuture(APIUtil.anonymousAccess(ctx.callContext)) EitherT( io.attempt.flatMap { @@ -219,7 +266,7 @@ object ResourceDocMiddleware extends MdcLoggable { pathParams.get("BANK_ID") match { case Some(bankId) => EitherT( - IO.fromFuture(IO(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext)))) + RequestScopeConnection.fromFuture(NewStyle.function.getBank(BankId(bankId), Some(ctx.callContext))) .attempt.flatMap { case Right((bank, Some(updatedCC))) => IO.pure(Right(ctx.copy(bank = Some(bank), callContext = updatedCC))) case Right((bank, None)) => IO.pure(Right(ctx.copy(bank = Some(bank)))) @@ -237,7 +284,7 @@ object ResourceDocMiddleware extends MdcLoggable { (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID")) match { case (Some(bankId), Some(accountId)) => EitherT( - IO.fromFuture(IO(NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(ctx.callContext)))) + RequestScopeConnection.fromFuture(NewStyle.function.getBankAccount(BankId(bankId), AccountId(accountId), Some(ctx.callContext))) .attempt.flatMap { case Right((acc, Some(updatedCC))) => IO.pure(Right(ctx.copy(account = Some(acc), callContext = updatedCC))) case Right((acc, None)) => IO.pure(Right(ctx.copy(account = Some(acc)))) @@ -255,7 +302,7 @@ object ResourceDocMiddleware extends MdcLoggable { (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("VIEW_ID")) match { case (Some(bankId), Some(accountId), Some(viewId)) => EitherT( - IO.fromFuture(IO(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), ctx.user.toOption, Some(ctx.callContext)))) + RequestScopeConnection.fromFuture(ViewNewStyle.checkViewAccessAndReturnView(ViewId(viewId), BankIdAccountId(BankId(bankId), AccountId(accountId)), ctx.user.toOption, Some(ctx.callContext))) .attempt.flatMap { case Right(view) => IO.pure(Right(ctx.copy(view = Some(view)))) case Left(e: APIFailureNewStyle) => ErrorResponseConverter.createErrorResponse(e.failCode, e.failMsg, ctx.callContext).map(Left(_)) @@ -272,7 +319,7 @@ object ResourceDocMiddleware extends MdcLoggable { (pathParams.get("BANK_ID"), pathParams.get("ACCOUNT_ID"), pathParams.get("COUNTERPARTY_ID")) match { case (Some(bankId), Some(accountId), Some(counterpartyId)) => EitherT( - IO.fromFuture(IO(NewStyle.function.getCounterpartyTrait(BankId(bankId), AccountId(accountId), counterpartyId, Some(ctx.callContext)))) + RequestScopeConnection.fromFuture(NewStyle.function.getCounterpartyTrait(BankId(bankId), AccountId(accountId), counterpartyId, Some(ctx.callContext))) .attempt.flatMap { case Right((cp, Some(updatedCC))) => IO.pure(Right(ctx.copy(counterparty = Some(cp), callContext = updatedCC))) case Right((cp, None)) => IO.pure(Right(ctx.copy(counterparty = Some(cp)))) 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 f3ca840ff2..3c1d49fc8a 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 @@ -2,12 +2,13 @@ package code.api.v7_0_0 import cats.data.{Kleisli, OptionT} import cats.effect._ +import code.api.Constant import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCardsForBank, canGetCustomersAtOneBank} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ import code.api.util.http4s.{ErrorResponseConverter, Http4sRequestAttributes, ResourceDocMiddleware} @@ -17,7 +18,10 @@ import code.api.v1_3_0.JSONFactory1_3_0 import code.api.v1_4_0.JSONFactory1_4_0 import code.api.v2_0_0.{BasicViewJson, CreateEntitlementJSON, JSONFactory200} import code.api.v4_0_0.JSONFactory400 -import code.api.v6_0_0.{BasicAccountJsonV600, BasicAccountsJsonV600, BankJsonV600, ConnectorInfoJsonV600, ConnectorsJsonV600, FeaturesJsonV600, JSONFactory600, UserV600} +import code.api.v6_0_0.{BasicAccountJsonV600, BasicAccountsJsonV600, BankJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CacheNamespaceJsonV600, CacheNamespacesJsonV600, ConnectorInfoJsonV600, ConnectorsJsonV600, DatabasePoolInfoJsonV600, FeaturesJsonV600, InMemoryCacheStatusJsonV600, JSONFactory600, RedisCacheStatusJsonV600, StoredProcedureConnectorHealthJsonV600, UserV600} +import code.api.cache.Redis +import code.bankconnectors.storedprocedure.StoredProcedureUtils +import code.migration.MigrationScriptLogProvider import code.bankconnectors.{Connector => BankConnector} import code.entitlement.Entitlement import code.metadata.tags.Tags @@ -218,15 +222,9 @@ object Http4s700 { ) { ApiVersionUtils.valueOf(requestedApiVersionString) } - _ <- Helper.booleanToFuture( - failMsg = s"$InvalidApiVersionString This server supports only ${ApiVersion.v7_0_0}. Current value: $requestedApiVersionString", - failCode = 400, - cc = Some(cc) - ) { - requestedApiVersion == ApiVersion.v7_0_0 - } - http4sOnlyDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(resourceDocs.toList, tags, functions) - } yield JSONFactory1_4_0.createResourceDocsJson(http4sOnlyDocs, isVersion4OrHigher = true, localeParam, includeTechnology = true) + allDocs = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) + filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(allDocs, tags, functions) + } yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam, includeTechnology = true) } } @@ -864,6 +862,235 @@ object Http4s700 { // ── End Phase 1 batch 2 ────────────────────────────────────────────────── + // ── Phase 1 batch 3 — system endpoints ────────────────────────────────── + + // Route: GET /obp/v7.0.0/system/cache/config + val getCacheConfig: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "cache" / "config" => + EndpointHelpers.withUser(req) { (_, cc) => + Future.successful(JSONFactory600.createCacheConfigJsonV600()) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCacheConfig), + "GET", + "/system/cache/config", + "Get Cache Configuration", + """Returns cache configuration including Redis status, in-memory cache status, instance ID, environment and global prefix.""", + EmptyBody, + CacheConfigJsonV600( + redis_status = RedisCacheStatusJsonV600(available = true, url = "127.0.0.1", port = 6379, use_ssl = false), + in_memory_status = InMemoryCacheStatusJsonV600(available = true, current_size = 42), + instance_id = "obp", + environment = "dev", + global_prefix = "obp_dev_" + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetCacheConfig)), + http4sPartialFunction = Some(getCacheConfig) + ) + + // Route: GET /obp/v7.0.0/system/cache/info + val getCacheInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "cache" / "info" => + EndpointHelpers.withUser(req) { (_, cc) => + Future.successful(JSONFactory600.createCacheInfoJsonV600()) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCacheInfo), + "GET", + "/system/cache/info", + "Get Cache Information", + """Returns detailed cache information for all namespaces including key counts, TTL info and storage location.""", + EmptyBody, + CacheInfoJsonV600( + namespaces = List(CacheNamespaceInfoJsonV600( + namespace_id = "call_counter", + prefix = "obp_dev_call_counter_1_", + current_version = 1, + key_count = 42, + description = "Rate limit call counters", + category = "Rate Limiting", + storage_location = "redis", + ttl_info = "range 60s to 86400s (avg 3600s)" + )), + total_keys = 42, + redis_available = true + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetCacheInfo)), + http4sPartialFunction = Some(getCacheInfo) + ) + + // Route: GET /obp/v7.0.0/system/database/pool + val getDatabasePoolInfo: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "database" / "pool" => + EndpointHelpers.withUser(req) { (_, cc) => + Future.successful(JSONFactory600.createDatabasePoolInfoJsonV600()) + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getDatabasePoolInfo), + "GET", + "/system/database/pool", + "Get Database Pool Information", + """Returns HikariCP connection pool information including active/idle connections, pool size and timeouts.""", + EmptyBody, + DatabasePoolInfoJsonV600( + pool_name = "HikariPool-1", + active_connections = 5, + idle_connections = 3, + total_connections = 8, + threads_awaiting_connection = 0, + maximum_pool_size = 10, + minimum_idle = 2, + connection_timeout_ms = 30000, + idle_timeout_ms = 600000, + max_lifetime_ms = 1800000, + keepalive_time_ms = 0 + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetDatabasePoolInfo)), + http4sPartialFunction = Some(getDatabasePoolInfo) + ) + + // Route: GET /obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health + val getStoredProcedureConnectorHealth: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "connectors" / "stored_procedure_vDec2019" / "health" => + EndpointHelpers.withUser(req) { (_, cc) => + Future { + val health = StoredProcedureUtils.getHealth() + StoredProcedureConnectorHealthJsonV600( + status = health.status, + server_name = health.serverName, + server_ip = health.serverIp, + database_name = health.databaseName, + response_time_ms = health.responseTimeMs, + error_message = health.errorMessage + ) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getStoredProcedureConnectorHealth), + "GET", + "/system/connectors/stored_procedure_vDec2019/health", + "Get Stored Procedure Connector Health", + """Returns health status of the stored procedure connector including connection status, server name and response time.""", + EmptyBody, + StoredProcedureConnectorHealthJsonV600( + status = "ok", + server_name = Some("DBSERVER01"), + server_ip = Some("10.0.1.50"), + database_name = Some("obp_adapter"), + response_time_ms = 45, + error_message = None + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagConnector :: apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetConnectorHealth)), + http4sPartialFunction = Some(getStoredProcedureConnectorHealth) + ) + + // Route: GET /obp/v7.0.0/system/migrations + val getMigrations: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "migrations" => + EndpointHelpers.withUser(req) { (_, cc) => + Future { + val migrations = MigrationScriptLogProvider.migrationScriptLogProvider.vend.getMigrationScriptLogs() + JSONFactory600.createMigrationScriptLogsJsonV600(migrations) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getMigrations), + "GET", + "/system/migrations", + "Get Database Migrations", + """Get all database migration script logs. Returns information about all migration scripts that have been executed or attempted.""", + EmptyBody, + migrationScriptLogsJsonV600, + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetMigrations)), + http4sPartialFunction = Some(getMigrations) + ) + + // Route: GET /obp/v7.0.0/system/cache/namespaces + val getCacheNamespaces: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> `prefixPath` / "system" / "cache" / "namespaces" => + EndpointHelpers.withUser(req) { (_, cc) => + Future { + val namespaces = List( + (Constant.CALL_COUNTER_PREFIX, "Rate limiting counters per consumer and time period", "varies", "Rate Limiting"), + (Constant.RATE_LIMIT_ACTIVE_PREFIX, "Active rate limit configurations", Constant.RATE_LIMIT_ACTIVE_CACHE_TTL.toString, "Rate Limiting"), + (Constant.LOCALISED_RESOURCE_DOC_PREFIX, "Localized resource documentation", Constant.CREATE_LOCALISED_RESOURCE_DOC_JSON_TTL.toString, "Resource Documentation"), + (Constant.DYNAMIC_RESOURCE_DOC_CACHE_KEY_PREFIX, "Dynamic resource documentation", Constant.GET_DYNAMIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.STATIC_RESOURCE_DOC_CACHE_KEY_PREFIX, "Static resource documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.ALL_RESOURCE_DOC_CACHE_KEY_PREFIX, "All resource documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.STATIC_SWAGGER_DOC_CACHE_KEY_PREFIX, "Swagger documentation", Constant.GET_STATIC_RESOURCE_DOCS_TTL.toString, "Resource Documentation"), + (Constant.CONNECTOR_PREFIX, "Connector method names and metadata", "3600", "Connector"), + (Constant.METRICS_STABLE_PREFIX, "Stable metrics (historical)", "86400", "Metrics"), + (Constant.METRICS_RECENT_PREFIX, "Recent metrics", "7", "Metrics"), + (Constant.ABAC_RULE_PREFIX, "ABAC rule cache", "indefinite", "ABAC") + ).map { case (prefix, description, ttl, category) => + JSONFactory600.createCacheNamespaceJsonV600( + prefix, description, ttl, category, + Redis.countKeys(s"${prefix}*"), + Redis.getSampleKey(s"${prefix}*") + ) + } + JSONFactory600.createCacheNamespacesJsonV600(namespaces) + } + } + } + + resourceDocs += ResourceDoc( + null, + implementedInApiVersion, + nameOf(getCacheNamespaces), + "GET", + "/system/cache/namespaces", + "Get Cache Namespaces", + """Returns information about all cache namespaces in the system including key counts, TTL and example keys.""", + EmptyBody, + CacheNamespacesJsonV600(List( + CacheNamespaceJsonV600( + prefix = "obp_dev_call_counter_1_", + description = "Rate limiting counters per consumer and time period", + ttl_seconds = "varies", + category = "Rate Limiting", + key_count = 42, + example_key = "obp_dev_call_counter_1_consumer123_PER_MINUTE" + ) + )), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, UnknownError), + apiTagCache :: apiTagSystem :: apiTagApi :: Nil, + Some(List(canGetCacheNamespaces)), + http4sPartialFunction = Some(getCacheNamespaces) + ) + + // ── End Phase 1 batch 3 ────────────────────────────────────────────────── + // All routes combined (without middleware - for direct use). // // Routes are sorted automatically by URL template specificity (segment count, diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala index 8135580af2..1c4b623424 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/ResourceDocsTest.scala @@ -107,7 +107,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with responseGetObp.code should equal(200) responseDocs.resource_docs.head.implemented_by.technology shouldBe Some(Constant.TECHNOLOGY_LIFTWEB) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq600", ApiEndpoint1, VersionOfApi) { val requestGetObp = (ResourceDocsV6_0Request / "resource-docs" / fq600 / "obp").GET @@ -116,7 +116,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v500", ApiEndpoint1, VersionOfApi) { val requestGetObp = (ResourceDocsV5_0Request / "resource-docs" / v500 / "obp").GET @@ -126,7 +126,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with responseGetObp.code should equal(200) responseDocs.resource_docs.head.implemented_by.technology shouldBe None //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario("Test OpenAPI endpoint with valid parameters", ApiEndpoint1, VersionOfApi) { @@ -181,7 +181,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq500", ApiEndpoint1, VersionOfApi) { @@ -191,7 +191,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v400", ApiEndpoint1, VersionOfApi) { @@ -201,7 +201,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq400", ApiEndpoint1, VersionOfApi) { @@ -211,7 +211,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v310", ApiEndpoint1, VersionOfApi) { @@ -222,7 +222,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with org.scalameta.logger.elem(responseGetObp) responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq310", ApiEndpoint1, VersionOfApi) { @@ -232,7 +232,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v300", ApiEndpoint1, VersionOfApi) { @@ -242,7 +242,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq300", ApiEndpoint1, VersionOfApi) { @@ -252,7 +252,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v220", ApiEndpoint1, VersionOfApi) { @@ -262,7 +262,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq220", ApiEndpoint1, VersionOfApi) { @@ -272,7 +272,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v210", ApiEndpoint1, VersionOfApi) { @@ -282,7 +282,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq210", ApiEndpoint1, VersionOfApi) { @@ -292,7 +292,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v200", ApiEndpoint1, VersionOfApi) { @@ -302,7 +302,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq200", ApiEndpoint1, VersionOfApi) { @@ -312,7 +312,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v140", ApiEndpoint1, VersionOfApi) { @@ -330,7 +330,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v130", ApiEndpoint1, VersionOfApi) { @@ -340,7 +340,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq130", ApiEndpoint1, VersionOfApi) { @@ -350,7 +350,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v121", ApiEndpoint1, VersionOfApi) { @@ -360,7 +360,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$fq121", ApiEndpoint1, VersionOfApi) { @@ -370,7 +370,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -v1.3", ApiEndpoint1, VersionOfApi) { @@ -380,7 +380,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -BGv1.3", ApiEndpoint1, VersionOfApi) { @@ -390,7 +390,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -v3.1", ApiEndpoint1, VersionOfApi) { @@ -400,7 +400,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -UKv3.1", ApiEndpoint1, VersionOfApi) { @@ -410,7 +410,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint1.name} Api -$v400 - resource_docs_requires_role props", ApiEndpoint1, VersionOfApi) { @@ -448,7 +448,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq600", ApiEndpoint1, VersionOfApi) { val requestGetObp = (ResourceDocsV1_4Request /"banks"/ testBankId1.value/ "resource-docs" / fq600 / "obp").GET @@ -457,7 +457,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v500/$v400", ApiEndpoint1, VersionOfApi) { val requestGetObp = (ResourceDocsV1_4Request /"banks"/ testBankId1.value/ "resource-docs" / v500 / "obp").GET @@ -466,7 +466,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v400", ApiEndpoint1, VersionOfApi) { @@ -476,7 +476,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq400", ApiEndpoint1, VersionOfApi) { @@ -486,7 +486,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v310", ApiEndpoint1, VersionOfApi) { @@ -497,7 +497,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with org.scalameta.logger.elem(responseGetObp) responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq310", ApiEndpoint1, VersionOfApi) { @@ -507,7 +507,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v300", ApiEndpoint1, VersionOfApi) { @@ -517,7 +517,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq300", ApiEndpoint1, VersionOfApi) { @@ -527,7 +527,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v220", ApiEndpoint1, VersionOfApi) { @@ -537,7 +537,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq220", ApiEndpoint1, VersionOfApi) { @@ -547,7 +547,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v210", ApiEndpoint1, VersionOfApi) { @@ -557,7 +557,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq210", ApiEndpoint1, VersionOfApi) { @@ -567,7 +567,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v200", ApiEndpoint1, VersionOfApi) { @@ -577,7 +577,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq200", ApiEndpoint1, VersionOfApi) { @@ -587,7 +587,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v140", ApiEndpoint1, VersionOfApi) { @@ -605,7 +605,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v130", ApiEndpoint1, VersionOfApi) { @@ -615,7 +615,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq130", ApiEndpoint1, VersionOfApi) { @@ -625,7 +625,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v121", ApiEndpoint1, VersionOfApi) { @@ -635,7 +635,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$fq121", ApiEndpoint1, VersionOfApi) { @@ -645,7 +645,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -v1.3", ApiEndpoint1, VersionOfApi) { @@ -655,7 +655,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -BGv1.3", ApiEndpoint1, VersionOfApi) { @@ -665,7 +665,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -v3.1", ApiEndpoint1, VersionOfApi) { @@ -675,7 +675,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -UKv3.1", ApiEndpoint1, VersionOfApi) { @@ -685,7 +685,7 @@ class ResourceDocsTest extends ResourceDocsV140ServerSetup with PropsReset with val responseDocs = responseGetObp.body.extract[ResourceDocsJson] responseGetObp.code should equal(200) //This should not throw any exceptions - responseDocs.resource_docs.map(responseDoc => stringToNodeSeq(responseDoc.description)) + responseDocs.resource_docs.take(3).foreach(doc => stringToNodeSeq(doc.description)) } scenario(s"We will test ${ApiEndpoint2.name} Api -$v400 - resource_docs_requires_role props", ApiEndpoint1, VersionOfApi) { diff --git a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala index ee3326acec..1412bf74c3 100644 --- a/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala +++ b/obp-api/src/test/scala/code/api/ResourceDocs1_4_0/SwaggerDocsTest.scala @@ -85,30 +85,6 @@ class SwaggerDocsTest extends ResourceDocsV140ServerSetup with PropsReset with D errors.isEmpty should be (true) } - scenario(s"We will test ${ApiEndpoint1.name} Api - v5.0.0/v5.0.0 ", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV5_0Request / "resource-docs" / "v5.0.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - if (!errors.isEmpty) logger.info(s"Here is the wrong swagger json: $swaggerJsonString") - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v5.0.0/v4.0.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV5_0Request / "resource-docs" / "v4.0.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - if (!errors.isEmpty) logger.info(s"Here is the wrong swagger json: $swaggerJsonString") - errors.isEmpty should be (true) - } - scenario(s"We will test ${ApiEndpoint1.name} Api - v4.0.0", ApiEndpoint1, VersionOfApi) { val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v4.0.0" / "swagger").GET val responseGetObp = makeGetRequest(requestGetObp) @@ -121,88 +97,6 @@ class SwaggerDocsTest extends ResourceDocsV140ServerSetup with PropsReset with D errors.isEmpty should be (true) } - scenario(s"We will test ${ApiEndpoint1.name} Api - v3.1.1", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v3.1.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v3.0.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v3.0.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v2.2.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v2.2.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v2.1.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v2.1.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v2.0.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v2.0.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v1.4.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v1.4.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - - scenario(s"We will test ${ApiEndpoint1.name} Api - v1.3.0", ApiEndpoint1, VersionOfApi) { - val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v1.3.0" / "swagger").GET - val responseGetObp = makeGetRequest(requestGetObp) - And("We should get 200 and the response can be extract to case classes") - responseGetObp.code should equal(200) - val swaggerJsonString = json.compactRender(responseGetObp.body) - - val validatedSwaggerResult = ValidateSwaggerString(swaggerJsonString) - val errors = validatedSwaggerResult._1 - errors.isEmpty should be (true) - } - scenario(s"We will test ${ApiEndpoint1.name} Api - v1.2.1", ApiEndpoint1, VersionOfApi) { val requestGetObp = (ResourceDocsV4_0Request / "resource-docs" / "v1.2.1" / "swagger").GET val responseGetObp = makeGetRequest(requestGetObp) diff --git a/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala b/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala index 888e47f267..666dfe406e 100644 --- a/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala +++ b/obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala @@ -95,6 +95,11 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { ) private val ukObVersions = List("v2.0", "v3.1") + // Reduced iteration counts keep CI fast while still catching probabilistic bugs. + // Run with CI_ITERATIONS=10 locally or in nightly builds for full coverage. + private val CI_ITERATIONS = 3 + private val CI_ITERATIONS_HEAVY = 5 + object PropertyTag extends Tag("lift-to-http4s-migration-property") private val http4sServer = Http4sTestServer @@ -234,7 +239,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.1: All registered public endpoints return valid responses (10 iterations)", PropertyTag) { var successCount = 0 var failureCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -267,7 +272,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.2: Handler priority is consistent (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -292,7 +297,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.3: Missing handlers return 404 with error message (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val randomPath = s"/obp/v5.0.0/nonexistent/${randomString(10)}" @@ -317,7 +322,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.4: Authentication failures return consistent error responses (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -344,7 +349,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.5: POST requests are properly dispatched (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val path = "/my/logins/direct" @@ -372,7 +377,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.6: Concurrent requests are handled correctly (10 iterations)", PropertyTag) { import scala.concurrent.Future - val iterations = 10 + val iterations = CI_ITERATIONS val batchSize = 10 // Process in batches to avoid overwhelming the server var successCount = 0 @@ -409,7 +414,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 6.7: Error responses have consistent structure (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => // Generate random invalid paths @@ -485,7 +490,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 4.1: Random invalid DirectLogin credentials rejected via DirectLogin header (10 iterations)", PropertyTag) { // **Validates: Requirements 4.1, 4.5** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val (user, pass, key) = genRandomDirectLoginCredentials() @@ -518,7 +523,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 4.2: Random invalid DirectLogin credentials rejected via Authorization header (10 iterations)", PropertyTag) { // **Validates: Requirements 4.4, 4.5** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = randomVersion() @@ -555,7 +560,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { } var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(prop4Rand.nextInt(apiVersions.length)) @@ -591,7 +596,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { } var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(prop4Rand.nextInt(apiVersions.length)) @@ -621,7 +626,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 4.5: Random invalid Gateway tokens rejected (10 iterations)", PropertyTag) { // **Validates: Requirements 4.3, 4.5** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = randomVersion() @@ -652,7 +657,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 4.6: Auth failure error responses consistent between DirectLogin and Authorization headers (10 iterations)", PropertyTag) { // **Validates: Requirements 4.4, 4.5** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val token = genRandomToken() @@ -695,7 +700,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 4.7: Missing auth on authenticated endpoints returns 4xx (10 iterations)", PropertyTag) { // **Validates: Requirements 4.5** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = randomVersion() @@ -730,7 +735,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 7.1: Concurrent requests maintain session/context thread-safety (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (0 until iterations).foreach { i => val random = new Random(i) @@ -793,7 +798,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 7.2: Session lifecycle is properly managed across requests (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (0 until iterations).foreach { i => val random = new Random(i) @@ -823,7 +828,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 7.3: Request adapter provides correct HTTP metadata (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (0 until iterations).foreach { i => val random = new Random(i) @@ -860,7 +865,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 7.4: Context operations work correctly under load (10 iterations)", PropertyTag) { var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (0 until iterations).foreach { i => val random = new Random(i) @@ -900,7 +905,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // --- 8.1.1: 404 Not Found - non-existent endpoints return consistent error JSON --- scenario("8.1.1: 404 Not Found responses have consistent JSON structure with 'code' and 'message' fields", ErrorResponseValidationTag) { // **Validates: Requirements 6.3, 8.2** - val iterations = 20 + val iterations = CI_ITERATIONS_HEAVY (1 to iterations).foreach { i => val randomSuffix = randomString(10) @@ -955,7 +960,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // --- 8.1.2: 401 Unauthorized - missing auth returns consistent error JSON --- scenario("8.1.2: 401 Unauthorized responses have consistent JSON structure", ErrorResponseValidationTag) { // **Validates: Requirements 6.3, 8.2** - val iterations = 20 + val iterations = CI_ITERATIONS_HEAVY (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1006,7 +1011,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // --- 8.1.3: Invalid auth token returns consistent error JSON --- scenario("8.1.3: Invalid auth token responses have consistent JSON structure", ErrorResponseValidationTag) { // **Validates: Requirements 6.3, 8.2** - val iterations = 20 + val iterations = CI_ITERATIONS_HEAVY (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1099,7 +1104,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("8.1.9: Error responses are always valid parseable JSON (10 iterations)", ErrorResponseValidationTag) { // **Validates: Requirements 6.3** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1168,7 +1173,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 8.2, 10.3** // Exercises: No handler found → errorJsonResponse(InvalidUri, 404) var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1216,7 +1221,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 8.2, 10.3** // Exercises: JsonResponseException path (auth failures throw JsonResponseException in OBP) var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1264,7 +1269,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 8.2, 10.3** // Exercises: All error paths - verifies JSON validity and structure consistency var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS // Mix of error-triggering paths val errorPathGenerators: List[() => String] = List( @@ -1342,7 +1347,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 8.2, 10.3** // Exercises: All error paths - verifies header injection works on error responses var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1438,7 +1443,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 5.1: Correlation-Id is present on all responses across random endpoints (10 iterations)", HeaderPreservationTag) { // **Validates: Requirements 6.2** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1472,7 +1477,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 5.2: Cache-Control and X-Frame-Options present on all responses (10 iterations)", HeaderPreservationTag) { // **Validates: Requirements 6.2** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1510,7 +1515,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 5.3: Content-Type is application/json on JSON responses (10 iterations)", HeaderPreservationTag) { // **Validates: Requirements 6.2** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1543,7 +1548,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 5.4: All standard headers present on error responses (10 iterations)", HeaderPreservationTag) { // **Validates: Requirements 6.2, 6.4** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => // Generate requests that produce various error responses @@ -1578,7 +1583,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // The bridge extracts Correlation-Id from request X-Request-ID header if present, // otherwise generates a new UUID. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -1614,7 +1619,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 5.7: Standard headers present across all API standards (10 iterations)", HeaderPreservationTag) { // **Validates: Requirements 6.2, 6.4** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS // Combine OBP standard + international standard endpoints val allEndpoints: List[String] = { @@ -1672,7 +1677,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Every response from the HTTP4S bridge must include a Correlation-Id header // with a non-empty value, ensuring correlation tracking is always available. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS val endpoints = List( "/obp/v5.0.0/banks", @@ -1710,7 +1715,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 8.3** // When no X-Request-ID is provided, the bridge must generate a unique // Correlation-Id for each request. No two requests should share the same ID. - val iterations = 10 + val iterations = CI_ITERATIONS val correlationIds = scala.collection.mutable.Set[String]() (1 to iterations).foreach { i => @@ -1738,7 +1743,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // When a client provides an X-Request-ID header, the bridge should use it // as the Correlation-Id in the response, enabling end-to-end request tracing. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val requestId = java.util.UUID.randomUUID().toString @@ -1775,7 +1780,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Error responses must also include Correlation-Id for debugging and // log correlation. This is critical for troubleshooting failed requests. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => // Generate paths that will produce various error responses @@ -1817,7 +1822,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Correlation-Id must be consistently present across all API versions, // ensuring uniform logging behavior regardless of which version is called. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS val allVersionEndpoints = allStandardVersions.map(v => s"/obp/$v/banks") @@ -1854,7 +1859,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // This validates that the bridge's session/correlation mechanism is thread-safe. import scala.concurrent.Future - val iterations = 10 + val iterations = CI_ITERATIONS val batchSize = 10 val allCorrelationIds = java.util.concurrent.ConcurrentHashMap.newKeySet[String]() var totalRequests = 0 @@ -1903,7 +1908,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { } var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS val auditableEndpoints = List( "/obp/v5.0.0/banks", @@ -1954,7 +1959,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Verify that Props-dependent endpoints return valid responses through the bridge, // proving that the same Props configuration is loaded and accessible. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS // These endpoints depend on Props configuration being loaded correctly: // /banks reads from DB (configured via Props), /root reads API info (Props-dependent) @@ -1995,7 +2000,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Verify that database-dependent endpoints work through the bridge, // proving that CustomDBVendor/HikariCP pool is shared and functional. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS // /banks endpoint reads from the database - if DB connection is broken, it fails val dbDependentEndpoints = List( @@ -2038,7 +2043,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Verify that HTTP4S-specific properties (port, host, continuation timeout) // are read from the same Props system and have correct defaults. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => // Verify the test server is running on the configured port @@ -2086,7 +2091,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Verify that endpoints using external service patterns work through the bridge. // ATM/branch endpoints use connector patterns configured via Props. var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS // Endpoints that exercise different integration patterns val integrationEndpoints = List( @@ -2131,7 +2136,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { } var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS val authEndpoints = List( "/obp/v5.0.0/banks", @@ -2170,7 +2175,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // Use small batches (3) with pauses to avoid exhausting the test H2 pool. import scala.concurrent.Future - val iterations = 10 + val iterations = CI_ITERATIONS val batchSize = 3 var successCount = 0 @@ -2275,7 +2280,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 1.2, 1.3** // v5.0.0 system-views is a native HTTP4S endpoint - should be served directly var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => // Native v5.0.0 endpoint: GET /obp/v5.0.0/system-views/{VIEW_ID} @@ -2305,7 +2310,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 1.2, 1.3** // v3.0.0 endpoints have no native HTTP4S implementation - must go through bridge var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS val legacyVersions = List("v1.2.1", "v1.3.0", "v1.4.0", "v2.0.0", "v2.1.0", "v2.2.0", "v3.0.0", "v3.1.0", "v4.0.0") @@ -2336,7 +2341,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 1.2, 1.3** // Endpoints that don't exist in native HTTP4S or Lift should return 404 var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => val version = apiVersions(Random.nextInt(apiVersions.length)) @@ -2364,7 +2369,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { scenario("Property 14.4: Routing priority is deterministic - same request always same result (10 iterations)", PriorityRoutingTag) { // **Validates: Requirements 1.2, 1.3** var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS // Mix of native and bridge endpoints val testPaths = List( @@ -2410,7 +2415,7 @@ class Http4sLiftBridgePropertyTest extends V500ServerSetup { // **Validates: Requirements 1.2, 1.3** // v7.0.0 has native HTTP4S endpoints (Http4s700.scala) var successCount = 0 - val iterations = 10 + val iterations = CI_ITERATIONS (1 to iterations).foreach { i => // v7.0.0 /banks is a native HTTP4S endpoint diff --git a/obp-api/src/test/scala/code/api/util/JavaWebSignatureTest.scala b/obp-api/src/test/scala/code/api/util/JavaWebSignatureTest.scala index f629673469..2444d640dd 100644 --- a/obp-api/src/test/scala/code/api/util/JavaWebSignatureTest.scala +++ b/obp-api/src/test/scala/code/api/util/JavaWebSignatureTest.scala @@ -135,13 +135,15 @@ class JavaWebSignatureTest extends V400ServerSetup { scenario("We try to make ur call - unsuccessful", ApiEndpoint1) { When("We make the request") val requestGet = (v4_0_0_Request / "development" / "echo" / "jws-verified-request-jws-signed-response").GET <@ (user1) + // Sign with a timestamp 65 seconds in the past — always outside the 60s validity window, + // no sleep needed, and independent of the jws.signing_time_validity_seconds prop value. val signHeaders = signRequest( Full(""), "get", "/obp/v4.0.0/development/echo/jws-verified-request-jws-signed-response", - "application/json;charset=UTF-8" + "application/json;charset=UTF-8", + signingTime = Some(ZonedDateTime.now(ZoneOffset.UTC).minusSeconds(65)) ).map(i => (i.name, i.values.mkString(","))) - Thread.sleep(60 seconds) val responseGet = makeGetRequest(requestGet, signHeaders) Then("We should get a 401") responseGet.code should equal(401) diff --git a/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala b/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala new file mode 100644 index 0000000000..9a92aa51d4 --- /dev/null +++ b/obp-api/src/test/scala/code/api/util/http4s/RequestScopeConnectionTest.scala @@ -0,0 +1,261 @@ +package code.api.util.http4s + +import cats.effect.unsafe.IORuntime +import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.db.ConnectionManager +import net.liftweb.util.{ConnectionIdentifier, DefaultConnectionIdentifier} +import org.scalatest.{BeforeAndAfter, FeatureSpec, GivenWhenThen, Matchers} + +import java.lang.reflect.{InvocationHandler, Method, Proxy => JProxy} +import java.sql.Connection +import scala.concurrent.{ExecutionContext, Future} + +/** + * Unit tests for the request-scoped transaction infrastructure: + * - RequestScopeConnection.makeProxy — lifecycle methods are no-ops + * - RequestAwareConnectionManager — proxy vs. delegate selection + * - RequestScopeConnection.fromFuture — TTL propagation to Future workers + * + * All tests use JDK dynamic proxy to build trackable mock Connections; no + * mocking framework is needed. The `after` block resets the global TTL so + * that tests do not bleed state into each other. + */ +class RequestScopeConnectionTest extends FeatureSpec with Matchers with GivenWhenThen with BeforeAndAfter { + + // Use the OBP EC so TtlRunnable wraps every Future submission — required for + // the TTL propagation scenarios. + implicit val ec: ExecutionContext = com.openbankproject.commons.ExecutionContext.Implicits.global + implicit val runtime: IORuntime = IORuntime.global + + after { + // Reset global TTL state so tests are independent. + RequestScopeConnection.currentProxy.set(null) + } + + // ─── helpers ───────────────────────────────────────────────────────────────── + + /** Mutable counters written by the tracking Connection handler. */ + class ConnectionTracker { + @volatile var commitCount = 0 + @volatile var rollbackCount = 0 + @volatile var closeCount = 0 + @volatile var autoCommitArg: Option[Boolean] = None + } + + /** Create a JDK-proxy Connection that records lifecycle calls. */ + private def trackingConn(t: ConnectionTracker): Connection = + JProxy.newProxyInstance( + classOf[Connection].getClassLoader, + Array(classOf[Connection]), + new InvocationHandler { + def invoke(proxy: Any, method: Method, args: Array[AnyRef]): AnyRef = + method.getName match { + case "commit" => t.commitCount += 1; null + case "rollback" => t.rollbackCount += 1; null + case "close" => t.closeCount += 1; null + case "setAutoCommit" => + if (args != null && args.nonEmpty) + t.autoCommitArg = Some(args(0).asInstanceOf[Boolean]) + null + case "getAutoCommit" => Boolean.box(t.autoCommitArg.getOrElse(true)) + case "isClosed" => Boolean.box(false) + case _ => null + } + } + ).asInstanceOf[Connection] + + private def simpleManager(conn: Connection): ConnectionManager = + new ConnectionManager { + def newConnection(name: ConnectionIdentifier): Box[Connection] = Full(conn) + def releaseConnection(c: Connection): Unit = () + } + + // ─── makeProxy ─────────────────────────────────────────────────────────────── + + feature("RequestScopeConnection.makeProxy — lifecycle methods are no-ops") { + + scenario("commit on the proxy does not reach the real connection") { + Given("A tracked real connection wrapped in a proxy") + val t = new ConnectionTracker + val proxy = RequestScopeConnection.makeProxy(trackingConn(t)) + + When("commit is called on the proxy") + proxy.commit() + + Then("The real connection's commit counter remains zero") + t.commitCount shouldBe 0 + } + + scenario("rollback on the proxy does not reach the real connection") { + Given("A tracked real connection wrapped in a proxy") + val t = new ConnectionTracker + val proxy = RequestScopeConnection.makeProxy(trackingConn(t)) + + When("rollback is called on the proxy") + proxy.rollback() + + Then("The real connection's rollback counter remains zero") + t.rollbackCount shouldBe 0 + } + + scenario("close on the proxy does not reach the real connection") { + Given("A tracked real connection wrapped in a proxy") + val t = new ConnectionTracker + val proxy = RequestScopeConnection.makeProxy(trackingConn(t)) + + When("close is called on the proxy") + proxy.close() + + Then("The real connection's close counter remains zero") + t.closeCount shouldBe 0 + } + + scenario("non-lifecycle methods are forwarded to the real connection") { + Given("A tracked real connection wrapped in a proxy") + val t = new ConnectionTracker + val proxy = RequestScopeConnection.makeProxy(trackingConn(t)) + + When("setAutoCommit(false) is called on the proxy") + proxy.setAutoCommit(false) + + Then("The real connection receives the call with the correct argument") + t.autoCommitArg shouldBe Some(false) + } + } + + // ─── RequestAwareConnectionManager.newConnection ───────────────────────────── + + feature("RequestAwareConnectionManager.newConnection — proxy vs. delegate selection") { + + scenario("Returns the request proxy when currentProxy TTL is populated") { + Given("A proxy stored in the TTL") + val proxy = RequestScopeConnection.makeProxy(trackingConn(new ConnectionTracker)) + RequestScopeConnection.currentProxy.set(proxy) + + And("A delegate that would return a different connection") + val mgr = new RequestAwareConnectionManager(simpleManager(trackingConn(new ConnectionTracker))) + + When("newConnection is called") + val result = mgr.newConnection(DefaultConnectionIdentifier) + + Then("The proxy is returned — the delegate is bypassed") + result shouldBe Full(proxy) + } + + scenario("Falls through to the delegate when TTL holds null") { + Given("No proxy in the TTL") + RequestScopeConnection.currentProxy.set(null) + + And("A delegate that returns a known connection") + val delegateConn = trackingConn(new ConnectionTracker) + val mgr = new RequestAwareConnectionManager(simpleManager(delegateConn)) + + When("newConnection is called") + val result = mgr.newConnection(DefaultConnectionIdentifier) + + Then("The delegate's connection is returned") + result shouldBe Full(delegateConn) + } + } + + // ─── RequestAwareConnectionManager.releaseConnection ───────────────────────── + + feature("RequestAwareConnectionManager.releaseConnection — proxy is never released") { + + scenario("Releasing the proxy is a no-op — the delegate is not called") { + Given("A proxy set in the TTL") + val proxy = RequestScopeConnection.makeProxy(trackingConn(new ConnectionTracker)) + RequestScopeConnection.currentProxy.set(proxy) + + var delegateReleased = false + val delegate = new ConnectionManager { + def newConnection(name: ConnectionIdentifier): Box[Connection] = Empty + def releaseConnection(conn: Connection): Unit = delegateReleased = true + } + val mgr = new RequestAwareConnectionManager(delegate) + + When("releaseConnection is called with the proxy instance") + mgr.releaseConnection(proxy) + + Then("The delegate's releaseConnection is never invoked") + delegateReleased shouldBe false + } + + scenario("Releasing a non-proxy connection delegates normally") { + Given("No proxy in the TTL (null)") + RequestScopeConnection.currentProxy.set(null) + + var releasedConn: Connection = null + val realConn = trackingConn(new ConnectionTracker) + val delegate = new ConnectionManager { + def newConnection(name: ConnectionIdentifier): Box[Connection] = Empty + def releaseConnection(conn: Connection): Unit = releasedConn = conn + } + val mgr = new RequestAwareConnectionManager(delegate) + + When("releaseConnection is called with a non-proxy connection") + mgr.releaseConnection(realConn) + + Then("The delegate receives the exact same connection instance") + releasedConn should be theSameInstanceAs realConn + } + } + + // ─── RequestScopeConnection.fromFuture ─────────────────────────────────────── + + feature("RequestScopeConnection.fromFuture — TTL propagation to Future workers") { + + scenario("Future observes the proxy via TTL when requestProxyLocal is populated") { + Given("A proxy stored in requestProxyLocal") + val proxy = RequestScopeConnection.makeProxy(trackingConn(new ConnectionTracker)) + + val program = for { + _ <- RequestScopeConnection.requestProxyLocal.set(Some(proxy)) + seen <- RequestScopeConnection.fromFuture { + // TtlRunnable (OBP EC) captures currentProxy from the submitting + // compute thread and restores it on the worker thread. + Future { RequestScopeConnection.currentProxy.get() } + } + _ <- RequestScopeConnection.requestProxyLocal.set(None) // cleanup + } yield seen + + When("fromFuture is executed inside an IO fiber") + val seen = program.unsafeRunSync() + + Then("The Future observed the proxy through the TransmittableThreadLocal") + seen should be theSameInstanceAs proxy + } + + scenario("Future observes null TTL when requestProxyLocal holds None") { + Given("requestProxyLocal is None (no active request scope)") + val program = for { + _ <- RequestScopeConnection.requestProxyLocal.set(None) + seen <- RequestScopeConnection.fromFuture { + Future { RequestScopeConnection.currentProxy.get() } + } + } yield seen + + When("fromFuture is executed with no proxy active") + val seen = program.unsafeRunSync() + + Then("The Future sees null in the TTL — no proxy was propagated") + seen shouldBe null + } + + scenario("fromFuture returns the value produced by the Future") { + Given("A simple Future that returns a known value") + val program = for { + _ <- RequestScopeConnection.requestProxyLocal.set(None) + result <- RequestScopeConnection.fromFuture { + Future.successful(42) + } + } yield result + + When("fromFuture wraps and awaits the Future") + val result = program.unsafeRunSync() + + Then("The returned value matches the Future's result") + result shouldBe 42 + } + } +} 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 1f6cb1a56d..ef1506a5ed 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 @@ -4,7 +4,7 @@ import code.Http4sTestServer import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCardsForBank, canGetCustomersAtOneBank, canReadResourceDoc} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canDeleteEntitlementAtAnyBank, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canReadResourceDoc} import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, UserHasMissingRoles, UserNotFoundByUserId} import code.customer.CustomerX import code.entitlement.Entitlement @@ -584,22 +584,44 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - scenario("Reject request for non-v7.0.0 API version", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v6.0.0/obp — wrong version in path") + scenario("Serve v6.0.0 resource docs when v6.0.0 requested via v7 endpoint", Http4s700RoutesTag) { + // Previously returned 400 — fixed by delegating to ImplementationsResourceDocs.getResourceDocsList + Given("GET /obp/v7.0.0/resource-docs/v6.0.0/obp?functions=getBanks — filtered to avoid timeout") setPropsValues("resource_docs_requires_role" -> "false") When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v6.0.0/obp") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v6.0.0/obp?functions=getBanks") - Then("Response is 400 with InvalidApiVersionString message") + Then("Response is 200 OK with resource_docs array") + statusCode shouldBe 200 + json match { + case JObject(fields) => + toFieldMap(fields).get("resource_docs") match { + case Some(JArray(_)) => succeed + case _ => + fail("Expected resource_docs field to be an array") + } + case _ => + fail("Expected JSON object") + } + } + + scenario("Return 400 for an unrecognised API version string", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/resource-docs/not-a-version/obp") + setPropsValues("resource_docs_requires_role" -> "false") + + When("Making HTTP request to server") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/not-a-version/obp") + + Then("Response is 400 with error message containing the bad version string") statusCode shouldBe 400 json match { case JObject(fields) => toFieldMap(fields).get("message") match { case Some(JString(message)) => - message should include("v6.0.0") + message should include("not-a-version") case _ => - fail("Expected message field describing the version error") + fail("Expected message field") } case _ => fail("Expected JSON object") @@ -1716,4 +1738,346 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } } + + // ─── getCacheConfig ────────────────────────────────────────────────────────── + + feature("Http4s700 getCacheConfig endpoint") { + + scenario("Reject unauthenticated access to /system/cache/config", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/cache/config with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/config") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetCacheConfig role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/cache/config with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/config", headers) + + Then("Response is 403 with UserHasMissingRoles") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canGetCacheConfig.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return cache config when authenticated with canGetCacheConfig role", Http4s700RoutesTag) { + Given("canGetCacheConfig role granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canGetCacheConfig.toString) + + When("GET /obp/v7.0.0/system/cache/config with DirectLogin header") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/config", headers) + + Then("Response is 200 with redis_status, in_memory_status, instance_id fields") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.keys should contain("redis_status") + m.keys should contain("in_memory_status") + m.keys should contain("instance_id") + case _ => fail("Expected JSON object for getCacheConfig") + } + } + } + + // ─── getCacheInfo ──────────────────────────────────────────────────────────── + + feature("Http4s700 getCacheInfo endpoint") { + + scenario("Reject unauthenticated access to /system/cache/info", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/cache/info with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/info") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetCacheInfo role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/cache/info with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/info", headers) + + Then("Response is 403 with UserHasMissingRoles") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canGetCacheInfo.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return cache info when authenticated with canGetCacheInfo role", Http4s700RoutesTag) { + Given("canGetCacheInfo role granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canGetCacheInfo.toString) + + When("GET /obp/v7.0.0/system/cache/info with DirectLogin header") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/info", headers) + + Then("Response is 200 with namespaces, total_keys, redis_available fields") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.keys should contain("namespaces") + m.keys should contain("total_keys") + m.keys should contain("redis_available") + case _ => fail("Expected JSON object for getCacheInfo") + } + } + } + + // ─── getDatabasePoolInfo ───────────────────────────────────────────────────── + + feature("Http4s700 getDatabasePoolInfo endpoint") { + + scenario("Reject unauthenticated access to /system/database/pool", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/database/pool with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/database/pool") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetDatabasePoolInfo role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/database/pool with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/database/pool", headers) + + Then("Response is 403 with UserHasMissingRoles") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canGetDatabasePoolInfo.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return pool info when authenticated with canGetDatabasePoolInfo role", Http4s700RoutesTag) { + Given("canGetDatabasePoolInfo role granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canGetDatabasePoolInfo.toString) + + When("GET /obp/v7.0.0/system/database/pool with DirectLogin header") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/database/pool", headers) + + Then("Response is 200 with pool_name, active_connections, maximum_pool_size fields") + statusCode shouldBe 200 + json match { + case JObject(fields) => + val m = toFieldMap(fields) + m.keys should contain("pool_name") + m.keys should contain("active_connections") + m.keys should contain("maximum_pool_size") + case _ => fail("Expected JSON object for getDatabasePoolInfo") + } + } + } + + // ─── getStoredProcedureConnectorHealth ─────────────────────────────────────── + + feature("Http4s700 getStoredProcedureConnectorHealth endpoint") { + + scenario("Reject unauthenticated access to stored_procedure_vDec2019/health", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetConnectorHealth role", Http4s700RoutesTag) { + Given("GET stored_procedure_vDec2019/health with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/connectors/stored_procedure_vDec2019/health", headers) + + Then("Response is 403 with UserHasMissingRoles") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canGetConnectorHealth.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + // Note: no 200 scenario — StoredProcedureUtils init block requires stored_procedure_connector.* + // props that are not set in the test environment. The route is correctly wired (auth passes), + // but the Future would fail when StoredProcedureUtils is first accessed, returning 500. + } + + // ─── getMigrations ─────────────────────────────────────────────────────────── + + feature("Http4s700 getMigrations endpoint") { + + scenario("Reject unauthenticated access to /system/migrations", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/migrations with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/migrations") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetMigrations role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/migrations with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/migrations", headers) + + Then("Response is 403 with UserHasMissingRoles") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canGetMigrations.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return migrations list when authenticated with canGetMigrations role", Http4s700RoutesTag) { + Given("canGetMigrations role granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canGetMigrations.toString) + + When("GET /obp/v7.0.0/system/migrations with DirectLogin header") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/migrations", headers) + + Then("Response is 200 with migration_script_logs field") + statusCode shouldBe 200 + json match { + case JObject(fields) => + toFieldMap(fields).keys should contain("migration_script_logs") + case _ => fail("Expected JSON object for getMigrations") + } + } + } + + // ─── getCacheNamespaces ────────────────────────────────────────────────────── + + feature("Http4s700 getCacheNamespaces endpoint") { + + scenario("Reject unauthenticated access to /system/cache/namespaces", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/cache/namespaces with no auth headers") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/namespaces") + + Then("Response is 401 with AuthenticatedUserIsRequired message") + statusCode shouldBe 401 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(AuthenticatedUserIsRequired) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 403 when authenticated but missing canGetCacheNamespaces role", Http4s700RoutesTag) { + Given("GET /obp/v7.0.0/system/cache/namespaces with DirectLogin header but no role") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/namespaces", headers) + + Then("Response is 403 with UserHasMissingRoles") + statusCode shouldBe 403 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => + msg should include(UserHasMissingRoles) + msg should include(canGetCacheNamespaces.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return cache namespaces when authenticated with canGetCacheNamespaces role", Http4s700RoutesTag) { + Given("canGetCacheNamespaces role granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canGetCacheNamespaces.toString) + + When("GET /obp/v7.0.0/system/cache/namespaces with DirectLogin header") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/system/cache/namespaces", headers) + + Then("Response is 200 with namespaces array") + statusCode shouldBe 200 + json match { + case JObject(fields) => + toFieldMap(fields).get("namespaces") match { + case Some(JArray(_)) => succeed + case _ => fail("Expected namespaces array") + } + case _ => fail("Expected JSON object for getCacheNamespaces") + } + } + } + } diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700TransactionTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700TransactionTest.scala new file mode 100644 index 0000000000..54ef857c92 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700TransactionTest.scala @@ -0,0 +1,251 @@ +package code.api.v7_0_0 + +import code.Http4sTestServer +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canDeleteEntitlementAtAnyBank} +import code.entitlement.Entitlement +import code.setup.ServerSetupWithTestData +import dispatch.Defaults._ +import dispatch._ +import net.liftweb.json.JsonAST.{JObject, JString} +import net.liftweb.json.JsonParser.parse +import net.liftweb.json.JValue +import org.scalatest.Tag + +import scala.collection.JavaConverters._ +import scala.concurrent.Await +import scala.concurrent.duration._ + +/** + * Integration tests for the v7 request-scoped transaction feature. + * + * Each HTTP request handled by the http4s stack runs inside + * `ResourceDocMiddleware.withRequestTransaction`, which: + * - Borrows one real JDBC connection from HikariCP + * - Wraps it in a non-closing proxy so Lift Mapper cannot commit early + * - Commits on Outcome.Succeeded (HTTP 2xx or error response) + * - Rolls back on Outcome.Errored / Outcome.Canceled (uncaught exception) + * + * These tests exercise the observable guarantee: data written inside a + * successful request is durably committed; the connection is returned to the + * pool so subsequent requests can proceed. + * + * Commit-on-success is the primary path tested here. Rollback is only + * triggered by an uncaught IO exception (not by a 4xx business-logic + * response), so it is verified indirectly: a 4xx response that reaches the + * client means the IO succeeded, the connection was committed and released, + * and the pool is still healthy. + */ +class Http4s700TransactionTest extends ServerSetupWithTestData { + + object Http4s700TransactionTag extends Tag("Http4s700Transaction") + + private val http4sServer = Http4sTestServer + private val baseUrl = s"http://${http4sServer.host}:${http4sServer.port}" + + // ─── HTTP helpers (copied from Http4s700RoutesTest) ─────────────────────── + + private def makeHttpRequest( + path: String, + headers: Map[String, String] = Map.empty + ): (Int, JValue, Map[String, String]) = { + val request = url(s"$baseUrl$path") + val withHdr = headers.foldLeft(request) { case (r, (k, v)) => r.addHeader(k, v) } + val response = Http.default( + withHdr.setHeader("Accept", "*/*") > as.Response(p => + (p.getStatusCode, p.getResponseBody, + p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) + ) + ) + val (status, body, hdrs) = Await.result(response, 10.seconds) + val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) + (status, json, hdrs) + } + + private def makeHttpRequestWithBody( + method: String, + path: String, + body: String, + headers: Map[String, String] = Map.empty + ): (Int, JValue, Map[String, String]) = { + val base = url(s"$baseUrl$path") + val withHdr = (headers + ("Content-Type" -> "application/json")).foldLeft(base) { + case (r, (k, v)) => r.addHeader(k, v) + } + val methodReq = method.toUpperCase match { + case "POST" => withHdr.POST << body + case "PUT" => withHdr.PUT << body + case _ => withHdr << body + } + val response = Http.default( + methodReq.setHeader("Accept", "*/*") > as.Response(p => + (p.getStatusCode, p.getResponseBody, + p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) + ) + ) + val (status, responseBody, hdrs) = Await.result(response, 10.seconds) + val json = if (responseBody.trim.isEmpty) JObject(Nil) else parse(responseBody) + (status, json, hdrs) + } + + private def makeHttpRequestWithMethod( + method: String, + path: String, + headers: Map[String, String] = Map.empty + ): (Int, JValue, Map[String, String]) = { + val base = url(s"$baseUrl$path") + val withHdr = headers.foldLeft(base) { case (r, (k, v)) => r.addHeader(k, v) } + val methodReq = method.toUpperCase match { + case "DELETE" => withHdr.DELETE + case "POST" => withHdr.POST + case _ => withHdr + } + val response = Http.default( + methodReq.setHeader("Accept", "*/*") > as.Response(p => + (p.getStatusCode, p.getResponseBody, + p.getHeaders.iterator().asScala.map(e => e.getKey -> e.getValue).toMap) + ) + ) + val (status, body, hdrs) = Await.result(response, 10.seconds) + val json = if (body.trim.isEmpty) JObject(Nil) else parse(body) + (status, json, hdrs) + } + + private def entitlementIdFromJson(json: JValue): String = + json match { + case JObject(fields) => + fields.collectFirst { case f if f.name == "entitlement_id" => + f.value.asInstanceOf[JString].s + }.getOrElse(fail("Expected entitlement_id in response")) + case _ => fail("Expected JSON object in response") + } + + // ─── Commit on successful write ─────────────────────────────────────────── + + feature("v7 transaction — commit on successful write") { + + scenario("POST addEntitlement → 201: created row is durable in the DB", Http4s700TransactionTag) { + Given("canCreateEntitlementAtAnyBank granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canCreateEntitlementAtAnyBank.toString) + + When("POST /obp/v7.0.0/users/USER_ID/entitlements returns 201") + val roleName = "CanGetAnyUser" + val body = s"""{"bank_id":"","role_name":"$roleName"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (status, json, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/users/${resourceUser1.userId}/entitlements", body, headers) + + status shouldBe 201 + + Then("The entitlement_id from the response is readable directly from the DB") + val entitlementId = entitlementIdFromJson(json) + val fromDb = Entitlement.entitlement.vend.getEntitlementById(entitlementId) + fromDb.isDefined shouldBe true + + And("The stored row has the expected role and user") + fromDb.foreach { e => + e.roleName shouldBe roleName + e.userId shouldBe resourceUser1.userId + } + } + + scenario("POST addEntitlement: a second request after the first can read committed data", Http4s700TransactionTag) { + Given("canCreateEntitlementAtAnyBank and canDeleteEntitlementAtAnyBank granted") + addEntitlement("", resourceUser1.userId, canCreateEntitlementAtAnyBank.toString) + addEntitlement("", resourceUser1.userId, canDeleteEntitlementAtAnyBank.toString) + + When("Request 1 — POST creates a CanGetCardsForBank entitlement") + val body = s"""{"bank_id":"${testBankId1.value}","role_name":"CanGetCardsForBank"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (status1, json1, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/users/${resourceUser1.userId}/entitlements", body, headers) + status1 shouldBe 201 + val createdId = entitlementIdFromJson(json1) + + And("Request 2 — DELETE removes the entitlement just created") + val (status2, _, _) = makeHttpRequestWithMethod( + "DELETE", s"/obp/v7.0.0/entitlements/$createdId", headers) + + Then("The DELETE sees the row committed by the POST (returns 204, not 404)") + status2 shouldBe 204 + } + } + + // ─── Commit on successful delete ───────────────────────────────────────── + + feature("v7 transaction — commit on successful delete") { + + scenario("DELETE deleteEntitlement → 204: row is gone from the DB", Http4s700TransactionTag) { + Given("canDeleteEntitlementAtAnyBank granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canDeleteEntitlementAtAnyBank.toString) + + And("A target entitlement created directly in the DB") + val target = Entitlement.entitlement.vend + .addEntitlement(testBankId1.value, resourceUser1.userId, "CanGetCardsForBank") + .openOrThrowException("Expected entitlement to be created for DELETE test") + + When(s"DELETE /obp/v7.0.0/entitlements/${target.entitlementId} returns 204") + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (status, _, _) = makeHttpRequestWithMethod( + "DELETE", s"/obp/v7.0.0/entitlements/${target.entitlementId}", headers) + + status shouldBe 204 + + Then("The row is no longer readable from the DB — the DELETE was committed") + val afterDelete = Entitlement.entitlement.vend.getEntitlementById(target.entitlementId) + afterDelete.isDefined shouldBe false + } + } + + // ─── Connection pool health ─────────────────────────────────────────────── + + feature("v7 transaction — connection pool health across multiple requests") { + + scenario("Ten sequential requests all succeed — connections are returned to the pool", Http4s700TransactionTag) { + Given("canCreateEntitlementAtAnyBank granted to resourceUser1") + addEntitlement("", resourceUser1.userId, canCreateEntitlementAtAnyBank.toString) + addEntitlement("", resourceUser1.userId, canDeleteEntitlementAtAnyBank.toString) + + val headers = Map("DirectLogin" -> s"token=${token1.value}") + + When("10 sequential POST + DELETE pairs are executed") + val uniqueRole = "CanGetAnyUser" + var allStatuses = List.empty[Int] + + (1 to 10).foreach { _ => + val body = s"""{"bank_id":"","role_name":"$uniqueRole"}""" + val (postStatus, postJson, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/users/${resourceUser1.userId}/entitlements", body, headers) + allStatuses :+= postStatus + + if (postStatus == 201) { + val eid = entitlementIdFromJson(postJson) + val (delStatus, _, _) = makeHttpRequestWithMethod( + "DELETE", s"/obp/v7.0.0/entitlements/$eid", headers) + allStatuses :+= delStatus + } + } + + Then("All POST responses are 201 and all DELETE responses are 204") + // Filter to only the statuses we actually got (no skipped deletes) + val postStatuses = allStatuses.zipWithIndex.collect { case (s, i) if i % 2 == 0 => s } + val deleteStatuses = allStatuses.zipWithIndex.collect { case (s, i) if i % 2 == 1 => s } + postStatuses.forall(_ == 201) shouldBe true + deleteStatuses.forall(_ == 204) shouldBe true + } + + scenario("A 4xx error response does not exhaust the connection pool", Http4s700TransactionTag) { + Given("An unauthenticated POST request that will return 401") + // No auth header — 401 is guaranteed regardless of any prior role grants in this suite. + val body = s"""{"bank_id":"","role_name":"CanGetAnyUser"}""" + val (unauthStatus, _, _) = makeHttpRequestWithBody( + "POST", s"/obp/v7.0.0/users/${resourceUser1.userId}/entitlements", body) + + When("The unauthenticated request returns 401") + unauthStatus shouldBe 401 + + Then("A subsequent public request still works — the pool was not leaked by the 401 path") + val (banksStatus, _, _) = makeHttpRequest("/obp/v7.0.0/banks") + banksStatus shouldBe 200 + } + } +}