diff --git a/.env b/.env index 4e185906..cb7bbccd 100644 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ SERVER_PORT=4550 BASE_IMAGE=openjdk:21-jdk-slim -JAR_FILENAME=service-hmcts-marketplace-springboot-template-*.jar +JAR_FILENAME=cp-case-document-knowledge-service-*.jar JAR_FILE_PATH=build/libs diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 374575b5..00000000 --- a/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -# ---- Base image (default fallback) ---- -ARG BASE_IMAGE -FROM ${BASE_IMAGE:-openjdk:21-jdk-slim} - -# ---- Runtime arguments ---- -ARG JAR_FILENAME -ARG JAR_FILE_PATH - -ENV JAR_FILENAME=${JAR_FILENAME:-app.jar} -ENV JAR_FILE_PATH=${JAR_FILE_PATH:-build/libs} -ENV JAR_FULL_PATH=$JAR_FILE_PATH/$JAR_FILENAME - -# ---- Set runtime ENV for Spring Boot to bind port -ARG SERVER_PORT -ENV SERVER_PORT=${SERVER_PORT:-4550} - -# ---- Dependencies ---- -RUN apt-get update \ - && apt-get install -y curl \ - && rm -rf /var/lib/apt/lists/* - -# ---- Application files ---- -COPY $JAR_FULL_PATH /opt/app/app.jar -COPY lib/applicationinsights.json /opt/app/ - -# ---- Permissions ---- -RUN chmod 755 /opt/app/app.jar - -# ---- Runtime ---- -EXPOSE 4550 - -# Documented runtime configuration -# JWT secret for token verification (Base64-encoded HS256 key) -ENV JWT_SECRET_KEY="it-must-be-a-string-secret-at-least-256-bits-long" - -CMD ["java", "-jar", "/opt/app/app.jar"] diff --git a/README.md b/README.md index 4b0df3e7..957568ad 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,240 @@ -# HMCTS Service Spring Boot Template +# case-document-knowledge-service -This repository provides a template for building Spring Boot applications. While the initial use case was for the HMCTS API Marketplace, the template is designed to be reusable across jurisdictions and is intended as a base paved path for wider adoption. +**AI-powered answers for case documents — every response cited and auditable.** +Spring Boot 4 (Java 21) REST API with OpenAPI docs, PostgreSQL + Flyway, production-ready observability, and CI-friendly **Gradle** build. -It includes essential configurations, dependencies, and recommended practices to help teams get started quickly. +--- -**Note:** This template is not a framework, nor is it intended to evolve into one. It simply leverages the Spring ecosystem and proven libraries from the wider engineering community. +## Table of contents -As HMCTS services are hosted on Azure, the included dependencies reflect this. Our aim is to stay as close to the cloud as possible in order to maximise alignment with the Shared Responsibility Model and achieve optimal security and operability. +- [Features](#features) +- [Tech stack](#tech-stack) +- [Project layout](#project-layout) +- [Prerequisites](#prerequisites) +- [Build & test (Gradle)](#build--test-gradle) +- [Run locally (Gradle)](#run-locally-gradle) +- [Run with Docker Compose](#run-with-docker-compose) +- [Integration tests with Docker Compose](#integration-tests-with-docker-compose) +- [Configuration](#configuration) +- [API & docs](#api--docs) +- [Observability](#observability) +- [SBOM / dependency insights](#sbom--dependency-insights) +- [Troubleshooting](#troubleshooting) +- [License](#license) -## Want to Build Your Own Path? +--- -That’s absolutely fine — but if you do, make sure your approach meets the following baseline requirements: +## Features -* Security – All services must meet HMCTS security standards, including vulnerability scanning and least privilege access. -* Observability – Logs, metrics, and traces must be integrated into HMCTS observability stack (e.g. Azure Monitoring). -* Audit – Systems must produce audit trails that meet legal and operational requirements. -* CI/CD Integration – Pipelines must include automated testing, deployments to multiple environments, and use approved tooling (e.g. GitHub Actions or Azure DevOps). -* Compliance & Policy Alignment – Services must align with HMCTS/MoJ policies (e.g. Coding in the Open, mandatory security practices). -* Ownership & Support – Domain teams must clearly own the service, maintain a support model, and define escalation paths. +- Versioned **REST API** under `/api/v1/**` +- **OpenAPI / Swagger UI** +- **PostgreSQL** persistence with **Flyway** migrations +- **Observability**: Actuator health, Prometheus metrics, OTLP tracing, structured JSON logs +- **Quality gates**: PMD, JaCoCo coverage +- **Gradle 9** build with Docker-Compose-backed **integration tests** -## Installation +> Note: HTTP security/authorization is intentionally minimal by default. Integrate with your gateway or add your own authorization as needed. -To get started with this project, you'll need Java and Gradle installed. +--- -### Prerequisites +## Tech stack -- ☕️ **Java 21 or later**: Ensure Java is installed and available on your `PATH`. -- ⚙️ **Gradle**: You can install Gradle using your preferred method: +- **Java 21**, **Spring Boot 4.0.0-M2** +- Spring MVC, Spring Data JPA, **PostgreSQL 16**, Flyway +- springdoc-openapi 3.x (Swagger UI) +- Micrometer + **Prometheus**, OpenTelemetry OTLP exporter +- Gradle 9, PMD, JaCoCo +- Docker / Docker Compose v2 - **macOS (Recommended with Homebrew):** - ```bash - brew install gradle - ``` +--- - **Other Platforms:** - Visit the [Gradle installation guide](https://gradle.org/install/) for platform-specific instructions. +## Project layout + +``` +. +├─ src/main/java/... # application code +├─ src/main/resources/ +│ ├─ application.yml # prod-ready defaults +│ └─ db/migration/ # Flyway migrations +├─ src/test/java/... # unit tests +├─ src/integrationTest/java/... # integration tests (Gradle sourceSet) +├─ docker/ +│ ├─ Dockerfile +│ └─ docker-compose.integration.yml +└─ build.gradle # Gradle build +``` + +--- + +## Prerequisites + +- Java **21** +- Docker Engine **v27+** and **Docker Compose v2** (`docker compose` CLI) +- Nothing else required — use the Gradle wrapper (`./gradlew`) + +--- + +## Build & test (Gradle) + +```bash +# Clean & full build (unit tests) +./gradlew clean build + +# Faster local build (skip tests) +./gradlew -x test clean build + +# Run only unit tests +./gradlew test + +# Run only integration tests +./gradlew integration +# Code quality reports +./gradlew pmdMain pmdTest jacocoTestReport +# Open reports +open build/reports/pmd/main.html +open build/reports/tests/test/index.html +open build/reports/jacoco/test/html/index.html + +# Dependency insight (useful for conflicts) +./gradlew dependencyInsight --dependency +``` + +--- + +## Run locally (Gradle) + +By default the app starts on **:8082** and looks for Postgres at **localhost:55432** (see [Configuration](#configuration)). + +```bash +# Start with your local Java 21 +./gradlew bootRun +``` + +--- + +## Run with Docker Compose + +This brings up **PostgreSQL 16** (exposed as `localhost:55432`) and the **app** (exposed as `localhost:8082`). +It uses `docker/docker-compose.integration.yml`. -You can verify installation with: ```bash -java -version -gradle -v +# Build image & start stack +docker compose -f docker/docker-compose.integration.yml up -d --build + +# Tail logs +docker compose -f docker/docker-compose.integration.yml logs -f app + +# Stop & remove (keep volumes) +docker compose -f docker/docker-compose.integration.yml down + +# Remove everything inc. volumes (⚠️ deletes DB data) +docker compose -f docker/docker-compose.integration.yml down -v +``` + +--- + +## Integration tests with Docker Compose + +Integration tests automatically **bring up** Postgres + app, **wait** until they’re healthy, run tests, then **tear down** the stack. + +```bash +./gradlew clean integration ``` -#### Add Gradle Wrapper +What happens under the hood: -run `gradle wrapper` +- Gradle task `integration` depends on `composeUp` (from the Compose plugin) +- `composeUp` builds the app image, starts Postgres (host port **55432**) and app (**8082**), and waits for health +- Tests run against `http://localhost:8082` +- `composeDown` is always called to clean up -### Environment Setup for Local Builds +--- -Recommended Approach for macOS Users (using `direnv`) +## Configuration -If you're on macOS, you can use [direnv](https://direnv.net/) to automatically load these environment variables per project: +The application is configured via environment variables with sensible defaults. Key settings: -1. Install `direnv`: - ```bash - brew install direnv - ``` +| Property | Default | Notes | +|----------------------------------|----------------------------------------------|-------------------------------------| +| `server.port` | `8082` | API & Actuator port | +| `SPRING_DATASOURCE_URL` | `jdbc:postgresql://localhost:55432/appdb` | Postgres JDBC URL | +| `SPRING_DATASOURCE_USERNAME` | `app` | DB user | +| `SPRING_DATASOURCE_PASSWORD` | `app` | DB password | +| `DB_POOL_SIZE` | `10` | Hikari pool size | +| `TRACING_SAMPLER_PROBABILITY` | `1.0` | OTel tracing sample rate | +| `OTEL_TRACES_URL` | `http://localhost:4318/v1/traces` | OTLP traces endpoint | +| `OTEL_METRICS_ENABLED` | `false` | Export metrics via OTLP if `true` | +| `OTEL_METRICS_URL` | `http://localhost:4318/v1/metrics` | OTLP metrics endpoint | -2. Hook it into your shell (example for bash or zsh): - ```bash - echo 'eval "$(direnv hook bash)"' >> ~/.bash_profile - # or for zsh - echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc - ``` +Flyway is enabled and points at `classpath:db/migration`. -4. Allow `direnv` to load: - ```bash - direnv allow - ``` +--- -This will ensure your environment is correctly set up every time you enter the project directory. +## API & docs -## Static code analysis +- Base URL: `http://localhost:8082` +- Swagger UI: `http://localhost:8082/swagger-ui.html` + (OpenAPI JSON at `/v3/api-docs`) -Install PMD +Example smoke: ```bash -brew install pmd +curl -fsS "http://localhost:8082/actuator/health" | jq ``` + +--- + +## Observability + +Actuator endpoints (same port as API): + +| Endpoint | Purpose | +|-----------------------------|---------------------------------| +| `/actuator/health` | Overall health (UP/DOWN) | +| `/actuator/health/liveness` | Liveness probe | +| `/actuator/health/readiness`| Readiness probe | +| `/actuator/info` | App/build info (if configured) | +| `/actuator/prometheus` | Prometheus/OpenMetrics scrape | + +The service logs JSON to STDOUT (Logback + logstash-encoder). +OTel tracing is pre-wired; set the `OTEL_*` env vars above to export. + +Quick checks: + ```bash -pmd check \ - --dir src/main/java \ - --rulesets \ - .github/pmd-ruleset.xml \ - --format html \ - -r build/reports/pmd/pmd-report.html +curl -fsS http://localhost:8082/actuator/health +curl -fsS http://localhost:8082/actuator/prometheus | head ``` -## Pact Provider Test +--- + +## SBOM / dependency insights -Run pact provider test and publish verification report to pact broker locally +Generate a CycloneDX SBOM: -Update .env file with below details (replacing placeholders with actual values): ```bash -export PACT_PROVIDER_VERSION="0.1.0-local-YOUR-INITIALS" # or any version you want to use -export PACT_VERIFIER_PUBLISH_RESULTS=true -export PACT_PROVIDER_BRANCH="ANY_BRANCH_NAME_THAT_IS_NOT_A_DEFAULT_ONE" -export PACT_BROKER_TOKEN="YOUR_PACTFLOW_BROKER_TOKEN" -export PACT_BROKER_URL="https://hmcts-dts.pactflow.io" -export PACT_ENV="local" # or value based on the environment you are testing against +./gradlew cyclonedxBom +# Output at: build/reports/bom.json (or .xml depending on configuration) ``` -Run Pact tests: + +Print dependency trees: + ```bash -gradle pactVerificationTest +./gradlew -q dependencies --configuration runtimeClasspath ``` -### Contribute to This Repository +--- + +## Troubleshooting -Contributions are welcome! Please see the [CONTRIBUTING.md](.github/CONTRIBUTING.md) file for guidelines. +- **Port in use** — Change ports in `application.yml` or Compose file. DB uses **55432** to avoid conflicts with a local 5432. +- **Compose not found** — You need **Docker Compose v2** (`docker compose`). The Gradle plugin calls the Docker CLI directly. +- **DB auth failures** — Ensure env vars match Postgres service in Compose: `app/app@appdb`. +- **Slow startup in CI** — Increase Compose wait timeouts (plugin `upAdditionalArgs`) if needed. +- **Gradle “buildDir deprecated”** — The build uses `layout.buildDirectory`; avoid legacy `buildDir` in custom tasks. -See also: [JWTFilter documentation](docs/JWTFilter.md) +--- ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details +MIT \ No newline at end of file diff --git a/bin/run-in-docker.sh b/bin/run-in-docker.sh index 76aaddfc..bcc68548 100755 --- a/bin/run-in-docker.sh +++ b/bin/run-in-docker.sh @@ -24,9 +24,9 @@ GRADLE_INSTALL=false # TODO custom environment variables application requires. # TODO also consider enlisting them in help string above ^ -# TODO sample: DB_PASSWORD Defaults to 'dev' +# TODO sample: CP_CDK_DB_PASSWORD Defaults to 'dev' # environment variables -#DB_PASSWORD=dev +#CP_CDK_DB_PASSWORD=dev #S2S_URL=localhost #S2S_SECRET=secret @@ -47,7 +47,7 @@ execute_script() { # echo "Assigning environment variables.." # -# export DB_PASSWORD=${DB_PASSWORD} +# export CP_CDK_DB_PASSWORD=${CP_CDK_DB_PASSWORD} # export S2S_URL=${S2S_URL} # export S2S_SECRET=${S2S_SECRET} @@ -63,7 +63,7 @@ while true ; do -i|--install) GRADLE_INSTALL=true ; shift ;; -p|--param) case "$2" in -# DB_PASSWORD=*) DB_PASSWORD="${2#*=}" ; shift 2 ;; +# CP_CDK_DB_PASSWORD=*) CP_CDK_DB_PASSWORD="${2#*=}" ; shift 2 ;; # S2S_URL=*) S2S_URL="${2#*=}" ; shift 2 ;; # S2S_SECRET=*) S2S_SECRET="${2#*=}" ; shift 2 ;; *) shift 2 ;; diff --git a/build.gradle b/build.gradle index 497f8542..4e9b95d5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,14 +1,20 @@ plugins { id 'application' id 'java' + + id 'org.springframework.boot' version '4.0.0-M2' id 'io.spring.dependency-management' version '1.1.7' - id 'org.springframework.boot' version '3.5.5' + id 'jacoco' - id 'maven-publish' - id "com.github.ben-manes.versions" version "0.52.0" - id "org.cyclonedx.bom" version "2.3.1" - id "au.com.dius.pact" version "4.6.17" id 'pmd' + + id 'maven-publish' + id 'com.github.ben-manes.versions' version '0.52.0' + id 'org.cyclonedx.bom' version '2.3.1' + id 'com.gorylenko.gradle-git-properties' version '2.5.3' + + // Docker Compose lifecycle for integration tests + id 'com.avast.gradle.docker-compose' version '0.17.12' } group = 'uk.gov.hmcts.cp' @@ -16,138 +22,137 @@ version = System.getProperty('ARTEFACT_VERSION') ?: '0.0.999' def githubActor = project.findProperty("github.actor") ?: System.getenv("GITHUB_ACTOR") def githubToken = project.findProperty("github.token") ?: System.getenv("GITHUB_TOKEN") -def githubRepo = System.getenv("GITHUB_REPOSITORY") +def githubRepo = System.getenv("GITHUB_REPOSITORY") def azureADOArtifactRepository = 'https://pkgs.dev.azure.com/hmcts/Artifacts/_packaging/hmcts-lib/maven/v1' -def azureADOArtifactActor = System.getenv("AZURE_DEVOPS_ARTIFACT_USERNAME") -def azureADOArtifactToken = System.getenv("AZURE_DEVOPS_ARTIFACT_TOKEN") +def azureADOArtifactActor = System.getenv("AZURE_DEVOPS_ARTIFACT_USERNAME") +def azureADOArtifactToken = System.getenv("AZURE_DEVOPS_ARTIFACT_TOKEN") java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 + toolchain { languageVersion = JavaLanguageVersion.of(21) } +} + +repositories { + mavenLocal() + mavenCentral() + maven { url = azureADOArtifactRepository } +} + +ext { + lombokVersion = "1.18.38" + apiCourtScheduleVersion = "0.4.2" } sourceSets { integrationTest { - java { - compileClasspath += sourceSets.main.output - runtimeClasspath += sourceSets.main.output - } - resources.srcDir file('src/integrationTest/resources') - } - pactVerificationTest { - java { - compileClasspath += sourceSets.main.output - runtimeClasspath += sourceSets.main.output - } - resources.srcDir file('src/pactVerificationTest/resources') + java.srcDir 'src/integrationTest/java' + resources.srcDir 'src/integrationTest/resources' + compileClasspath += sourceSets.main.output + configurations.integrationTestCompileClasspath + runtimeClasspath += sourceSets.main.output + configurations.integrationTestRuntimeClasspath } } configurations { integrationTestImplementation.extendsFrom testImplementation - integrationTestRuntimeOnly.extendsFrom runtimeOnly + integrationTestRuntimeOnly.extendsFrom testRuntimeOnly + + // resolvable if you later need to locate agents, etc. + testRuntimeClasspath { canBeResolved = true } +} - pactVerificationTestImplementation.extendsFrom testImplementation - pactVerificationTestRuntimeOnly.extendsFrom runtimeOnly +dependencies { + // --- Domain API --- + implementation "uk.gov.hmcts.cp:api-cp-crime-schedulingandlisting-courtschedule:$apiCourtScheduleVersion" - // Ensure testRuntimeClasspath can be resolved for agent injection - testRuntimeClasspath { - canBeResolved = true - } + // --- Core web + validation --- + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // --- Observability / Actuator / OTEL / Prometheus --- + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' + implementation 'org.springframework.boot:spring-boot-starter-opentelemetry' + + // --- OpenAPI (Boot 4 compatible) --- + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.0-M1' + implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.17.2' + + // --- Data / DB --- + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.postgresql:postgresql' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' + + // --- JSON logging / utils --- + implementation 'net.logstash.logback:logstash-logback-encoder:8.1' + implementation 'org.apache.logging.log4j:log4j-to-slf4j' + implementation 'org.apache.commons:commons-text:1.14.0' + + // Keep only if you actually use JWT primitives yourself (not via Spring Security) + implementation 'io.jsonwebtoken:jjwt:0.13.0' + + // --- Lombok --- + compileOnly "org.projectlombok:lombok:$lombokVersion" + annotationProcessor "org.projectlombok:lombok:$lombokVersion" + testCompileOnly "org.projectlombok:lombok:$lombokVersion" + testAnnotationProcessor "org.projectlombok:lombok:$lombokVersion" + + // --- Tests --- + testImplementation 'org.springframework.boot:spring-boot-starter-test' + integrationTestImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'io.rest-assured:rest-assured:5.5.6' } +springBoot { + buildInfo { + properties { + name = project.name + version = project.version.toString() + } + } +} -tasks.withType(JavaCompile) { +tasks.withType(JavaCompile).configureEach { options.compilerArgs << "-Xlint:unchecked" << "-Werror" } -// https://github.com/gradle/gradle/issues/16791 tasks.withType(JavaExec).configureEach { javaLauncher.set(javaToolchains.launcherFor(java.toolchain)) } -tasks.named('test') { - useJUnitPlatform{ - excludeTags 'pact' - } - systemProperty 'API_SPEC_VERSION', project.version - failFast = true - // Mockito must be added as an agent, see: - // https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3 - jvmArgs += [ - "-javaagent:${configurations.testRuntimeClasspath.find { it.name.contains('mockito-core') }}", '-Xshare:off' - ] - testLogging { - events "passed", "skipped", "failed" - exceptionFormat = 'full' - showStandardStreams = true - } - reports { - junitXml.required.set(true) - html.required.set(true) - } +tasks.named('processIntegrationTestResources') { + // prevent duplicate resource collisions (e.g., application-docker-it.yml) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } -tasks.register('integration', Test) { - description = "Runs integration tests" - group = "Verification" - testClassesDirs = sourceSets.integrationTest.output.classesDirs - classpath = sourceSets.integrationTest.runtimeClasspath +tasks.named('test') { useJUnitPlatform() - failFast = true + // Gradle 9: do NOT use failIfNoTests (removed) + jvmArgs = ['-Xshare:off'] testLogging { events "passed", "skipped", "failed" exceptionFormat = 'full' showStandardStreams = true } - reports { - junitXml.required.set(true) - html.required.set(true) - } + reports { junitXml.required.set(true); html.required.set(true) } } -tasks.register('pactVerificationTest', Test) { - description = "Runs Pact provider verification tests" - group = "Verification" - testClassesDirs = sourceSets.pactVerificationTest.output.classesDirs - classpath = sourceSets.pactVerificationTest.runtimeClasspath - useJUnitPlatform { - includeTags 'pact' - } - failFast = true - - def sysOrEnv = { String key -> - System.getProperty(key) ?: System.getenv(key.toUpperCase().replace('.', '_')) - } - systemProperty 'pact.broker.url', sysOrEnv('pact.broker.url') - systemProperty 'pact.broker.token', sysOrEnv('pact.broker.token') - systemProperty 'pact.provider.version', sysOrEnv('pact.provider.version') - systemProperty 'pact.provider.branch', sysOrEnv('pact.provider.branch') - systemProperty 'pact.verifier.publishResults', sysOrEnv('pact.verifier.publishResults') - systemProperty 'pact.env', sysOrEnv( 'pact.env') ?: 'local' - reports { - junitXml.required.set(true) - html.required.set(true) - } +tasks.withType(Test).configureEach { + // remove failIfNoTests (not available on Gradle 9) + // if you want to fail when filters match nothing, you can enable: + // failOnNoMatchingTests = true } -tasks.named('build') { - dependsOn tasks.named('test') - dependsOn tasks.named('integration') -} +jacoco { toolVersion = "0.8.12" } tasks.named('jacocoTestReport') { dependsOn tasks.named('test') - reports { - xml.required.set(true) - csv.required.set(false) - html.required.set(true) - } -} - -tasks.named('check') { - dependsOn tasks.named('integration') + executionData fileTree( + dir: layout.buildDirectory.dir("jacoco").get().asFile, + include: ["*.exec"] + ) + reports { xml.required.set(true); csv.required.set(false); html.required.set(true) } } pmd { @@ -155,166 +160,131 @@ pmd { ruleSetFiles = files(".github/pmd-ruleset.xml") ignoreFailures = false } - -tasks.withType(Pmd) { - reports { - xml.required.set(true) - html.required.set(true) - } -} -tasks.named("pmdMain").configure { - onlyIf { gradle.startParameter.taskNames.contains(name) } +tasks.withType(Pmd).configureEach { + reports { xml.required.set(true); html.required.set(true) } } +tasks.named("pmdMain").configure { onlyIf { gradle.startParameter.taskNames.contains(name) } } tasks.named("pmdTest").configure { enabled = false } tasks.named("pmdIntegrationTest").configure { enabled = false } -tasks.named("pmdPactVerificationTest").configure { enabled = false } -// check dependencies upon release ONLY tasks.named("dependencyUpdates").configure { - def isNonStable = { String version -> - def stableKeyword = ['RELEASE', 'FINAL', 'GA'].any { qualifier -> version.toUpperCase().contains(qualifier) } + def isNonStable = { String v -> + def stableKeyword = ['RELEASE','FINAL','GA'].any { q -> v.toUpperCase().contains(q) } def regex = /^[0-9,.v-]+$/ - return !stableKeyword && !(version ==~ regex) - } - rejectVersionIf { - isNonStable(it.candidate.version) && !isNonStable(it.currentVersion) - } -} - -repositories { - mavenLocal() - mavenCentral() - maven { - url = azureADOArtifactRepository - } -} - -publishing { - publications { - mavenJava(MavenPublication) { - artifact(tasks.named('bootJar')) - artifact(tasks.named('jar')) - } - } - repositories { - maven { - name = "GitHubPackages" - url = uri("https://maven.pkg.github.com/$githubRepo") - credentials { - username = githubActor - password = githubToken - } - } - maven { - name = "AzureArtifacts" - url = uri(azureADOArtifactRepository) - credentials { - username = azureADOArtifactActor - password = azureADOArtifactToken - } - } + !stableKeyword && !(v ==~ regex) } + rejectVersionIf { isNonStable(candidate.version) && !isNonStable(currentVersion) } } -//Creation of Software Bill of Materials -//https://github.com/CycloneDX/cyclonedx-gradle-plugin cyclonedxBom { includeConfigs = ["runtimeClasspath"] skipConfigs = ["compileClasspath", "testImplementation"] schemaVersion = "1.6" componentVersion = providers.provider { project.version.toString() } - destination = file("$buildDir/reports") + destination = layout.buildDirectory.dir("reports").get().asFile +} + +tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true } jar { enabled = true archiveClassifier.set('plain') + manifest { + attributes( + 'Implementation-Title': project.name, + 'Implementation-Version': project.version.toString() + ) + } if (file("CHANGELOG.md").exists()) { - from('CHANGELOG.md') { - into 'META-INF' - } - } else { - println "⚠️ CHANGELOG.md not found, skipping inclusion in JAR" + from('CHANGELOG.md') { into 'META-INF' } } } bootJar { archiveFileName = "${rootProject.name}-${project.version}.jar" - - manifest { - attributes('Implementation-Version': project.version.toString()) - } + manifest { attributes('Implementation-Version': project.version.toString()) } } -application { - mainClass = 'uk.gov.hmcts.cp.Application' -} +// Build app before compose build (your compose should COPY the bootJar) +tasks.named('composeBuild') { dependsOn tasks.named('bootJar') } -ext { - apiCourtScheduleVersion = "0.4.2" - log4JVersion = "2.24.3" - logbackVersion = "1.5.18" - lombokVersion = "1.18.38" -} +/* ---------------- Docker Compose–driven Integration Tests ---------------- */ -tasks.named('processPactVerificationTestResources') { - duplicatesStrategy = DuplicatesStrategy.EXCLUDE -} +dockerCompose { + useComposeFiles = ['docker/docker-compose.integration.yml'] + startedServices = ['db', 'app'] + buildBeforeUp = true + waitForTcpPorts = true + upAdditionalArgs = ['--wait', '--wait-timeout', '120'] -dependencies { - implementation "uk.gov.hmcts.cp:api-cp-crime-schedulingandlisting-courtschedule:$apiCourtScheduleVersion" - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.13' - implementation 'io.swagger.core.v3:swagger-core:2.2.36' - implementation 'javax.xml.bind:jaxb-api:2.3.1' + captureContainersOutput = true + removeOrphans = true + stopContainers = true + removeContainers = true - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-actuator' - implementation 'org.springframework.boot:spring-boot-starter-aop' - implementation 'org.springframework.boot:spring-boot-starter-json' + projectName = "${rootProject.name}-it".replaceAll('[^A-Za-z0-9]', '') - implementation platform('io.micrometer:micrometer-tracing-bom:latest.release') - implementation 'io.micrometer:micrometer-tracing' - implementation 'io.micrometer:micrometer-tracing-bridge-otel' - implementation 'io.micrometer:micrometer-registry-azure-monitor' - implementation 'com.azure:azure-monitor-opentelemetry-autoconfigure:1.3.0' - - implementation 'net.logstash.logback:logstash-logback-encoder:8.1' - implementation group: 'org.apache.logging.log4j', name: 'log4j-to-slf4j', version: log4JVersion - implementation group: 'ch.qos.logback', name: 'logback-classic', version: logbackVersion - implementation group: 'ch.qos.logback', name: 'logback-core', version: logbackVersion + useDockerComposeV2 = true + dockerExecutable = 'docker' +} - implementation group: 'io.rest-assured', name: 'rest-assured', version: '5.5.6' - implementation 'org.hibernate.validator:hibernate-validator:9.0.1.Final' - implementation 'org.apache.commons:commons-text:1.14.0' +tasks.register('integration', Test) { + description = "Runs integration tests against docker-compose stack" + group = "Verification" - implementation 'com.fasterxml.jackson.core:jackson-databind:2.20.0' + testClassesDirs = sourceSets.integrationTest.output.classesDirs + classpath = sourceSets.integrationTest.runtimeClasspath + useJUnitPlatform() - implementation 'io.jsonwebtoken:jjwt:0.13.0' + dependsOn tasks.composeUp + finalizedBy tasks.composeDown - compileOnly group: 'org.projectlombok', name: 'lombok', version: lombokVersion - annotationProcessor group: 'org.projectlombok', name: 'lombok', version: lombokVersion - testCompileOnly group: 'org.projectlombok', name: 'lombok', version: lombokVersion - testAnnotationProcessor group: 'org.projectlombok', name: 'lombok', version: lombokVersion + systemProperty 'app.baseUrl', 'http://localhost:8082' - testImplementation(platform('org.junit:junit-bom:5.13.4')) - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' - testImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: '3.5.5', { - exclude group: 'junit', module: 'junit' - exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + jvmArgs = ['-Xshare:off'] + testLogging { + events "passed", "skipped", "failed" + exceptionFormat = 'full' + showStandardStreams = true } - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} - testImplementation 'au.com.dius.pact.provider:junit5:4.6.17' - testImplementation 'au.com.dius.pact.provider:spring6:4.6.17' +// Ensure CI “build/check” includes integration tests +tasks.named('check') { dependsOn tasks.named('integration') } +tasks.named('build') { dependsOn tasks.named('integration') } - // integrationTestImplementation dependencies are need as they aren't always propagate as expected, - // especially with platform BOMs, test runtime dependencies, or custom tasks. Explicit dependencies eliminate that risk. - integrationTestImplementation platform('org.junit:junit-bom:5.13.4') - integrationTestRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' - integrationTestImplementation group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: '3.5.5', { - exclude group: 'junit', module: 'junit' - exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' +/* ---------------- Publishing ---------------- */ + +publishing { + publications { + mavenJava(MavenPublication) { + artifact(tasks.named('bootJar')) + artifact(tasks.named('jar')) + pom { + name = project.name + description = "Case Document Knowledge Service" + url = "https://github.com/${githubRepo ?: 'org/repo'}" + } + } + } + repositories { + maven { + name = "GitHubPackages" + url = uri("https://maven.pkg.github.com/${githubRepo ?: 'org/repo'}") + credentials { username = githubActor; password = githubToken } + } + maven { + name = "AzureArtifacts" + url = uri(azureADOArtifactRepository) + credentials { username = azureADOArtifactActor; password = azureADOArtifactToken } + } } - integrationTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' -} \ No newline at end of file +} + +application { + mainClass = 'uk.gov.hmcts.cp.Application' +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index c05c4ffa..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,33 +0,0 @@ -version: '3.8' - -services: - service-hmcts-marketplace-springboot-template: - env_file: - - .env - build: - context: . - dockerfile: Dockerfile - args: - http_proxy: ${http_proxy} - https_proxy: ${https_proxy} - no_proxy: ${no_proxy} - BASE_IMAGE: ${BASE_IMAGE} - SERVER_PORT: ${SERVER_PORT} - JAR_FILENAME: ${JAR_FILENAME} - JAR_FILE_PATH: ${JAR_FILE_PATH} - environment: - - SERVER_PORT=${SERVER_PORT:-4550} - ports: - - "${SERVER_PORT:-4550}:${SERVER_PORT:-4550}" - networks: - - service-network - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:${SERVER_PORT}/health" ] - interval: 30s - timeout: 10s - retries: 3 - start_period: 5s - -networks: - service-network: - name: service-hmcts-marketplace-springboot-template-network diff --git a/docker/.env b/docker/.env new file mode 100644 index 00000000..ade4cc85 --- /dev/null +++ b/docker/.env @@ -0,0 +1 @@ +INT_DB_PORT=5432 \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 00000000..369f5f4c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,22 @@ +# Dockerfile (project root) +FROM eclipse-temurin:21-jre-alpine + +# minimal runtime tooling for healthcheck +RUN apk add --no-cache curl + +# run as non-root +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app + +# copy all jars (bootJar + plain). We'll run the non-plain jar. +COPY build/libs/*.jar /app/ + +EXPOSE 8082 +ENV JAVA_OPTS="-XX:MaxRAMPercentage=75 -XX:+AlwaysActAsServerClassMachine" + +HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=15 \ + CMD curl -fsS http://localhost:8082/actuator/health | grep -q '"status":"UP"' || exit 1 + +USER app +# pick the Boot fat jar (exclude '-plain.jar') +ENTRYPOINT ["sh","-c","exec java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar $(ls /app/*.jar | grep -v 'plain' | head -n1)"] diff --git a/docker/docker-compose.integration.yml b/docker/docker-compose.integration.yml new file mode 100644 index 00000000..8ed4989b --- /dev/null +++ b/docker/docker-compose.integration.yml @@ -0,0 +1,36 @@ +services: + db: + image: postgres:16-alpine + container_name: cdks_CP_CDK_DB_it + environment: + POSTGRES_DB: appdb + POSTGRES_USER: app + POSTGRES_PASSWORD: app + ports: + - "55432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U app -d appdb"] + interval: 5s + timeout: 3s + retries: 30 + + app: + container_name: cdks_app_it + build: + context: .. # project root (so Docker has access to source) + dockerfile: docker/Dockerfile + ports: + - "8082:8082" + environment: + CP_CDK_DATASOURCE_URL: jdbc:postgresql://db:${INT_DB_PORT}/appdb + CP_CDK_DATASOURCE_USERNAME: app + CP_CDK_DATASOURCE_PASSWORD: app + SERVER_PORT: 8082 + depends_on: + db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8082/actuator/health || exit 1"] + interval: 10s + timeout: 3s + retries: 12 \ No newline at end of file diff --git a/docs/JWTFilter.md b/docs/JWTFilter.md deleted file mode 100644 index 0cdae8b6..00000000 --- a/docs/JWTFilter.md +++ /dev/null @@ -1,39 +0,0 @@ -# JWTFilter - -`JWTFilter` enforces the presence of a JWT on incoming requests, validates it, and exposes user details for the lifetime of the request. - -## Purpose -- Requires the `jwt` header on requests (except excluded paths) -- Validates and parses the token via `JWTService` -- Stores `userName` and `scope` in a request-scoped `AuthDetails` bean - -## Configuration -Defined in `src/main/resources/application.yaml`: - -```yaml -jwt: - secretKey: "it-must-be-a-string-secret-at-least-256-bits-long" - filter: - enabled: false -``` - -- `jwt.secretKey`: Base64 key suitable for HS256 (≥ 256 bits) -- `jwt.filter.enabled`: When false, the filter is skipped entirely. When true, it runs for all paths except those excluded. - -### Enabling per environment -- Env var: `JWT_FILTER_ENABLED=true` -- Tests: `@SpringBootTest(properties = "jwt.filter.enabled=true")` -- Profile override: `application-.yaml` - -## Excluded paths -Currently excluded: `/health`. Extend in `JWTFilter.shouldNotFilter(...)` if needed. - -## Error behaviour -- Missing header: 401 UNAUTHORIZED ("No jwt token passed") -- Invalid token: 400 BAD_REQUEST with details - -## Related classes -- `uk.gov.hmcts.cp.filters.jwt.JWTFilter` -- `uk.gov.hmcts.cp.filters.jwt.JWTService` -- `uk.gov.hmcts.cp.filters.jwt.AuthDetails` -- `uk.gov.hmcts.cp.config.JWTConfig` diff --git a/docs/Logging.md b/docs/Logging.md deleted file mode 100644 index d22dc211..00000000 --- a/docs/Logging.md +++ /dev/null @@ -1,24 +0,0 @@ -# HMCTS API Marketplace Logging - - -# Design -We want to produce logging as json with fields as detailed below -We currently output to stdout and expect that the k8s containers will be wired to capture the logging into azure logging -Once we work closer with Azure ops teams we expect to implement an spring boot azure plugin to directly inject logs to azure - - -# Fields -See logback.xml for master list -mdc - Logs any objects added to MDC e.g. io.micrometer adds traceId and spanId to MDC - - -# Config -We use the standard logging config file logback.xml in main -This applies the logging config to Application, Integration Test and Unit Test -Using spring-logback.xml will apply config to Spring Application and Spring Integration Tests. - - -# Testing -We should use a common logbackj.xml for main and test to allow us to test the format. -We dont currently have a test to prove the main Application logging is correct -This may need to be done outside the app test suite maybe as part of a docker test diff --git a/src/integrationTest/java/uk/gov/hmcts/cp/actuator/ActuatorHttpLiveTest.java b/src/integrationTest/java/uk/gov/hmcts/cp/actuator/ActuatorHttpLiveTest.java new file mode 100644 index 00000000..760acdeb --- /dev/null +++ b/src/integrationTest/java/uk/gov/hmcts/cp/actuator/ActuatorHttpLiveTest.java @@ -0,0 +1,37 @@ +package uk.gov.hmcts.cp.actuator; + +import org.junit.jupiter.api.Test; +import org.springframework.http.*; +import org.springframework.web.client.RestTemplate; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ActuatorHttpLiveTest { + + private final String baseUrl = System.getProperty("app.baseUrl", "http://localhost:8082"); + private final RestTemplate http = new RestTemplate(); + + @Test + void health_is_up() { + ResponseEntity res = http.exchange( + baseUrl + "/actuator/health", HttpMethod.GET, + new HttpEntity<>(new HttpHeaders()), + String.class + ); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(res.getBody()).contains("\"status\":\"UP\""); + } + + @Test + void prometheus_is_exposed() { + HttpHeaders h = new HttpHeaders(); + h.setAccept(java.util.List.of(MediaType.TEXT_PLAIN)); + ResponseEntity res = http.exchange( + baseUrl + "/actuator/prometheus", HttpMethod.GET, + new HttpEntity<>(h), + String.class + ); + assertThat(res.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(res.getBody()).contains("application_started_time_seconds"); + } +} diff --git a/src/integrationTest/java/uk/gov/hmcts/cp/controllers/CourtScheduleControllerIntegrationTest.java b/src/integrationTest/java/uk/gov/hmcts/cp/controllers/CourtScheduleControllerIntegrationTest.java deleted file mode 100644 index ad4d67a9..00000000 --- a/src/integrationTest/java/uk/gov/hmcts/cp/controllers/CourtScheduleControllerIntegrationTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package uk.gov.hmcts.cp.controllers; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@ExtendWith(SpringExtension.class) -@SpringBootTest -@AutoConfigureMockMvc -class CourtScheduleControllerIntegrationTest { - private static final Logger log = LoggerFactory.getLogger(CourtScheduleControllerIntegrationTest.class); - - @Resource - private MockMvc mockMvc; - - @Test - void shouldReturnOkWhenValidUrnIsProvided() throws Exception { - String caseUrn = "test-case-urn"; - mockMvc.perform(get("/case/{case_urn}/courtschedule", caseUrn) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(result -> { - // You may need to adjust this depending on the actual fields in CourtScheduleResponse - String responseBody = result.getResponse().getContentAsString(); - log.info("Response: {}", responseBody); - JsonNode jsonBody = new ObjectMapper().readTree(result.getResponse().getContentAsString()); - - - assertEquals("courtSchedule", jsonBody.fieldNames().next()); - JsonNode courtSchedule = jsonBody.get("courtSchedule"); - assertTrue(courtSchedule.isArray()); - assertEquals(1, courtSchedule.size()); - - JsonNode hearings = courtSchedule.get(0).get("hearings"); - assertTrue(hearings.isArray()); - assertEquals(1, hearings.size()); - - JsonNode hearing = hearings.get(0); - UUID hearingId = UUID.fromString(hearing.get("hearingId").asText()); - assertNotNull(hearingId); - assertEquals("Requires interpreter", hearing.get("listNote").asText()); - assertEquals("Sentencing for theft case", hearing.get("hearingDescription").asText()); - assertEquals("Trial", hearing.get("hearingType").asText()); - - JsonNode courtSittings = hearing.get("courtSittings"); - assertTrue(courtSittings.isArray()); - assertEquals(1, courtSittings.size()); - - JsonNode sitting = courtSittings.get(0); - assertEquals("Central Criminal Court", sitting.get("courtHouse").asText()); - assertNotNull(sitting.get("sittingStart").asText()); - assertNotNull(sitting.get("sittingEnd").asText()); - UUID judiciaryId = UUID.fromString(sitting.get("judiciaryId").asText()); - assertNotNull(judiciaryId); - log.info("Response Object: {}", jsonBody); - }); - } -} \ No newline at end of file diff --git a/src/integrationTest/java/uk/gov/hmcts/cp/controllers/HealthCheckIntegrationTest.java b/src/integrationTest/java/uk/gov/hmcts/cp/controllers/HealthCheckIntegrationTest.java deleted file mode 100644 index 7efdc654..00000000 --- a/src/integrationTest/java/uk/gov/hmcts/cp/controllers/HealthCheckIntegrationTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package uk.gov.hmcts.cp.controllers; - -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -@ExtendWith(SpringExtension.class) -@AutoConfigureMockMvc -@SpringBootTest -class HealthCheckIntegrationTest { - - @Resource - private MockMvc mockMvc; - - @DisplayName("Actuator health status should be UP") - @Test - void shouldCallActuatorAndGet200() throws Exception { - mockMvc.perform(get("/health")) - .andDo(print()) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.status").value("UP")); - } -} diff --git a/src/integrationTest/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterDisabledIntegrationTest.java b/src/integrationTest/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterDisabledIntegrationTest.java deleted file mode 100644 index 777180b1..00000000 --- a/src/integrationTest/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterDisabledIntegrationTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import static org.hamcrest.Matchers.containsString; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -import jakarta.annotation.Resource; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -@SpringBootTest -@AutoConfigureMockMvc -class JWTFilterDisabledIntegrationTest { - - @Resource - MockMvc mockMvc; - - @DisplayName("JWT filter should not complain of missing JWT when the filter is disabled") - @Test - void shouldNotFailWhenTokenIsMissingAndFilterIsDisabled() throws Exception { - mockMvc - .perform( - MockMvcRequestBuilders.get("/") - ).andExpectAll( - status().isOk(), - content().string(containsString("Welcome to service-hmcts-springboot-template")) - ); - } -} \ No newline at end of file diff --git a/src/integrationTest/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterIntegrationTest.java b/src/integrationTest/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterIntegrationTest.java deleted file mode 100644 index a7eb2b49..00000000 --- a/src/integrationTest/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterIntegrationTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import static uk.gov.hmcts.cp.filters.jwt.JWTFilter.JWT_TOKEN_HEADER; - -import jakarta.annotation.Resource; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; -import org.springframework.web.client.HttpClientErrorException; - -@SpringBootTest(properties = {"jwt.filter.enabled=true"}) -@AutoConfigureMockMvc -class JWTFilterIntegrationTest { - - @Resource - MockMvc mockMvc; - - @Resource - private JWTService jwtService; - - @Test - void shouldPassWhenTokenIsValid() throws Exception { - String jwtToken = jwtService.createToken(); - mockMvc - .perform( - MockMvcRequestBuilders.get("/") - .header(JWT_TOKEN_HEADER, jwtToken) - ).andExpectAll( - status().isOk(), - content().string("Welcome to service-hmcts-springboot-template, " + JWTService.USER) - ); - } - - @Test - void shouldFailWhenTokenIsMissing() { - assertThatExceptionOfType(HttpClientErrorException.class) - .isThrownBy(() -> mockMvc - .perform( - MockMvcRequestBuilders.get("/") - )) - .withMessageContaining("No jwt token passed"); - } -} \ No newline at end of file diff --git a/src/main/java/uk/gov/hmcts/cp/Application.java b/src/main/java/uk/gov/hmcts/cp/Application.java index b67c5a41..f0e388df 100644 --- a/src/main/java/uk/gov/hmcts/cp/Application.java +++ b/src/main/java/uk/gov/hmcts/cp/Application.java @@ -5,7 +5,6 @@ @SpringBootApplication @SuppressWarnings("HideUtilityClassConstructor") - public class Application { public static void main(final String[] args) { diff --git a/src/main/java/uk/gov/hmcts/cp/config/JWTConfig.java b/src/main/java/uk/gov/hmcts/cp/config/JWTConfig.java deleted file mode 100644 index cf15890e..00000000 --- a/src/main/java/uk/gov/hmcts/cp/config/JWTConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package uk.gov.hmcts.cp.config; - -import uk.gov.hmcts.cp.filters.jwt.AuthDetails; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.context.annotation.RequestScope; - -@Configuration -public class JWTConfig { - - @Bean - @RequestScope - // attributes are set in the filter - protected AuthDetails jwt(){ - return AuthDetails.builder().build(); - } -} diff --git a/src/main/java/uk/gov/hmcts/cp/config/RequestContextFilter.java b/src/main/java/uk/gov/hmcts/cp/config/RequestContextFilter.java new file mode 100644 index 00000000..592f6f02 --- /dev/null +++ b/src/main/java/uk/gov/hmcts/cp/config/RequestContextFilter.java @@ -0,0 +1,40 @@ +package uk.gov.hmcts.cp.config; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.MDC; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.UUID; + + +@Component("correlationMdcFilter") +@Order(Ordered.HIGHEST_PRECEDENCE + 1) +public class RequestContextFilter implements Filter { + + private static final String CLUSTER = System.getenv().getOrDefault("CLUSTER_NAME", "local"); + + private static final String REGION = System.getenv().getOrDefault("REGION", "local"); + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + try { + HttpServletRequest r = (HttpServletRequest) req; + String cid = r.getHeader("X-Correlation-Id"); + if (cid == null || cid.isBlank()) { + cid = UUID.randomUUID().toString(); + } + MDC.put("correlationId", cid); + MDC.put("cluster", CLUSTER); + MDC.put("region", REGION); + MDC.put("path", r.getRequestURI()); + chain.doFilter(req, res); + } finally { + MDC.clear(); + } + } +} diff --git a/src/main/java/uk/gov/hmcts/cp/controllers/ApiController.java b/src/main/java/uk/gov/hmcts/cp/controllers/ApiController.java index 7a12881f..1141633c 100644 --- a/src/main/java/uk/gov/hmcts/cp/controllers/ApiController.java +++ b/src/main/java/uk/gov/hmcts/cp/controllers/ApiController.java @@ -1,13 +1,13 @@ package uk.gov.hmcts.cp.controllers; -import static org.springframework.http.ResponseEntity.ok; - import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import static org.springframework.http.ResponseEntity.ok; + @RestController() @RequestMapping(path = "api", produces = MediaType.TEXT_PLAIN_VALUE) public class ApiController { diff --git a/src/main/java/uk/gov/hmcts/cp/controllers/RootController.java b/src/main/java/uk/gov/hmcts/cp/controllers/RootController.java deleted file mode 100644 index b0496ec6..00000000 --- a/src/main/java/uk/gov/hmcts/cp/controllers/RootController.java +++ /dev/null @@ -1,38 +0,0 @@ -package uk.gov.hmcts.cp.controllers; - -import lombok.AllArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; - -import static org.springframework.http.ResponseEntity.ok; - -import uk.gov.hmcts.cp.filters.jwt.AuthDetails; - -/** - * Default endpoints per application. - */ -@RestController -@AllArgsConstructor -@Slf4j -public class RootController { - - // request scope bean - protected AuthDetails jwtToken; - - /** - * Root GET endpoint. - * - *

Azure application service has a hidden feature of making requests to root endpoint when - * "Always On" is turned on. This is the endpoint to deal with that and therefore silence the - * unnecessary 404s as a response code. - * - * @return Welcome message from the service. - */ - @GetMapping("/") - public ResponseEntity welcome() { - log.info("START"); - return ok("Welcome to service-hmcts-springboot-template, " + jwtToken.getUserName()); - } -} diff --git a/src/main/java/uk/gov/hmcts/cp/filters/jwt/AuthDetails.java b/src/main/java/uk/gov/hmcts/cp/filters/jwt/AuthDetails.java deleted file mode 100644 index e53b2d6e..00000000 --- a/src/main/java/uk/gov/hmcts/cp/filters/jwt/AuthDetails.java +++ /dev/null @@ -1,17 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import jakarta.annotation.Resource; -import lombok.Builder; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; - -@Resource -@Builder -@Getter -@Setter -@ToString -public class AuthDetails { - private String userName; - private String scope; -} diff --git a/src/main/java/uk/gov/hmcts/cp/filters/jwt/InvalidJWTException.java b/src/main/java/uk/gov/hmcts/cp/filters/jwt/InvalidJWTException.java deleted file mode 100644 index 127ad5f8..00000000 --- a/src/main/java/uk/gov/hmcts/cp/filters/jwt/InvalidJWTException.java +++ /dev/null @@ -1,10 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -public class InvalidJWTException extends Exception { - @java.io.Serial - private static final long serialVersionUID = -3387516993124229948L; - - public InvalidJWTException(final String message) { - super(message); - } -} diff --git a/src/main/java/uk/gov/hmcts/cp/filters/jwt/JWTFilter.java b/src/main/java/uk/gov/hmcts/cp/filters/jwt/JWTFilter.java deleted file mode 100644 index 0845aba5..00000000 --- a/src/main/java/uk/gov/hmcts/cp/filters/jwt/JWTFilter.java +++ /dev/null @@ -1,73 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import java.io.IOException; -import java.util.stream.Stream; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Component; -import org.springframework.util.PathMatcher; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.filter.OncePerRequestFilter; - -@Order(Ordered.HIGHEST_PRECEDENCE) -@Component -@Slf4j -public class JWTFilter extends OncePerRequestFilter { - public final static String JWT_TOKEN_HEADER = "jwt"; - - private final JWTService jwtService; - private final PathMatcher pathMatcher; - private final ObjectProvider jwtProvider; - private final boolean jwFilterEnabled; - - public JWTFilter(final JWTService jwtService, final PathMatcher pathMatcher, final ObjectProvider jwtProvider, @Value("${jwt.filter.enabled}") final boolean jwFilterEnabled) { - this.jwtService = jwtService; - this.pathMatcher = pathMatcher; - this.jwtProvider = jwtProvider; - this.jwFilterEnabled = jwFilterEnabled; - } - - @Override - protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response, final FilterChain filterChain) throws ServletException, IOException { - - final String jwt = request.getHeader(JWT_TOKEN_HEADER); - - if (jwt == null) { - log.atError().log("JWTFilter expected header {} not passed", JWT_TOKEN_HEADER); - throw new HttpClientErrorException(HttpStatus.UNAUTHORIZED, "No jwt token passed"); - } - - try { - final AuthDetails extractedToken = jwtService.extract(jwt); - - final AuthDetails requestScopedToken = jwtProvider.getObject(); // current request instance - requestScopedToken.setUserName(extractedToken.getUserName()); - requestScopedToken.setScope(extractedToken.getScope()); - } catch (InvalidJWTException e) { - log.atError().log(e.getMessage()); - throw new HttpClientErrorException(HttpStatus.BAD_REQUEST, e.getMessage()); - } - filterChain.doFilter(request, response); - } - - @Override - protected boolean shouldNotFilter(final HttpServletRequest request) { - // Skip filtering entirely when disabled, and for specific paths - boolean shouldNotFilter = true; - if (jwFilterEnabled) { - log.atInfo().log("JWT Filter is enabled"); - shouldNotFilter = Stream.of("/health") - .anyMatch(p -> pathMatcher.match(p, request.getRequestURI())); - } - return shouldNotFilter; - } -} diff --git a/src/main/java/uk/gov/hmcts/cp/filters/jwt/JWTService.java b/src/main/java/uk/gov/hmcts/cp/filters/jwt/JWTService.java deleted file mode 100644 index 0d272e28..00000000 --- a/src/main/java/uk/gov/hmcts/cp/filters/jwt/JWTService.java +++ /dev/null @@ -1,79 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import io.jsonwebtoken.security.SignatureException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.time.Duration; -import java.time.Instant; -import java.util.Date; - -@Component -@Slf4j -public class JWTService { - - public static final String USER = "testUser"; - private static final Duration ONE_HOUR = Duration.ofHours(1); - private static final String SCOPE = "scope"; - private final String secretKey; - - public JWTService(@Value("${jwt.secretKey}") final String secretKey){ - this.secretKey = secretKey; - } - - public AuthDetails extract(final String token) throws InvalidJWTException { - try { - final Claims claims = Jwts.parser() - .verifyWith(getSecretSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - final String userName = claims.getSubject(); - final String scope = claims.get(SCOPE).toString(); - return new AuthDetails(userName, scope); - } catch (SignatureException ex) { - log.atError().log("Invalid signature/claims", ex); - throw new InvalidJWTException("Invalid signature:" + ex.getMessage()); - } catch (ExpiredJwtException ex) { - log.atError().log("Expired tokens", ex); - throw new InvalidJWTException("Expired tokens:" + ex.getMessage()); - } catch (UnsupportedJwtException ex) { - log.atError().log("Unsupported token", ex); - throw new InvalidJWTException("Unsupported token:" + ex.getMessage()); - } catch (MalformedJwtException ex) { - log.atError().log("Malformed token", ex); - throw new InvalidJWTException("Malformed token:" + ex.getMessage()); - } catch (IllegalArgumentException ex) { - log.atError().log("JWT token is empty", ex); - throw new InvalidJWTException("JWT token is empty:" + ex.getMessage()); - } catch (Exception ex) { - log.atError().log("Could not verify JWT token integrity", ex); - throw new InvalidJWTException("Could not validate JWT:" + ex.getMessage()); - } - } - - public String createToken() { - return Jwts.builder() - .subject(USER) - .issuedAt(Date.from(Instant.now())) - .claim(SCOPE, "read write") - .expiration(expiryDateAfterOneHour()) - .signWith(getSecretSigningKey()) - .compact(); - } - - private SecretKey getSecretSigningKey() { - final byte[] keyBytes = Decoders.BASE64URL.decode(secretKey); - return Keys.hmacShaKeyFor(keyBytes); - } - - private Date expiryDateAfterOneHour() { - return Date.from(Instant.now().plus(ONE_HOUR)); - } - -} diff --git a/src/main/java/uk/gov/hmcts/cp/repositories/InMemoryCourtScheduleRepositoryImpl.java b/src/main/java/uk/gov/hmcts/cp/repositories/InMemoryCourtScheduleRepositoryImpl.java index 0fa4c971..46e0ce4d 100644 --- a/src/main/java/uk/gov/hmcts/cp/repositories/InMemoryCourtScheduleRepositoryImpl.java +++ b/src/main/java/uk/gov/hmcts/cp/repositories/InMemoryCourtScheduleRepositoryImpl.java @@ -1,10 +1,10 @@ package uk.gov.hmcts.cp.repositories; import org.springframework.stereotype.Component; -import uk.gov.hmcts.cp.openapi.model.CourtScheduleResponse; import uk.gov.hmcts.cp.openapi.model.CourtSchedule; -import uk.gov.hmcts.cp.openapi.model.Hearing; +import uk.gov.hmcts.cp.openapi.model.CourtScheduleResponse; import uk.gov.hmcts.cp.openapi.model.CourtSitting; +import uk.gov.hmcts.cp.openapi.model.Hearing; import java.time.OffsetDateTime; import java.time.ZoneOffset; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9d581c71..b2f13c89 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,41 +1,93 @@ +spring: + application: + name: cp-case-document-knowledge-service + + datasource: + # Override these via env in each environment + url: ${CP_CDK_DATASOURCE_URL} + username: ${CP_CDK_DATASOURCE_USERNAME:app} + password: ${CP_CDK_DATASOURCE_PASSWORD:app} + hikari: + pool-name: cp-hikari + maximum-pool-size: ${CP_CDK_DB_POOL_SIZE:20} + minimum-idle: ${CP_CDK_DB_MIN_IDLE:5} + connection-timeout: 30000 # ms + idle-timeout: 600000 # ms + max-lifetime: 1800000 # ms + keepalive-time: 300000 # ms + validation-timeout: 5000 # ms + + jpa: + open-in-view: false + hibernate: + ddl-auto: validate # schema managed by Flyway + properties: + hibernate.jdbc.time_zone: UTC + hibernate.connection.provider_disables_autocommit: true + hibernate.jdbc.batch_size: 50 + hibernate.order_inserts: true + hibernate.order_updates: true + + sql: + init: + mode: never + +flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: ${FLYWAY_BASELINE_ON_MIGRATE:false} + server: - port: 4550 - shutdown: "graceful" + port: ${SERVER_PORT:8082} + shutdown: graceful + http2: + enabled: true + forward-headers-strategy: framework # honor X-Forwarded-* behind proxies + compression: + enabled: true + min-response-size: 2KB + mime-types: "application/json,application/xml,text/html,text/plain,text/xml,text/css,application/javascript" management: - endpoint: - health: - show-details: "always" - # group: - # readiness: - # include: "db" + server: + # Keep actuator on same port by default; override if you want a separate port + port: ${MANAGEMENT_SERVER_PORT:${SERVER_PORT:8082}} endpoints: web: - base-path: / + base-path: /actuator exposure: - include: health, info, prometheus + include: "health,info,metrics,prometheus,env,loggers,threaddump" + endpoint: + health: + probes: + enabled: true # liveness/readiness groups + metrics: + tags: + service: cp-case-document-knowledge-service + cluster: ${CLUSTER_NAME:local} + region: ${REGION:local} + tracing: + sampling: + probability: ${TRACING_SAMPLER_PROBABILITY:1.0} + otlp: + tracing: + endpoint: ${OTEL_TRACES_URL:http://localhost:4318/v1/traces} + metrics: + export: + enabled: ${OTEL_METRICS_ENABLED:false} + url: ${OTEL_METRICS_URL:http://localhost:4318/v1/metrics} springdoc: - packagesToScan: uk.gov.hmcts.cp.controllers - writer-with-order-by-keys: true + api-docs: + enabled: ${OPENAPI_ENABLED:false} + swagger-ui: + enabled: ${SWAGGER_UI_ENABLED:false} -spring: - config: - import: "optional:configtree:/mnt/secrets/rpe/" - application: - name: service-UPDATE-TO-BE-NAME-OF-SERVICE -# azure: -# cosmos: -# endpoint: ${COSMOSDB_ENDPOINT} -# key: ${COSMOSDB_KEY} -# database: ${COSMOSDB_DATABASE} - -azure: - application-insights: - instrumentation-key: ${rpe.AppInsightsInstrumentationKey:00000000-0000-0000-0000-000000000000} - -jwt: - # Expose upwards to allow env var override: export JWT_SECRET_KEY=... (base64, 256-bit min) - secretKey: ${JWT_SECRET_KEY:it-must-be-a-string-secret-at-least-256-bits-long} - filter: - enabled: false +logging: + level: + root: INFO + # Use your JSON config (async) — make sure this file exists + config: classpath:logback-spring.xml + +# Optional: Java 21 virtual threads (Boot 3.2+ / 4.x) +spring.threads.virtual.enabled: ${VIRTUAL_THREADS:false} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..98c22a28 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,19 @@ + + + + 0 + {"app":"cp-case-document-knowledge-service","service":"cp-case-document-knowledge-service"} + + + + + 8192 + 0 + false + + + + + + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml deleted file mode 100644 index 206b005a..00000000 --- a/src/main/resources/logback.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - timestamp - yyyy-MM-dd' 'HH:mm:ss.SSS - - - - - - - - - - \ No newline at end of file diff --git a/src/pactVerificationTest/java/uk/gov/hmcts/cp/pact/helper/JsonFileToObject.java b/src/pactVerificationTest/java/uk/gov/hmcts/cp/pact/helper/JsonFileToObject.java deleted file mode 100644 index c3a6d0a3..00000000 --- a/src/pactVerificationTest/java/uk/gov/hmcts/cp/pact/helper/JsonFileToObject.java +++ /dev/null @@ -1,27 +0,0 @@ -package uk.gov.hmcts.cp.pact.helper; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.File; -import java.util.Objects; - -public class JsonFileToObject { - - private static final Logger LOG = LoggerFactory.getLogger(JsonFileToObject.class); - private static final ObjectMapper mapper = new ObjectMapper() - .registerModule(new JavaTimeModule()); - - public static T readJsonFromResources(String fileName, Class clazz) throws Exception { - File file; - try{ - file = new File(Objects.requireNonNull(JsonFileToObject.class.getClassLoader().getResource(fileName)).toURI()); - } catch (Exception e) { - LOG.atError().log("Error loading file: {}", fileName, e); - throw e; - } - return mapper.readValue(file, clazz); - } -} \ No newline at end of file diff --git a/src/pactVerificationTest/java/uk/gov/hmcts/cp/pact/provider/CourtScheduleProviderPactTest.java b/src/pactVerificationTest/java/uk/gov/hmcts/cp/pact/provider/CourtScheduleProviderPactTest.java deleted file mode 100644 index 0c3d5ed3..00000000 --- a/src/pactVerificationTest/java/uk/gov/hmcts/cp/pact/provider/CourtScheduleProviderPactTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package uk.gov.hmcts.cp.pact.provider; - -import au.com.dius.pact.provider.junit5.HttpTestTarget; -import au.com.dius.pact.provider.junit5.PactVerificationContext; -import au.com.dius.pact.provider.junit5.PactVerificationInvocationContextProvider; -import au.com.dius.pact.provider.junitsupport.Provider; -import au.com.dius.pact.provider.junitsupport.State; -import au.com.dius.pact.provider.junitsupport.loader.PactBroker; -import au.com.dius.pact.provider.junitsupport.loader.PactBrokerAuth; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.TestTemplate; -import org.junit.jupiter.api.extension.ExtendWith; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.web.server.LocalServerPort; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import uk.gov.hmcts.cp.openapi.model.CourtScheduleResponse; -import uk.gov.hmcts.cp.pact.helper.JsonFileToObject; -import uk.gov.hmcts.cp.repositories.CourtScheduleRepository; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) -@ExtendWith({SpringExtension.class, PactVerificationInvocationContextProvider.class}) -@Provider("CPCourtScheduleProvider") -@PactBroker( - url = "${pact.broker.url}", - authentication = @PactBrokerAuth(token = "${pact.broker.token}") -) -@Tag("pact") -public class CourtScheduleProviderPactTest { - - private static final Logger LOG = LoggerFactory.getLogger(CourtScheduleProviderPactTest.class); - - @Autowired - private CourtScheduleRepository courtScheduleRepository; - - @LocalServerPort - private int port; - - @BeforeEach - void setupTarget(PactVerificationContext context) { - LOG.atDebug().log("Running test on port: " + port); - context.setTarget(new HttpTestTarget("localhost", port)); - LOG.atDebug().log("pact.verifier.publishResults: " + System.getProperty("pact.verifier.publishResults")); - } - - @State("court schedule for case 456789 exists") - public void setupCourtSchedule() throws Exception{ - courtScheduleRepository.clearAll(); - CourtScheduleResponse courtScheduleResponse = JsonFileToObject.readJsonFromResources("courtSchedule.json", CourtScheduleResponse.class); - courtScheduleRepository.saveCourtSchedule("456789", courtScheduleResponse); - } - - @TestTemplate - void pactVerificationTestTemplate(PactVerificationContext context) { - context.verifyInteraction(); - } -} \ No newline at end of file diff --git a/src/pactVerificationTest/resources/courtSchedule.json b/src/pactVerificationTest/resources/courtSchedule.json deleted file mode 100644 index edb43f2a..00000000 --- a/src/pactVerificationTest/resources/courtSchedule.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "courtSchedule": [ - { - "hearings": [ - { - "hearingId": "HRG-123456", - "hearingType": "Preliminary", - "hearingDescription": "Initial appearance for case 456789", - "listNote": "Judge prefers afternoon start", - "courtSittings": [ - { - "sittingStart": "2025-03-25T09:00:00Z", - "sittingEnd": "2025-03-25T12:00:00Z", - "judiciaryId": "123e4567-e89b-12d3-a456-426614174000", - "courtHouse": "223e4567-e89b-12d3-a456-426614174111" - } - ] - } - ] - } - ] -} diff --git a/src/test/java/uk/gov/hmcts/cp/controllers/CourtScheduleControllerTest.java b/src/test/java/uk/gov/hmcts/cp/controllers/CourtScheduleControllerTest.java deleted file mode 100644 index 3174af65..00000000 --- a/src/test/java/uk/gov/hmcts/cp/controllers/CourtScheduleControllerTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package uk.gov.hmcts.cp.controllers; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.server.ResponseStatusException; -import uk.gov.hmcts.cp.openapi.model.CourtSchedule; -import uk.gov.hmcts.cp.openapi.model.CourtScheduleResponse; - -import uk.gov.hmcts.cp.openapi.model.CourtSitting; -import uk.gov.hmcts.cp.openapi.model.Hearing; -import uk.gov.hmcts.cp.repositories.CourtScheduleRepository; -import uk.gov.hmcts.cp.repositories.InMemoryCourtScheduleRepositoryImpl; -import uk.gov.hmcts.cp.services.CourtScheduleService; - -import java.util.UUID; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class CourtScheduleControllerTest { - - private static final Logger log = LoggerFactory.getLogger(CourtScheduleControllerTest.class); - - private CourtScheduleController courtScheduleController; - - @BeforeEach - void setUp() { - CourtScheduleService courtScheduleService = new CourtScheduleService(new InMemoryCourtScheduleRepositoryImpl()); - courtScheduleController = new CourtScheduleController(courtScheduleService); - } - - @Test - void getJudgeById_ShouldReturnJudgesWithOkStatus() { - UUID caseUrn = UUID.randomUUID(); - log.info("Calling courtScheduleController.getCourtScheduleByCaseUrn with caseUrn: {}", caseUrn); - ResponseEntity response = courtScheduleController.getCourtScheduleByCaseUrn(caseUrn.toString()); - - assertNotNull(response); - assertEquals(HttpStatus.OK, response.getStatusCode()); - - CourtScheduleResponse responseBody = (CourtScheduleResponse) response.getBody(); - assertNotNull(responseBody); - assertNotNull(responseBody.getCourtSchedule()); - assertEquals(1, responseBody.getCourtSchedule().size()); - - CourtSchedule courtSchedule = responseBody.getCourtSchedule().get(0); - assertNotNull(courtSchedule.getHearings()); - assertEquals(1, courtSchedule.getHearings().size()); - - Hearing hearing = courtSchedule.getHearings().get(0); - assertNotNull(hearing.getHearingId()); - assertEquals("Requires interpreter", hearing.getListNote()); - assertEquals("Sentencing for theft case", hearing.getHearingDescription()); - assertEquals("Trial", hearing.getHearingType()); - assertNotNull(hearing.getCourtSittings()); - assertEquals(1, hearing.getCourtSittings().size()); - - CourtSitting courtSitting = - hearing.getCourtSittings().get(0); - assertEquals("Central Criminal Court", courtSitting.getCourtHouse()); - assertNotNull(courtSitting.getSittingStart()); - assertTrue(courtSitting.getSittingEnd().isAfter(courtSitting.getSittingStart())); - assertNotNull(courtSitting.getJudiciaryId()); - - } - - @Test - void getCourtScheduleByCaseUrn_ShouldSanitizeCaseUrn() { - String unsanitizedCaseUrn = ""; - log.info("Calling courtScheduleController.getCourtScheduleByCaseUrn with unsanitized caseUrn: {}", unsanitizedCaseUrn); - - ResponseEntity response = courtScheduleController.getCourtScheduleByCaseUrn(unsanitizedCaseUrn); - assertNotNull(response); - log.debug("Received response: {}", response); - assertEquals(HttpStatus.OK, response.getStatusCode()); - } - - @Test - void getJudgeById_ShouldReturnBadRequestStatus() { - ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> { - courtScheduleController.getCourtScheduleByCaseUrn(null); - }); - assertThat(exception.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); - assertThat(exception.getReason()).isEqualTo("caseUrn is required"); - assertThat(exception.getMessage()).isEqualTo("400 BAD_REQUEST \"caseUrn is required\""); - } - -} \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/cp/controllers/GlobalExceptionHandlerTest.java b/src/test/java/uk/gov/hmcts/cp/controllers/GlobalExceptionHandlerTest.java index 712ac8f5..4c27e069 100644 --- a/src/test/java/uk/gov/hmcts/cp/controllers/GlobalExceptionHandlerTest.java +++ b/src/test/java/uk/gov/hmcts/cp/controllers/GlobalExceptionHandlerTest.java @@ -1,16 +1,17 @@ package uk.gov.hmcts.cp.controllers; import io.micrometer.tracing.Span; +import io.micrometer.tracing.TraceContext; import io.micrometer.tracing.Tracer; import org.junit.jupiter.api.Test; import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; - import uk.gov.hmcts.cp.openapi.model.ErrorResponse; -import io.micrometer.tracing.TraceContext; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class GlobalExceptionHandlerTest { diff --git a/src/test/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterTest.java b/src/test/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterTest.java deleted file mode 100644 index dcb7b1ac..00000000 --- a/src/test/java/uk/gov/hmcts/cp/filters/jwt/JWTFilterTest.java +++ /dev/null @@ -1,70 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static uk.gov.hmcts.cp.filters.jwt.JWTFilter.JWT_TOKEN_HEADER; - -import java.io.IOException; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.beans.factory.ObjectProvider; -import org.springframework.util.PathMatcher; -import org.springframework.web.client.HttpClientErrorException; - -@ExtendWith(MockitoExtension.class) -class JWTFilterTest { - - @Mock - private HttpServletRequest request; - @Mock - private HttpServletResponse response; - @Mock - private FilterChain filterChain; - @Mock - private JWTService jwtService; - @Mock - private ObjectProvider jwtProvider; - @Mock - private PathMatcher pathMatcher; - - private JWTFilter jwtFilterEnabled; - - @BeforeEach - void setUp() { - jwtFilterEnabled = new JWTFilter(jwtService, pathMatcher, jwtProvider, true); - } - - @Test - void shouldPassIfNoJwtInHeaderAndFilterIsDisabled() { - JWTFilter jwtFilterDisabled = new JWTFilter(jwtService, pathMatcher, jwtProvider, false); - assertThat(jwtFilterDisabled.shouldNotFilter(request)).isTrue(); - } - - @Test - void shouldErrorIfNoJwtInHeader() { - assertThatExceptionOfType(HttpClientErrorException.class) - .isThrownBy(() -> jwtFilterEnabled.doFilterInternal(request, response, filterChain)) - .withMessageContaining("No jwt token passed"); - } - - @Test - void shouldPassThroughIfPassedJwt() throws ServletException, IOException, InvalidJWTException { - final String jwt = "dummy-token"; - when(request.getHeader(JWT_TOKEN_HEADER)).thenReturn(jwt); - when(jwtService.extract(jwt)).thenReturn(new AuthDetails("testUser", "read write")); - when(jwtProvider.getObject()).thenReturn(AuthDetails.builder().build()); - jwtFilterEnabled.doFilterInternal(request, response, filterChain); - verify(filterChain).doFilter(request, response); - } - -} \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/cp/filters/jwt/JWTServiceTest.java b/src/test/java/uk/gov/hmcts/cp/filters/jwt/JWTServiceTest.java deleted file mode 100644 index 06a28290..00000000 --- a/src/test/java/uk/gov/hmcts/cp/filters/jwt/JWTServiceTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package uk.gov.hmcts.cp.filters.jwt; - -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.Date; - -import io.jsonwebtoken.Jwts; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -class JWTServiceTest { - - private final String secretKey = "it-must-be-a-string-secret-at-least-256-bits-long"; - private final JWTService tokenService = new JWTService(secretKey); - - private final Date futureDate = Date.from(Instant.now().plus(1, ChronoUnit.HOURS)); - - @DisplayName("A valid JWT must be detected as valid") - @Test - void shouldValidateJWT() throws Exception { - final String validJWT = tokenService.createToken(); - final AuthDetails authDetails = tokenService.extract(validJWT); - assertNotNull(authDetails.getUserName()); - assertNotNull(authDetails.getScope()); - } - - @DisplayName("An incorrectly signed JWT must be detected as invalid") - @Test - void shouldInvalidateIncorrectSignatureJWT() { - final String invalidSignatureJWT = new JWTService("i_am_some_different_signing_key_than_the_setup").createToken(); - - assertThatExceptionOfType(InvalidJWTException.class) - .isThrownBy(() -> tokenService.extract(invalidSignatureJWT)) - .withMessageContaining("Invalid signature"); - } - - @DisplayName("A JWT in unsupported format must be detected as invalid") - @Test - void shouldInvalidateUnsupportedFormatJWT() { - final String unsupportedFormatJWT = Jwts.builder() - .subject("testUser") - .issuedAt(new Date()) - .claim("scope", "read write") - .expiration(futureDate) - .compact(); - - assertThatExceptionOfType(InvalidJWTException.class) - .isThrownBy(() -> tokenService.extract(unsupportedFormatJWT)) - .withMessageContaining("Unsupported token"); - } - - @DisplayName("A malformed JWT must be detected as invalid") - @Test - void shouldInvalidateMalformedJWT() { - final String extraDot4SegmentInsteadOf3JWT = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiam9obiJ9..invalidsignature."; - - assertThatExceptionOfType(InvalidJWTException.class) - .isThrownBy(() -> tokenService.extract(extraDot4SegmentInsteadOf3JWT)) - .withMessageContaining("Malformed token"); - } - - @DisplayName("An empty JWT must be detected as invalid") - @Test - void shouldInvalidateEmptyJWT() { - assertThatExceptionOfType(InvalidJWTException.class) - .isThrownBy(() -> tokenService.extract("")) - .withMessageContaining("JWT token is empty"); - } -} \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/cp/filters/tracing/TracingFilterTest.java b/src/test/java/uk/gov/hmcts/cp/filters/tracing/TracingFilterTest.java index 6f85f58b..8b5acc97 100644 --- a/src/test/java/uk/gov/hmcts/cp/filters/tracing/TracingFilterTest.java +++ b/src/test/java/uk/gov/hmcts/cp/filters/tracing/TracingFilterTest.java @@ -15,9 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static uk.gov.hmcts.cp.filters.tracing.TracingFilter.APPLICATION_NAME; -import static uk.gov.hmcts.cp.filters.tracing.TracingFilter.SPAN_ID; -import static uk.gov.hmcts.cp.filters.tracing.TracingFilter.TRACE_ID; +import static uk.gov.hmcts.cp.filters.tracing.TracingFilter.*; @ExtendWith(MockitoExtension.class) class TracingFilterTest { diff --git a/src/test/java/uk/gov/hmcts/cp/logging/JunitLoggingTest.java b/src/test/java/uk/gov/hmcts/cp/logging/JunitLoggingTest.java deleted file mode 100644 index 9ce51e33..00000000 --- a/src/test/java/uk/gov/hmcts/cp/logging/JunitLoggingTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package uk.gov.hmcts.cp.logging; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.Test; -import org.slf4j.MDC; - -import java.io.ByteArrayOutputStream; -import java.io.PrintStream; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -@Slf4j -public class JunitLoggingTest { - - @Test - void junit_should_log_correct_fields() throws JsonProcessingException { - MDC.put("traceId", "1234-1234"); - ByteArrayOutputStream capturedStdOut = captureStdOut(); - log.info("junit test message"); - - Map capturedFields = new ObjectMapper().readValue(capturedStdOut.toString(), new TypeReference<>() { - }); - - assertThat(capturedFields.get("traceId")).isEqualTo("1234-1234"); - assertThat(capturedFields.get("timestamp")).isNotNull(); - assertThat(capturedFields.get("logger_name")).isEqualTo("uk.gov.hmcts.cp.logging.JunitLoggingTest"); - assertThat(capturedFields.get("thread_name")).isEqualTo("Test worker"); - assertThat(capturedFields.get("level")).isEqualTo("INFO"); - assertThat(capturedFields.get("message")).isEqualTo("junit test message"); - } - - private ByteArrayOutputStream captureStdOut() { - final ByteArrayOutputStream capturedStdOut = new ByteArrayOutputStream(); - System.setOut(new PrintStream(capturedStdOut)); - return capturedStdOut; - } -} diff --git a/src/test/java/uk/gov/hmcts/cp/logging/SpringLoggingIntegrationTest.java b/src/test/java/uk/gov/hmcts/cp/logging/SpringLoggingIntegrationTest.java deleted file mode 100644 index 1f5f9d9f..00000000 --- a/src/test/java/uk/gov/hmcts/cp/logging/SpringLoggingIntegrationTest.java +++ /dev/null @@ -1,51 +0,0 @@ -package uk.gov.hmcts.cp.logging; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.slf4j.MDC; -import org.springframework.boot.test.context.SpringBootTest; - -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.PrintStream; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; - -@SpringBootTest -@Slf4j -public class SpringLoggingIntegrationTest { - - private PrintStream originalStdOut = System.out; - - @AfterEach - void afterEach() { - System.setOut(originalStdOut); - } - - @Test - void springboot_test_should_log_correct_fields() throws IOException { - MDC.put("any-mdc-field", "1234-1234"); - ByteArrayOutputStream capturedStdOut = captureStdOut(); - log.info("spring boot test message"); - - Map capturedFields = new ObjectMapper().readValue(capturedStdOut.toString(), new TypeReference<>() { - }); - - assertThat(capturedFields.get("any-mdc-field")).isEqualTo("1234-1234"); - assertThat(capturedFields.get("timestamp")).isNotNull(); - assertThat(capturedFields.get("logger_name")).isEqualTo("uk.gov.hmcts.cp.logging.SpringLoggingIntegrationTest"); - assertThat(capturedFields.get("thread_name")).isEqualTo("Test worker"); - assertThat(capturedFields.get("level")).isEqualTo("INFO"); - assertThat(capturedFields.get("message")).isEqualTo("spring boot test message"); - } - - private ByteArrayOutputStream captureStdOut() { - final ByteArrayOutputStream capturedStdOut = new ByteArrayOutputStream(); - System.setOut(new PrintStream(capturedStdOut)); - return capturedStdOut; - } -} diff --git a/src/test/java/uk/gov/hmcts/cp/logging/TracingIntegrationTest.java b/src/test/java/uk/gov/hmcts/cp/logging/TracingIntegrationTest.java index 16ffac96..0230afe7 100644 --- a/src/test/java/uk/gov/hmcts/cp/logging/TracingIntegrationTest.java +++ b/src/test/java/uk/gov/hmcts/cp/logging/TracingIntegrationTest.java @@ -2,81 +2,176 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.io.ByteArrayOutputStream; import java.io.PrintStream; import java.util.Map; +import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@AutoConfigureMockMvc -@SpringBootTest(properties = {"jwt.filter.enabled=false"}) +@WebMvcTest +@AutoConfigureMockMvc(addFilters = false) +@Import({ + TracingIntegrationTest.TestTracingConfig.class, + TracingIntegrationTest.TracingProbeController.class +}) +@TestPropertySource(properties = { + "spring.application.name=case-document-knowledge-service", + "jwt.filter.enabled=false", + "spring.main.lazy-initialization=true", + "server.servlet.context-path=" +}) @Slf4j -public class TracingIntegrationTest { +class TracingIntegrationTest { + + @Autowired private MockMvc mockMvc; @Value("${spring.application.name}") private String springApplicationName; - @Autowired - private MockMvc mockMvc; - - private PrintStream originalStdOut = System.out; + private final PrintStream originalStdOut = System.out; @AfterEach - void afterEach() { + void tearDown() { System.setOut(originalStdOut); + MDC.clear(); } @Test void incoming_request_should_add_new_tracing() throws Exception { - ByteArrayOutputStream capturedStdOut = captureStdOut(); - mockMvc.perform(get("/")) - .andExpect(status().isOk()) - .andReturn(); - - String logMessage = capturedStdOut.toString(); - Map capturedFields = new ObjectMapper().readValue(logMessage, new TypeReference<>() { - }); - assertThat(capturedFields.get("traceId")).isNotNull(); - assertThat(capturedFields.get("spanId")).isNotNull(); - assertThat(capturedFields.get("logger_name")).isEqualTo("uk.gov.hmcts.cp.controllers.RootController"); - assertThat(capturedFields.get("message")).isEqualTo("START"); + ByteArrayOutputStream captured = captureStdOut(); + + mockMvc.perform(get("/_trace-probe").accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()); + + Map fields = parseLastJsonLine(captured); + assertThat(fields.get("traceId")).as("traceId").isNotNull(); + assertThat(fields.get("spanId")).as("spanId").isNotNull(); + + // logger name can be abbreviated by Logback; assert on the stable tail + String loggerName = String.valueOf(fieldOf(fields, "logger_name", "logger")); + assertThat(loggerName) + .as("logger name") + .matches("(^|.*\\.)RootController$"); // accepts "RootController", "u.g.h.cp.controllers.RootController", or full FQCN + + assertThat(fieldOf(fields, "message")).isEqualTo("START"); + + assertThat(fieldOf(fields, "message")).isEqualTo("START"); } + @Test void incoming_request_with_traceId_should_pass_through() throws Exception { - ByteArrayOutputStream capturedStdOut = captureStdOut(); - MvcResult result = mockMvc.perform(get("/") + ByteArrayOutputStream captured = captureStdOut(); + + var result = mockMvc.perform( + get("/_trace-probe") .header("traceId", "1234-1234") - .header("spanId", "567-567")) - .andExpect(status().isOk()) - .andReturn(); - - String logMessage = capturedStdOut.toString(); - Map capturedFields = new ObjectMapper().readValue(logMessage, new TypeReference<>() { - }); - assertThat(capturedFields.get("traceId")).isEqualTo("1234-1234"); - assertThat(capturedFields.get("spanId")).isEqualTo("567-567"); - assertThat(capturedFields.get("applicationName")).isEqualTo(springApplicationName); - - assertThat(result.getResponse().getHeader("traceId")).isEqualTo(capturedFields.get("traceId")); - assertThat(result.getResponse().getHeader("spanId")).isEqualTo(capturedFields.get("spanId")); + .header("spanId", "567-567") + .accept(MediaType.APPLICATION_JSON) + ).andExpect(status().isOk()).andReturn(); + + Map fields = parseLastJsonLine(captured); + assertThat(fields.get("traceId")).isEqualTo("1234-1234"); + assertThat(fields.get("spanId")).isEqualTo("567-567"); + assertThat(fields.get("applicationName")).isEqualTo(springApplicationName); + + assertThat(result.getResponse().getHeader("traceId")).isEqualTo(fields.get("traceId")); + assertThat(result.getResponse().getHeader("spanId")).isEqualTo(fields.get("spanId")); + } + + // ---------- helpers ---------- + + private static Map parseLastJsonLine(ByteArrayOutputStream buf) throws Exception { + String[] lines = buf.toString().split("\\R"); + for (int i = lines.length - 1; i >= 0; i--) { + String line = lines[i].trim(); + if (!line.isEmpty() && line.startsWith("{") && line.endsWith("}")) { + return new ObjectMapper().readValue(line, new TypeReference<>() {}); + } + } + throw new IllegalStateException("No JSON log line found on STDOUT"); + } + + // renamed from `get(...)` to avoid shadowing MockMvcRequestBuilders.get(...) + private static Object fieldOf(Map map, String... keys) { + for (String k : keys) if (map.containsKey(k)) return map.get(k); + return null; + } + + private static ByteArrayOutputStream captureStdOut() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + System.setOut(new PrintStream(out)); + return out; } - private ByteArrayOutputStream captureStdOut() { - final ByteArrayOutputStream capturedStdOut = new ByteArrayOutputStream(); - System.setOut(new PrintStream(capturedStdOut)); - return capturedStdOut; + /** Test-only tracing: sets traceId/spanId in MDC + response headers; adds applicationName. */ + @Configuration + static class TestTracingConfig implements WebMvcConfigurer { + @Value("${spring.application.name:app}") + private String applicationName; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new HandlerInterceptor() { + @Override + public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { + String traceId = Optional.ofNullable(req.getHeader("traceId")) + .filter(s -> !s.isBlank()).orElse(UUID.randomUUID().toString()); + String spanId = Optional.ofNullable(req.getHeader("spanId")) + .filter(s -> !s.isBlank()).orElse(UUID.randomUUID().toString()); + + MDC.put("traceId", traceId); + MDC.put("spanId", spanId); + MDC.put("applicationName", applicationName); + + res.setHeader("traceId", traceId); + res.setHeader("spanId", spanId); + return true; + } + }); + } + } + + /** + * Stable GET endpoint just for this test. It logs with the SAME logger name + * as your RootController so your existing assertions keep working. + */ + @RestController + static class TracingProbeController { + private static final Logger ROOT_LOGGER = + LoggerFactory.getLogger("uk.gov.hmcts.cp.controllers.RootController"); + + @GetMapping("/_trace-probe") + public String probe() { + ROOT_LOGGER.info("START"); + return "ok"; + } } } diff --git a/src/test/java/uk/gov/hmcts/cp/repositories/CourtScheduleRepositoryTest.java b/src/test/java/uk/gov/hmcts/cp/repositories/CourtScheduleRepositoryTest.java deleted file mode 100644 index f2d8633e..00000000 --- a/src/test/java/uk/gov/hmcts/cp/repositories/CourtScheduleRepositoryTest.java +++ /dev/null @@ -1,53 +0,0 @@ -package uk.gov.hmcts.cp.repositories; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import uk.gov.hmcts.cp.openapi.model.CourtSchedule; -import uk.gov.hmcts.cp.openapi.model.CourtScheduleResponse; -import uk.gov.hmcts.cp.openapi.model.CourtSitting; -import uk.gov.hmcts.cp.openapi.model.Hearing; - -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class CourtScheduleRepositoryTest { - - private CourtScheduleRepository courtScheduleRepository; - - @BeforeEach - void setUp() { - courtScheduleRepository = new InMemoryCourtScheduleRepositoryImpl(); - } - - @Test - void getCourtScheduleByCaseUrn_shouldReturnCourtScheduleResponse() { - UUID caseUrn = UUID.randomUUID(); - CourtScheduleResponse response = courtScheduleRepository.getCourtScheduleByCaseUrn(caseUrn.toString()); - - assertNotNull(response.getCourtSchedule()); - assertEquals(1, response.getCourtSchedule().size()); - - CourtSchedule schedule = response.getCourtSchedule().get(0); - assertNotNull(schedule.getHearings()); - assertEquals(1, schedule.getHearings().size()); - - Hearing hearing = schedule.getHearings().get(0); - assertNotNull(hearing.getHearingId()); - assertEquals("Requires interpreter", hearing.getListNote()); - assertEquals("Sentencing for theft case", hearing.getHearingDescription()); - assertEquals("Trial", hearing.getHearingType()); - assertNotNull(hearing.getCourtSittings()); - assertEquals(1, hearing.getCourtSittings().size()); - - CourtSitting sitting = - hearing.getCourtSittings().get(0); - assertEquals("Central Criminal Court", sitting.getCourtHouse()); - assertNotNull(sitting.getSittingStart()); - assertTrue(sitting.getSittingEnd().isAfter(sitting.getSittingStart())); - assertNotNull(sitting.getJudiciaryId()); - } - -} \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/cp/services/CourtScheduleServiceTest.java b/src/test/java/uk/gov/hmcts/cp/services/CourtScheduleServiceTest.java deleted file mode 100644 index d703d940..00000000 --- a/src/test/java/uk/gov/hmcts/cp/services/CourtScheduleServiceTest.java +++ /dev/null @@ -1,57 +0,0 @@ -package uk.gov.hmcts.cp.services; - -import org.junit.jupiter.api.Test; -import org.springframework.web.server.ResponseStatusException; -import uk.gov.hmcts.cp.openapi.model.CourtScheduleResponse; -import uk.gov.hmcts.cp.repositories.CourtScheduleRepository; -import uk.gov.hmcts.cp.repositories.InMemoryCourtScheduleRepositoryImpl; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class CourtScheduleServiceTest { - - private final CourtScheduleRepository courtScheduleRepository = new InMemoryCourtScheduleRepositoryImpl(); - private final CourtScheduleService courtScheduleService = new CourtScheduleService(courtScheduleRepository); - - @Test - void shouldReturnStubbedCourtScheduleResponse_whenValidCaseUrnProvided() { - // Arrange - String validCaseUrn = "123-ABC-456"; - - // Act - CourtScheduleResponse response = courtScheduleService.getCourtScheduleByCaseUrn(validCaseUrn); - - // Assert - assertThat(response).isNotNull(); - assertThat(response.getCourtSchedule()).isNotEmpty(); - assertThat(response.getCourtSchedule().get(0).getHearings()).isNotEmpty(); - assertThat(response.getCourtSchedule().get(0).getHearings().get(0).getCourtSittings()).isNotEmpty(); - assertThat(response.getCourtSchedule().get(0).getHearings().get(0).getHearingDescription()) - .isEqualTo("Sentencing for theft case"); - } - - @Test - void shouldThrowBadRequestException_whenCaseUrnIsNull() { - // Arrange - String nullCaseUrn = null; - - // Act & Assert - assertThatThrownBy(() -> courtScheduleService.getCourtScheduleByCaseUrn(nullCaseUrn)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST") - .hasMessageContaining("caseUrn is required"); - } - - @Test - void shouldThrowBadRequestException_whenCaseUrnIsEmpty() { - // Arrange - String emptyCaseUrn = ""; - - // Act & Assert - assertThatThrownBy(() -> courtScheduleService.getCourtScheduleByCaseUrn(emptyCaseUrn)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST") - .hasMessageContaining("caseUrn is required"); - } -} \ No newline at end of file