diff --git a/.devops/code-review-pipelines.yml b/.devops/code-review-pipelines.yml new file mode 100644 index 0000000..bb04ac4 --- /dev/null +++ b/.devops/code-review-pipelines.yml @@ -0,0 +1,79 @@ +# Build your Java project and run tests with Apache Maven. +# Add steps that analyze code, save build artifacts, deploy, and more: +# https://docs.microsoft.com/azure/devops/pipelines/languages/java + +trigger: + - main + +pool: + #vmImage: 'ubuntu-latest' + vmImage: ubuntu-22.04 + +#variables: +# MAVEN_CACHE_FOLDER: $(Pipeline.Workspace)/.m2/repository +# MAVEN_OPTS: '-Dmaven.repo.local=$(MAVEN_CACHE_FOLDER)' + +steps: + # - task: Cache@2 + # inputs: + # key: 'maven | "$(Agent.OS)" | pom.xml' + # restoreKeys: | + # maven | "$(Agent.OS)" + # maven + # path: $(MAVEN_CACHE_FOLDER) + # displayName: Cache Maven local repo + + - task: SonarCloudPrepare@1 + displayName: 'Prepare SonarCloud analysis configuration' + inputs: + SonarCloud: '$(SONARCLOUD_SERVICE_CONN)' + organization: '$(SONARCLOUD_ORG)' + scannerMode: Other + extraProperties: | + sonar.projectKey=$(SONARCLOUD_PROJECT_KEY) + sonar.projectName=$(SONARCLOUD_PROJECT_NAME) + sonar.exclusions='**/enums/**, **/model/**, **/stub/**, **/dto/**, **/*Constant*, **/*Config.java, **/*Scheduler.java, **/*Application.java, **/src/test/**, **/Dummy*.java' + + # - task: DownloadSecureFile@1 + # displayName: 'download settings.xml for Maven' + # name: settingsxml + # inputs: + # secureFile: '$(SETTINGS_XML_RO_SECURE_FILE_NAME)' + # retryCount: '2' + + # options: '-B -s $(settingsxml.secureFilePath)' + - task: Maven@3 + inputs: + mavenPomFile: 'pom.xml' + goals: 'clean org.jacoco:jacoco-maven-plugin:0.8.8:prepare-agent verify org.jacoco:jacoco-maven-plugin:0.8.8:report org.jacoco:jacoco-maven-plugin:0.8.8:report-aggregate ' + options: '-B' + publishJUnitResults: true + testResultsFiles: '**/surefire-reports/TEST-*.xml' + javaHomeOption: 'JDKVersion' + jdkVersionOption: '1.17' + mavenVersionOption: 'Default' + mavenAuthenticateFeed: false + effectivePomSkip: false + sonarQubeRunAnalysis: false + - bash: xmlReportPaths=$(find "$(pwd)" -path '*jacoco.xml' | sed 's/.*/&/' | tr '\n' ','); echo "##vso[task.setvariable variable=xmlReportPaths]$xmlReportPaths" + displayName: finding jacoco.xml + + # options: '-B -s $(settingsxml.secureFilePath) -Dsonar.coverage.jacoco.xmlReportPaths=$(xmlReportPaths)' + + - task: Maven@3 + inputs: + mavenPomFile: 'pom.xml' + goals: 'sonar:sonar' + options: '-B -Dsonar.coverage.jacoco.xmlReportPaths=$(xmlReportPaths)' + publishJUnitResults: false + javaHomeOption: 'JDKVersion' + jdkVersionOption: '1.17' + mavenVersionOption: 'Default' + mavenAuthenticateFeed: false + effectivePomSkip: false + sonarQubeRunAnalysis: true + isJacocoCoverageReportXML: false + sqMavenPluginVersionChoice: 'latest' + - task: SonarCloudPublish@1 + inputs: + pollingTimeoutSec: '300' \ No newline at end of file diff --git a/.devops/deploy-pipelines.yml b/.devops/deploy-pipelines.yml new file mode 100644 index 0000000..d27c14c --- /dev/null +++ b/.devops/deploy-pipelines.yml @@ -0,0 +1,192 @@ +# Build and push image to Azure Container Registry; Deploy to Azure Kubernetes Service +# https://docs.microsoft.com/azure/devops/pipelines/languages/docker + +parameters: + - name: 'executeBuild' + displayName: 'Launch maven and docker build' + type: boolean + default: true + +trigger: + branches: + include: + - release-* + - main + paths: + include: + - src/* + - helm/* + - pom.xml + - Dockerfile + +pr: none + +resources: + - repo: self + +variables: + + # vmImageNameDefault: 'ubuntu-latest' + vmImageNameDefault: ubuntu-22.04 + + imageRepository: '$(K8S_IMAGE_REPOSITORY_NAME)' + deployNamespace: '$(DEPLOY_NAMESPACE)' + helmReleaseName : '$(HELM_RELEASE_NAME)' + settingsXmlROsecureFileName: '$(SETTINGS_XML_RO_SECURE_FILE_NAME)' + settingsXmlSecureFileName: '$(SETTINGS_XML_RO_SECURE_FILE_NAME)' + canDeploy: true + + # If the branch is develop or a feature branch starting with CEN, deploy in DEV environment + ${{ if startsWith(variables['Build.SourceBranch'], 'refs/heads/release-dev') }}: + environment: 'DEV' + dockerRegistryServiceConnection: '$(DEV_CONTAINER_REGISTRY_SERVICE_CONN)' + kubernetesServiceConnection: '$(DEV_KUBERNETES_SERVICE_CONN)' + containerRegistry: '$(DEV_CONTAINER_REGISTRY_NAME)' + selfHostedAgentPool: $(DEV_AGENT_POOL) + + ${{ elseif startsWith(variables['Build.SourceBranch'], 'refs/heads/release-uat') }}: + environment: 'UAT' + dockerRegistryServiceConnection: '$(UAT_CONTAINER_REGISTRY_SERVICE_CONN)' + kubernetesServiceConnection: '$(UAT_KUBERNETES_SERVICE_CONN)' + containerRegistry: '$(UAT_CONTAINER_REGISTRY_NAME)' + selfHostedAgentPool: $(UAT_AGENT_POOL) + + ${{ elseif or(eq(variables['Build.SourceBranch'], 'refs/heads/main'),eq(variables['Build.SourceBranch'], 'refs/heads/release-prod')) }}: + environment: 'PROD' + dockerRegistryServiceConnection: '$(PROD_CONTAINER_REGISTRY_SERVICE_CONN)' + kubernetesServiceConnection: '$(PROD_KUBERNETES_SERVICE_CONN)' + containerRegistry: '$(PROD_CONTAINER_REGISTRY_NAME)' + selfHostedAgentPool: $(PROD_AGENT_POOL) + + ${{ else }}: + environment: 'DEV' + dockerRegistryServiceConnection: '$(DEV_CONTAINER_REGISTRY_SERVICE_CONN)' + kubernetesServiceConnection: '$(DEV_KUBERNETES_SERVICE_CONN)' + containerRegistry: '$(DEV_CONTAINER_REGISTRY_NAME)' + selfHostedAgentPool: $(DEV_AGENT_POOL) + +stages: + - stage: 'pom_version' + displayName: Release + condition: eq(variables.canDeploy, true) + jobs: + - job: POM + displayName: POM + pool: + vmImage: $(vmImageNameDefault) + steps: + - task: Bash@3 + displayName: Get POM version + name: getpomversion + condition: and(succeeded(), eq(variables.canDeploy, true)) + inputs: + targetType: 'inline' + script: | + version=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "##vso[task.setvariable variable=outputpomversion;isOutput=true]$version" + failOnStderr: true + + - stage: 'build' + displayName: 'Build_and_Publish_to_${{ variables.environment }}' + dependsOn: 'pom_version' + variables: + pomversion: $[ stageDependencies.Release.POM.outputs['getpomversion.outputpomversion'] ] + jobs: + - job: Build + displayName: Build + pool: + vmImage: $(vmImageNameDefault) + steps: + - task: Docker@2 + condition: and(succeeded(), ${{ parameters.executeBuild }}) + displayName: 'Publish_image_to_${{ variables.environment }}' + inputs: + containerRegistry: '$(dockerRegistryServiceConnection)' + repository: '$(imageRepository)' + command: 'buildAndPush' + tags: | + $(Build.BuildId) + latest + $(pomversion) + # - task: PublishPipelineArtifact@1 + # displayName: 'Publish Artifact manifests' + # condition: and(succeeded(), eq(variables.canDeploy, true)) + # inputs: + # targetPath: '$(Build.Repository.LocalPath)/manifests' + # artifact: 'manifests' + # publishLocation: 'pipeline' + + - stage: 'publish_artifact_helm' + displayName: 'Publish_artifact_Helm' + dependsOn: ['build'] + jobs: + - job: Publish_artifact_helm + displayName: Publish_artifact_helm + pool: + vmImage: $(vmImageNameDefault) + steps: + - task: PublishPipelineArtifact@1 + displayName: 'Publish Artifact manifests' + condition: succeeded() + inputs: + targetPath: '$(Build.Repository.LocalPath)/helm' + artifact: 'helm' + publishLocation: 'pipeline' + + - stage: 'deploy' + displayName: 'Deploy to ${{ variables.environment }} K8S' + dependsOn: ['publish_artifact_helm'] + condition: and(succeeded(), eq(variables.canDeploy, true)) + variables: + pomversion: $[ stageDependencies.Release.POM.outputs['getpomversion.outputpomversion'] ] + jobs: + - deployment: 'Deploy_to_${{ variables.environment }}' + displayName: 'Deploy to ${{ variables.environment }} K8S' + pool: + name: $(selfHostedAgentPool) + environment: '$(environment)' + strategy: + runOnce: + deploy: + steps: + - download: none + - task: DownloadPipelineArtifact@2 + inputs: + buildType: 'current' + artifactName: 'helm' + targetPath: '$(Pipeline.Workspace)/helm' + - task: KubectlInstaller@0 + - task: Bash@3 + name: helm_dependency_build + displayName: Helm dependency build + inputs: + workingDirectory: '$(Pipeline.Workspace)/helm' + targetType: 'inline' + script: | + helm repo add pagopa-microservice https://pagopa.github.io/aks-microservice-chart-blueprint + helm dep build + failOnStderr: true + - task: HelmDeploy@0 + displayName: Helm upgrade + inputs: + kubernetesServiceEndpoint: ${{ variables.kubernetesServiceConnection }} + namespace: '$(deployNamespace)' + command: upgrade + chartType: filepath + chartPath: $(Pipeline.Workspace)/helm + chartName: ${{ variables.helmReleaseName }} + releaseName: ${{ variables.helmReleaseName }} + valueFile: "$(Pipeline.Workspace)/helm/values-${{ lower(variables.environment) }}.yaml" + install: true + waitForExecution: true + arguments: "--timeout 5m0s --debug" + - task: KubernetesManifest@0 + displayName: Patch + inputs: + kubernetesServiceConnection: ${{ variables.kubernetesServiceConnection }} + namespace: '$(deployNamespace)' + action: patch + kind: deployment + name: '$(helmReleaseName)-microservice-chart' + mergeStrategy: strategic + patch: '{"spec":{"template":{"metadata":{"annotations":{"buildNumber":"$(Build.BuildNumber)"}}}}}' \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..70a11e8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + + + + +### List of changes + + + +### Motivation and context + + + +### Type of changes + +- [ ] Add new feature +- [ ] Update existing feature +- [ ] Remove existing feature +- [ ] Other changes + +### Does this introduce a breaking change? + +- [ ] Yes +- [ ] No + +### Other information + + diff --git a/.github/workflows/code-review.yml b/.github/workflows/code-review.yml new file mode 100644 index 0000000..1b9b9b7 --- /dev/null +++ b/.github/workflows/code-review.yml @@ -0,0 +1,61 @@ +name: SonarCloud Analysis + +on: + push: + branches: + - main + - release-* + pull_request: + types: + - opened + - edited + - synchronize + +jobs: + sonarcloud: + name: SonarCloud Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4.1.7 + with: + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Set up JDK 17 + uses: actions/setup-java@v4.2.1 + with: + distribution: 'adopt' + java-version: '17' + + - name: Cache Maven packages + uses: actions/cache@v3 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Build and test with Maven + run: mvn clean org.jacoco:jacoco-maven-plugin:0.8.8:prepare-agent verify org.jacoco:jacoco-maven-plugin:0.8.8:report org.jacoco:jacoco-maven-plugin:0.8.8:report-aggregate -B + + - name: Generate JaCoCo XML Report + run: mvn org.jacoco:jacoco-maven-plugin:0.8.8:report -Djacoco.reportFormat=xml -B + + - name: SonarCloud Scan + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + mvn sonar:sonar \ + -Dsonar.projectKey=${{ vars.SONARCLOUD_PROJECT_KEY }} \ + -Dsonar.organization=${{ vars.SONARCLOUD_ORG }} \ + -Dsonar.host.url=https://sonarcloud.io \ + -Dsonar.token=${{ secrets.SONAR_TOKEN }} \ + -Dsonar.java.binaries=target/classes \ + -Dsonar.junit.reportPaths=target/surefire-reports \ + -Dsonar.coverage.jacoco.xmlReportPaths=target/site/jacoco/jacoco.xml \ + -Dsonar.exclusions=**/configuration/**,**/enums/**,**/model/**,**/stub/**,**/dto/**,**/*Constant*,**/*Config.java,**/*Scheduler.java,**/*Application.java,**/src/test/**,**/Dummy*.java + + - name: Fetch all branches + run: git fetch --all diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml new file mode 100644 index 0000000..df61c59 --- /dev/null +++ b/.github/workflows/pr-title.yml @@ -0,0 +1,56 @@ +name: "Validate PR title" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-22.04 + steps: + # Please look up the latest version from + # https://github.com/amannn/action-semantic-pull-request/releases + # from https://github.com/amannn/action-semantic-pull-request/commits/main + - uses: amannn/action-semantic-pull-request@01d5fd8a8ebb9aafe902c40c53f0f4744f7381eb + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + # Configure which types are allowed. + # Default: https://github.com/commitizen/conventional-commit-types + types: | + fix + feat + docs + chore + breaking + # Configure that a scope must always be provided. + requireScope: false + # Configure additional validation for the subject based on a regex. + # This example ensures the subject starts with an uppercase character. + subjectPattern: ^.+$ + # If `subjectPattern` is configured, you can use this property to override + # the default error message that is shown when the pattern doesn't match. + # The variables `subject` and `title` can be used within the message. + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + starts with an uppercase character. + # For work-in-progress PRs you can typically use draft pull requests + # from Github. However, private repositories on the free plan don't have + # this option and therefore this action allows you to opt-in to using the + # special "[WIP]" prefix to indicate this state. This will avoid the + # validation of the PR title and the pull request checks remain pending. + # Note that a second check will be reported if this is enabled. + wip: true + # When using "Squash and merge" on a PR with only one commit, GitHub + # will suggest using that commit message instead of the PR title for the + # merge commit, and it's easy to commit this by mistake. Enable this option + # to also validate the commit message for one commit PRs. + validateSingleCommit: false + # Related to `validateSingleCommit` you can opt-in to validate that the PR + # title matches a single commit to avoid confusion. + validateSingleCommitMatchesPrTitle: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..82277ad --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,25 @@ +name: Release + +on: + # Trigger the workflow on push on the main branch + push: + branches: + - main + paths-ignore: + - 'CODEOWNERS' + - '**.md' + - '.**' + +jobs: + release: + name: Release + runs-on: ubuntu-22.04 + + steps: + + - name: 🚀 Release with docker action + id: release + uses: pagopa/eng-github-actions-iac-template/global/release-with-docker@main # + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/snapshot-docker.yml b/.github/workflows/snapshot-docker.yml new file mode 100644 index 0000000..3496adf --- /dev/null +++ b/.github/workflows/snapshot-docker.yml @@ -0,0 +1,23 @@ +name: Snapshot docker build and push + +on: + push: + # Sequence of patterns matched against refs/heads + branches-ignore: + - 'main' + paths-ignore: + - 'CODEOWNERS' + - '**.md' + - '.**' + +jobs: + release: + name: Snapshot Docker + runs-on: ubuntu-22.04 + + steps: + - name: 📦 Docker build and push + id: release + uses: pagopa/eng-github-actions-iac-template/global/docker-build-push@main # + with: + github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml new file mode 100644 index 0000000..e039c2b --- /dev/null +++ b/.github/workflows/trivy.yml @@ -0,0 +1,52 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Docker security scan + +on: + push: + branches: [ "main", "master" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main", "master" ] + schedule: + - cron: '00 07 * * *' + +permissions: + contents: read + +jobs: + build: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + name: Build + runs-on: ubuntu-22.04 + steps: + - name: Checkout code + # from https://github.com/actions/checkout/commits/main + uses: actions/checkout@1f9a0c22da41e6ebfa534300ef656657ea2c6707 + + - name: Build an image from Dockerfile + run: | + docker build -t docker.io/my-organization/my-app:${{ github.sha }} . + + - name: Run Trivy vulnerability scanner + # from https://github.com/aquasecurity/trivy-action/commits/master + uses: aquasecurity/trivy-action@d63413b0a4a4482237085319f7f4a1ce99a8f2ac + with: + image-ref: 'docker.io/my-organization/my-app:${{ github.sha }}' + format: 'template' + template: '@/contrib/sarif.tpl' + output: 'trivy-results.sarif' + severity: 'CRITICAL,HIGH' + timeout: '10m0s' + + - name: Upload Trivy scan results to GitHub Security tab + # from https://github.com/github/codeql-action/commits/main + uses: github/codeql-action/upload-sarif@f0a12816612c7306b485a22cb164feb43c6df818 + with: + sarif_file: 'trivy-results.sarif' \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..549e00a --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..8f96f52 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.7/apache-maven-3.9.7-bin.zip diff --git a/.spectral.yaml b/.spectral.yaml new file mode 100644 index 0000000..b950826 --- /dev/null +++ b/.spectral.yaml @@ -0,0 +1,19 @@ +extends: + - "spectral:oas" + - "spectral:asyncapi" + - "https://unpkg.com/@stoplight/spectral-owasp-ruleset/dist/ruleset.mjs" +overrides: + - files: + - "src/main/resources/META-INF/openapi.yaml#/paths/~1token/post/security" + rules: + owasp:api2:2023-write-restricted: "off" + - files: + - "src/main/resources/META-INF/openapi.yaml#/paths/~1.well-known~1jwks.json/get/security" + - "src/main/resources/META-INF/openapi.yaml#/paths/~1.well-known~1openid-configuration/get/security" + rules: + owasp:api2:2023-read-restricted: "off" + - files: + - "src/main/resources/META-INF/openapi.yaml" + rules: + owasp:api3:2023-no-additionalProperties: "off" + owasp:api3:2023-constrained-additionalProperties: "off" \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e4b8047 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# +# Build +# +FROM maven:3.9.6-amazoncorretto-17-al2023@sha256:459be099faa25a32c06cd45ed1ef2bc9dbbf8a5414da4e72349459a1bb4d6166 AS buildtime + +WORKDIR /build +COPY . . + +RUN mvn clean package -DskipTests + +# +# Docker RUNTIME +# +FROM amazoncorretto:17-alpine3.20@sha256:1b1d0653d890ff313a1f7afadd1fd81f5ea742c9c48670d483b1bbccef98bb8b AS runtime + +RUN apk --no-cache add shadow +RUN useradd --uid 10000 runner + +VOLUME /tmp +WORKDIR /app + +COPY --from=buildtime /build/target/*.jar /app/app.jar +# The agent is enabled at runtime via JAVA_TOOL_OPTIONS. +ADD https://github.com/microsoft/ApplicationInsights-Java/releases/download/3.6.1/applicationinsights-agent-3.6.1.jar /app/applicationinsights-agent.jar + +RUN chown -R runner:runner /app + +USER 10000 + +ENTRYPOINT ["java","-jar","/app/app.jar"] diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..d7c358e --- /dev/null +++ b/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..6f779cf --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..1913399 --- /dev/null +++ b/pom.xml @@ -0,0 +1,137 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.3 + + + it.gov.pagopa.citizen + emd-citizen + emd-citizen + Citizen Microservice + 0.0.1-SNAPSHOT + + + 17 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-webflux + + + org.springframework.boot + spring-boot-starter-data-mongodb-reactive + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.azure.spring + spring-cloud-azure-starter-data-cosmos + + + org.springframework.cloud + spring-cloud-stream-test-support + test + + + org.springframework.cloud + spring-cloud-starter-stream-kafka + + + + + org.projectlombok + lombok + 1.18.30 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + io.projectreactor + reactor-test + test + + + com.squareup.okhttp3 + okhttp + test + + + com.squareup.okhttp3 + mockwebserver + test + + + + + + + com.azure.spring + spring-cloud-azure-dependencies + 5.16.0 + pom + import + + + org.springframework.cloud + spring-cloud-dependencies + 2023.0.2 + pom + import + + + org.xmlunit + xmlunit-core + 2.10.0 + test + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/src/main/java/it/gov/pagopa/common/configuration/CustomReactiveMongoHealthIndicator.java b/src/main/java/it/gov/pagopa/common/configuration/CustomReactiveMongoHealthIndicator.java new file mode 100644 index 0000000..d713e4e --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/configuration/CustomReactiveMongoHealthIndicator.java @@ -0,0 +1,28 @@ +package it.gov.pagopa.common.configuration; + +import org.bson.Document; +import org.springframework.boot.actuate.health.AbstractReactiveHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.util.Assert; +import reactor.core.publisher.Mono; + +public class CustomReactiveMongoHealthIndicator extends AbstractReactiveHealthIndicator { + + private final ReactiveMongoTemplate reactiveMongoTemplate; + + public CustomReactiveMongoHealthIndicator(ReactiveMongoTemplate reactiveMongoTemplate) { + super("Mongo health check failed"); + Assert.notNull(reactiveMongoTemplate, "ReactiveMongoTemplate must not be null"); + this.reactiveMongoTemplate = reactiveMongoTemplate; + } + + @Override + protected Mono doHealthCheck(Health.Builder builder) { + Mono buildInfo = this.reactiveMongoTemplate.executeCommand("{ isMaster: 1 }"); + return buildInfo.map(document -> builderUp(builder, document)); + } + private Health builderUp(Health.Builder builder, Document document) { + return builder.up().withDetail("maxWireVersion", document.getInteger("maxWireVersion")).build(); + } +} diff --git a/src/main/java/it/gov/pagopa/common/configuration/MongoConfig.java b/src/main/java/it/gov/pagopa/common/configuration/MongoConfig.java new file mode 100644 index 0000000..259f24e --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/configuration/MongoConfig.java @@ -0,0 +1,109 @@ +package it.gov.pagopa.common.configuration; + +import com.mongodb.lang.NonNull; +import it.gov.pagopa.common.utils.CommonConstants; +import lombok.Getter; +import lombok.Setter; +import org.bson.types.Decimal128; +import org.springframework.boot.autoconfigure.mongo.MongoClientSettingsBuilderCustomizer; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.convert.converter.Converter; +import org.springframework.data.convert.ReadingConverter; +import org.springframework.data.convert.WritingConverter; +import org.springframework.data.mongodb.core.convert.MongoCustomConversions; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableConfigurationProperties(MongoConfig.MongoDbCustomProperties.class) +public class MongoConfig { + + @ConfigurationProperties(prefix = "spring.data.mongodb.config") + @Getter + @Setter + public static class MongoDbCustomProperties { + + ConnectionPoolSettings connectionPool; + + @Getter + @Setter + public static class ConnectionPoolSettings { + int maxSize; + int minSize; + long maxWaitTimeMS; + long maxConnectionLifeTimeMS; + long maxConnectionIdleTimeMS; + int maxConnecting; + } + + } + + @Bean + public MongoClientSettingsBuilderCustomizer customizer(MongoDbCustomProperties mongoDbCustomProperties) { + return builder -> builder.applyToConnectionPoolSettings( + connectionPool -> { + connectionPool.maxSize(mongoDbCustomProperties.connectionPool.maxSize); + connectionPool.minSize(mongoDbCustomProperties.connectionPool.minSize); + connectionPool.maxWaitTime(mongoDbCustomProperties.connectionPool.maxWaitTimeMS, TimeUnit.MILLISECONDS); + connectionPool.maxConnectionLifeTime(mongoDbCustomProperties.connectionPool.maxConnectionLifeTimeMS, TimeUnit.MILLISECONDS); + connectionPool.maxConnectionIdleTime(mongoDbCustomProperties.connectionPool.maxConnectionIdleTimeMS, TimeUnit.MILLISECONDS); + connectionPool.maxConnecting(mongoDbCustomProperties.connectionPool.maxConnecting); + }); + } + + @Bean + public MongoCustomConversions mongoCustomConversions() { + return new MongoCustomConversions(Arrays.asList( + // BigDecimal support + new BigDecimalDecimal128Converter(), + new Decimal128BigDecimalConverter(), + + // OffsetDateTime support + new OffsetDateTimeWriteConverter(), + new OffsetDateTimeReadConverter() + )); + } + + @WritingConverter + public static class BigDecimalDecimal128Converter implements Converter { + + @Override + public Decimal128 convert(@NonNull BigDecimal source) { + return new Decimal128(source); + } + } + + @ReadingConverter + public static class Decimal128BigDecimalConverter implements Converter { + + @Override + public BigDecimal convert(@NonNull Decimal128 source) { + return source.bigDecimalValue(); + } + + } + + @WritingConverter + public static class OffsetDateTimeWriteConverter implements Converter { + @Override + public Date convert(OffsetDateTime offsetDateTime) { + return Date.from(offsetDateTime.toInstant()); + } + } + + @ReadingConverter + public static class OffsetDateTimeReadConverter implements Converter { + @Override + public OffsetDateTime convert(Date date) { + return date.toInstant().atZone(CommonConstants.ZONEID).toOffsetDateTime(); + } + } +} + diff --git a/src/main/java/it/gov/pagopa/common/configuration/MongoHealthConfig.java b/src/main/java/it/gov/pagopa/common/configuration/MongoHealthConfig.java new file mode 100644 index 0000000..d046ec2 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/configuration/MongoHealthConfig.java @@ -0,0 +1,14 @@ +package it.gov.pagopa.common.configuration; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; + +@Configuration +public class MongoHealthConfig { + @Bean + public CustomReactiveMongoHealthIndicator customMongoHealthIndicator(ReactiveMongoTemplate reactiveMongoTemplate) { + return new CustomReactiveMongoHealthIndicator(reactiveMongoTemplate); + } +} + diff --git a/src/main/java/it/gov/pagopa/common/kafka/utils/KafkaConstants.java b/src/main/java/it/gov/pagopa/common/kafka/utils/KafkaConstants.java new file mode 100644 index 0000000..6f847ed --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/kafka/utils/KafkaConstants.java @@ -0,0 +1,17 @@ +package it.gov.pagopa.common.kafka.utils; + +public class KafkaConstants { + private KafkaConstants(){} + +//region error-topic headers + public static final String ERROR_MSG_HEADER_APPLICATION_NAME = "applicationName"; + public static final String ERROR_MSG_HEADER_GROUP = "group"; + public static final String ERROR_MSG_HEADER_SRC_TYPE = "srcType"; + public static final String ERROR_MSG_HEADER_SRC_SERVER = "srcServer"; + public static final String ERROR_MSG_HEADER_SRC_TOPIC = "srcTopic"; + public static final String ERROR_MSG_HEADER_DESCRIPTION = "description"; + public static final String ERROR_MSG_HEADER_RETRY = "retry"; + public static final String ERROR_MSG_HEADER_RETRYABLE = "retryable"; + public static final String ERROR_MSG_HEADER_STACKTRACE = "stacktrace"; +//endregion +} diff --git a/src/main/java/it/gov/pagopa/common/reactive/kafka/consumer/BaseKafkaConsumer.java b/src/main/java/it/gov/pagopa/common/reactive/kafka/consumer/BaseKafkaConsumer.java new file mode 100644 index 0000000..bfe2115 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/reactive/kafka/consumer/BaseKafkaConsumer.java @@ -0,0 +1,178 @@ +package it.gov.pagopa.common.reactive.kafka.consumer; + +import com.fasterxml.jackson.databind.ObjectReader; +import it.gov.pagopa.common.kafka.utils.KafkaConstants; +import it.gov.pagopa.common.reactive.kafka.exception.UncommittableError; +import it.gov.pagopa.common.reactive.utils.PerformanceLogger; +import it.gov.pagopa.common.utils.CommonUtilities; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.tuple.Pair; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.Message; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.*; +import java.util.function.Consumer; +import java.util.stream.Collector; +import java.util.stream.Collectors; + +/** + * Base class to extend in order to configure a timed commit behavior when using KafkaBinder. + * Other than extend this class, you should: + *
    + *
  1. Turn off the autoCommit (spring.cloud.stream.kafka.bindings.BINDINGNAME.consumer.autoCommitOffset=false)
  2. + *
  3. Set the ackMode to MANUAL_IMMEDIATE (spring.cloud.stream.kafka.bindings.BINDINGNAME.consumer.ackMode=MANUAL_IMMEDIATE)
  4. + *
+ * @param The type of the message to read and deserialize + * @param The type of the message resulted + */ +@Slf4j +public abstract class BaseKafkaConsumer { + + /** Key used inside the {@link Context} to store the startTime */ + protected static final String CONTEXT_KEY_START_TIME = "START_TIME"; + /** Key used inside the {@link Context} to store a msg identifier used for logging purpose */ + protected static final String CONTEXT_KEY_MSG_ID = "MSG_ID"; + + private final String applicationName; + + private static final Collector, ?, Map>>> kafkaAcknowledgeResultMapCollector = + Collectors.groupingBy(KafkaAcknowledgeResult::partition + , Collectors.teeing( + Collectors.minBy(Comparator.comparing(KafkaAcknowledgeResult::offset)), + Collectors.maxBy(Comparator.comparing(KafkaAcknowledgeResult::offset)), + (min, max) -> Pair.of( + min.map(KafkaAcknowledgeResult::offset).orElse(null), + max.orElse(null)) + )); + + protected BaseKafkaConsumer(String applicationName) { + this.applicationName = applicationName; + } + + record KafkaAcknowledgeResult (Acknowledgment ack, Integer partition, Long offset, T result){ + public KafkaAcknowledgeResult(Message message, T result) { + this( + (Acknowledgment) CommonUtilities.getHeaderValue(message, KafkaHeaders.ACKNOWLEDGMENT), + getMessagePartitionId(message), + getMessageOffset(message), + result + ); + } + } + + private static Integer getMessagePartitionId(Message message) { + return (Integer) CommonUtilities.getHeaderValue(message, KafkaHeaders.RECEIVED_PARTITION); + } + + private static Long getMessageOffset(Message message) { + return (Long) CommonUtilities.getHeaderValue(message, KafkaHeaders.OFFSET); + } + + /** It will ask the superclass to handle the messages, then sequentially it will acknowledge them */ + public final void execute(Flux> messagesFlux) { + Flux> processUntilCommits = + messagesFlux + .flatMapSequential(this::executeAcknowledgeAware) + + .buffer(getCommitDelay()) + .map(p -> { + Map>> partition2Offsets = p.stream() + .collect(kafkaAcknowledgeResultMapCollector); + + log.info("[KAFKA_COMMIT][{}] Committing {} messages: {}", getFlowName(), p.size(), + partition2Offsets.entrySet().stream() + .map(e->"partition %d: %d - %d".formatted(e.getKey(),e.getValue().getKey(), e.getValue().getValue().offset())) + .collect(Collectors.joining(";"))); + + partition2Offsets.forEach((partition, offsets) -> Optional.ofNullable(offsets.getValue().ack()).ifPresent(Acknowledgment::acknowledge)); + + return p.stream() + .map(KafkaAcknowledgeResult::result) + .filter(Objects::nonNull) + .toList(); + } + ); + + subscribeAfterCommits(processUntilCommits); + } + + /** The {@link Duration} to wait before to commit processed messages */ + protected abstract Duration getCommitDelay(); + + /** {@link Flux} to which subscribe in order to start its execution and eventually perform some logic on results */ + protected abstract void subscribeAfterCommits(Flux> afterCommits2subscribe); + + private Mono> executeAcknowledgeAware(Message message) { + KafkaAcknowledgeResult defaultAck = new KafkaAcknowledgeResult<>(message, null); + + byte[] retryingApplicationName = message.getHeaders().get(KafkaConstants.ERROR_MSG_HEADER_APPLICATION_NAME, byte[].class); + if(retryingApplicationName != null && !new String(retryingApplicationName, StandardCharsets.UTF_8).equals(this.applicationName)){ + log.info("[{}] Discarding message due to other application retry ({}): {}", getFlowName(), retryingApplicationName, CommonUtilities.readMessagePayload(message)); + return Mono.just(defaultAck); + } + + Map ctx=new HashMap<>(); + ctx.put(CONTEXT_KEY_START_TIME, System.currentTimeMillis()); + ctx.put(CONTEXT_KEY_MSG_ID, CommonUtilities.readMessagePayload(message)); + + return execute(message, ctx) + .map(r -> new KafkaAcknowledgeResult<>(message, r)) + .defaultIfEmpty(defaultAck) + + .onErrorResume(e -> { + if(e instanceof UncommittableError) { + return Mono.error(e); + } else { + return Mono.just(defaultAck); + } + }) + .doOnNext(r -> doFinally(message, ctx)) + + .onErrorResume(e -> { + log.info("Retrying after reactive pipeline error: ", e); + return executeAcknowledgeAware(message); + }); + } + + /** to perform some operation at the end of business logic execution, thus before to wait for commit. As default, it will perform an INFO logging with performance time */ + protected void doFinally(Message message, Map ctx) { + Long startTime = (Long)ctx.get(CONTEXT_KEY_START_TIME); + String msgId = (String)ctx.get(CONTEXT_KEY_MSG_ID); + if(startTime != null){ + PerformanceLogger.logTiming(getFlowName(), startTime, + "(partition: %s, offset: %s) %s".formatted(getMessagePartitionId(message), getMessageOffset(message), msgId)); + } + } + + /** Name used for logging purpose */ + public String getFlowName() { + return getClass().getSimpleName(); + } + + /** It will deserialize the message and then call the {@link #execute(Object, Message, Map)} method */ + protected Mono execute(Message message, Map ctx){ + return Mono.just(message) + .mapNotNull(this::deserializeMessage) + .flatMap(payload->execute(payload, message, ctx)); + } + + /** The {@link ObjectReader} to use in order to deserialize the input message */ + protected abstract ObjectReader getObjectReader(); + /** The action to take if the deserialization will throw an error */ + protected abstract Consumer onDeserializationError(Message message); + + /** The function invoked in order to process the current message */ + protected abstract Mono execute(T payload, Message message, Map ctx); + + /** It will read and deserialize {@link Message#getPayload()} using the given {@link #getObjectReader()} */ + protected T deserializeMessage(Message message) { + return CommonUtilities.deserializeMessage(message, getObjectReader(),null/*onDeserializationError(message)*/); + } + +} \ No newline at end of file diff --git a/src/main/java/it/gov/pagopa/common/reactive/kafka/exception/UncommittableError.java b/src/main/java/it/gov/pagopa/common/reactive/kafka/exception/UncommittableError.java new file mode 100644 index 0000000..57c5932 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/reactive/kafka/exception/UncommittableError.java @@ -0,0 +1,11 @@ +package it.gov.pagopa.common.reactive.kafka.exception; + +public class UncommittableError extends RuntimeException { + public UncommittableError(String message){ + super(message); + } + + public UncommittableError(String message, Exception e){ + super(message, e); + } +} diff --git a/src/main/java/it/gov/pagopa/common/reactive/utils/PerformanceLogger.java b/src/main/java/it/gov/pagopa/common/reactive/utils/PerformanceLogger.java new file mode 100644 index 0000000..3aad658 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/reactive/utils/PerformanceLogger.java @@ -0,0 +1,54 @@ +package it.gov.pagopa.common.reactive.utils; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +@Slf4j +public class PerformanceLogger { + + private PerformanceLogger(){} + +//region Mono ops + public static Mono logTimingOnNext(String flowName, Mono publisher, Function data2LogPayload){ + return logTimingOnNext(flowName, System.currentTimeMillis(), publisher, data2LogPayload); + } + public static Mono logTimingOnNext(String flowName, long startTime, Mono publisher, Function data2LogPayload){ + return publisher + .doOnNext(x -> logTiming(flowName, startTime, data2LogPayload!=null? data2LogPayload.apply(x) : "")); + } + + public static Mono logTimingFinally(String flowName, Mono publisher, String logPayload){ + return logTimingFinally(flowName, System.currentTimeMillis(), publisher, logPayload); + } + public static Mono logTimingFinally(String flowName, long startTime, Mono publisher, String logPayload){ + return publisher + .doFinally(x -> logTiming(flowName, startTime, ObjectUtils.firstNonNull(logPayload, ""))); + } +//endregion + +//region Flux ops + public static Flux logTimingOnNext(String flowName, Flux publisher, Function data2LogPayload){ + return logTimingOnNext(flowName, System.currentTimeMillis(), publisher, data2LogPayload); + } + public static Flux logTimingOnNext(String flowName, long startTime, Flux publisher, Function data2LogPayload){ + return publisher + .doOnNext(x -> logTiming(flowName, startTime, data2LogPayload!=null? data2LogPayload.apply(x) : "")); + } + + public static Flux logTimingFinally(String flowName, Flux publisher, String logPayload){ + return logTimingFinally(flowName, System.currentTimeMillis(), publisher, logPayload); + } + public static Flux logTimingFinally(String flowName, long startTime, Flux publisher, String logPayload){ + return publisher + .doFinally(x -> logTiming(flowName, startTime, ObjectUtils.firstNonNull(logPayload, ""))); + } +//endregion + + public static void logTiming(String flowName, long startTime, String logPayload){ + log.info("[PERFORMANCE_LOG] [{}] Time occurred to perform business logic: {} ms {}", flowName, System.currentTimeMillis() - startTime, logPayload); + } +} diff --git a/src/main/java/it/gov/pagopa/common/utils/CommonConstants.java b/src/main/java/it/gov/pagopa/common/utils/CommonConstants.java new file mode 100644 index 0000000..79440b2 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/utils/CommonConstants.java @@ -0,0 +1,16 @@ +package it.gov.pagopa.common.utils; + +import java.time.ZoneId; + +public class CommonConstants { + + + public static final class ExceptionCode { + public static final String GENERIC_ERROR = "GENERIC_ERROR"; + private ExceptionCode() {} + } + + public static final ZoneId ZONEID = ZoneId.of("Europe/Rome"); + + private CommonConstants(){} +} diff --git a/src/main/java/it/gov/pagopa/common/utils/CommonUtilities.java b/src/main/java/it/gov/pagopa/common/utils/CommonUtilities.java new file mode 100644 index 0000000..32bec5f --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/utils/CommonUtilities.java @@ -0,0 +1,80 @@ +package it.gov.pagopa.common.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectReader; +import it.gov.pagopa.common.web.exception.EmdEncryptionException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.function.Consumer; + +@Slf4j +public class CommonUtilities { + private CommonUtilities() {} + + public static final DecimalFormatSymbols decimalFormatterSymbols = new DecimalFormatSymbols(); + public static final DecimalFormat decimalFormatter; + + static { + decimalFormatterSymbols.setDecimalSeparator(','); + decimalFormatter = new DecimalFormat("0.00", CommonUtilities.decimalFormatterSymbols); + } + + /** It will try to deserialize a message, eventually notifying the error */ + public static T deserializeMessage(Message message, ObjectReader objectReader, Consumer onError) { + try { + String payload = readMessagePayload(message); + return objectReader.readValue(payload); + } catch (JsonProcessingException e) { + onError.accept(e); + return null; + } + } + + /** It will read message payload checking if it's a byte[] or String */ + public static String readMessagePayload(Message message) { + String payload; + if(message.getPayload() instanceof byte[] bytes){ + payload=new String(bytes); + } else { + payload= message.getPayload().toString(); + } + return payload; + } + + /** To read Message header value */ + public static Object getHeaderValue(Message message, String headerName) { + return message.getHeaders().get(headerName); + } + + /** To read {@link org.apache.kafka.common.header.Header} value */ + public static String getByteArrayHeaderValue(Message message, String headerName) { + byte[] headerValue = message.getHeaders().get(headerName, byte[].class); + return headerValue!=null? new String(headerValue, StandardCharsets.UTF_8) : null; + } + + /** To convert cents into euro */ + public static String createSHA256(String fiscalCode) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] encodedhash = md.digest(fiscalCode.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(2 * encodedhash.length); + for (byte b : encodedhash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + log.info("Something went wrong creating SHA256"); + throw new EmdEncryptionException("Something went wrong creating SHA256",true,e); + } + } +} diff --git a/src/main/java/it/gov/pagopa/common/utils/Utils.java b/src/main/java/it/gov/pagopa/common/utils/Utils.java new file mode 100644 index 0000000..bc0a27c --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/utils/Utils.java @@ -0,0 +1,38 @@ +package it.gov.pagopa.common.utils; + +import it.gov.pagopa.common.web.exception.EmdEncryptionException; +import lombok.extern.slf4j.Slf4j; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +@Slf4j +public class Utils { + + private Utils(){} + public static String createSHA256(String fiscalCode) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] encodedhash = md.digest(fiscalCode.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(2 * encodedhash.length); + for (byte b : encodedhash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + log.info("Something went wrong creating SHA256"); + throw new EmdEncryptionException("Something went wrong creating SHA256",true,e); + } + } + + public static String inputSanify(String message){ + if (message != null) + return message.replaceAll("[\\r\\n]", " "); + return "[EMD][WARNING] Null log"; + } +} diff --git a/src/main/java/it/gov/pagopa/common/web/dto/ErrorDTO.java b/src/main/java/it/gov/pagopa/common/web/dto/ErrorDTO.java new file mode 100644 index 0000000..98a1b67 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/dto/ErrorDTO.java @@ -0,0 +1,20 @@ +package it.gov.pagopa.common.web.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import it.gov.pagopa.common.web.exception.ServiceExceptionPayload; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@JsonInclude(JsonInclude.Include.NON_NULL) +@AllArgsConstructor +@NoArgsConstructor +@Data +public class ErrorDTO implements ServiceExceptionPayload { + + @NotBlank + private String code; + @NotBlank + private String message; +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ClientException.java b/src/main/java/it/gov/pagopa/common/web/exception/ClientException.java new file mode 100644 index 0000000..4bf7708 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ClientException.java @@ -0,0 +1,25 @@ +package it.gov.pagopa.common.web.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class ClientException extends RuntimeException { + private final HttpStatus httpStatus; + private final boolean printStackTrace; + + public ClientException(HttpStatus httpStatus, String message) { + this(httpStatus, message, null); + } + + public ClientException(HttpStatus httpStatus, String message, Throwable ex) { + this(httpStatus, message, false, ex); + } + + public ClientException( + HttpStatus httpStatus, String message, boolean printStackTrace, Throwable ex) { + super(message, ex); + this.httpStatus = httpStatus; + this.printStackTrace = printStackTrace; + } +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ClientExceptionNoBody.java b/src/main/java/it/gov/pagopa/common/web/exception/ClientExceptionNoBody.java new file mode 100644 index 0000000..f330ec2 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ClientExceptionNoBody.java @@ -0,0 +1,20 @@ +package it.gov.pagopa.common.web.exception; + +import org.springframework.http.HttpStatus; + +public class ClientExceptionNoBody extends ClientException { + + public ClientExceptionNoBody(HttpStatus httpStatus, String message) { + super(httpStatus, message); + } + + public ClientExceptionNoBody(HttpStatus httpStatus, String message, Throwable ex) { + super(httpStatus, message, ex); + } + + public ClientExceptionNoBody(HttpStatus httpStatus, String message, boolean printStackTrace, + Throwable ex) { + super(httpStatus, message, printStackTrace, ex); + } +} + diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ClientExceptionWithBody.java b/src/main/java/it/gov/pagopa/common/web/exception/ClientExceptionWithBody.java new file mode 100644 index 0000000..7faac1c --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ClientExceptionWithBody.java @@ -0,0 +1,23 @@ +package it.gov.pagopa.common.web.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class ClientExceptionWithBody extends ClientException { + private final String code; + + public ClientExceptionWithBody(HttpStatus httpStatus, String code, String message){ + this(httpStatus, code, message, null); + } + + public ClientExceptionWithBody(HttpStatus httpStatus, String code, String message, + Throwable ex){ + this(httpStatus, code, message, false, ex); + } + + public ClientExceptionWithBody(HttpStatus httpStatus, String code, String message, boolean printStackTrace, Throwable ex){ + super(httpStatus, message, printStackTrace, ex); + this.code = code; + } +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/EmdEncryptionException.java b/src/main/java/it/gov/pagopa/common/web/exception/EmdEncryptionException.java new file mode 100644 index 0000000..99efea0 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/EmdEncryptionException.java @@ -0,0 +1,14 @@ +package it.gov.pagopa.common.web.exception; + + +import it.gov.pagopa.common.utils.CommonConstants; + +public class EmdEncryptionException extends ServiceException { + + public EmdEncryptionException(String message, boolean printStackTrace, Throwable ex) { + this(CommonConstants.ExceptionCode.GENERIC_ERROR, message, printStackTrace, ex); + } + public EmdEncryptionException(String code, String message, boolean printStackTrace, Throwable ex) { + super(code, message,null, printStackTrace, ex); + } +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ErrorManager.java b/src/main/java/it/gov/pagopa/common/web/exception/ErrorManager.java new file mode 100644 index 0000000..7b37f7a --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ErrorManager.java @@ -0,0 +1,77 @@ +package it.gov.pagopa.common.web.exception; + +import it.gov.pagopa.common.web.dto.ErrorDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.lang.Nullable; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Optional; + +@RestControllerAdvice +@Slf4j +public class ErrorManager { + private final ErrorDTO defaultErrorDTO; + + public ErrorManager(@Nullable ErrorDTO defaultErrorDTO) { + this.defaultErrorDTO = Optional.ofNullable(defaultErrorDTO) + .orElse(new ErrorDTO("Error", "Something gone wrong")); + } + + @ExceptionHandler(RuntimeException.class) + protected ResponseEntity handleException(RuntimeException error, ServerHttpRequest request) { + + logClientException(error, request); + + if(error instanceof ClientExceptionNoBody clientExceptionNoBody){ + return ResponseEntity.status(clientExceptionNoBody.getHttpStatus()).build(); + } + else { + ErrorDTO errorDTO; + HttpStatus httpStatus; + if (error instanceof ClientExceptionWithBody clientExceptionWithBody){ + httpStatus=clientExceptionWithBody.getHttpStatus(); + errorDTO = new ErrorDTO(clientExceptionWithBody.getCode(), error.getMessage()); + } + else { + httpStatus=HttpStatus.INTERNAL_SERVER_ERROR; + errorDTO = defaultErrorDTO; + } + return ResponseEntity.status(httpStatus) + .contentType(MediaType.APPLICATION_JSON) + .body(errorDTO); + } + } + public static void logClientException(RuntimeException error, ServerHttpRequest request) { + Throwable unwrappedException = error.getCause() instanceof ServiceException + ? error.getCause() + : error; + + String clientExceptionMessage = ""; + if(error instanceof ClientException clientException) { + clientExceptionMessage = ": HttpStatus %s - %s%s".formatted( + clientException.getHttpStatus(), + (clientException instanceof ClientExceptionWithBody clientExceptionWithBody) ? clientExceptionWithBody.getCode() + ": " : "", + clientException.getMessage() + ); + } + + if(!(error instanceof ClientException clientException) || clientException.isPrintStackTrace() || unwrappedException.getCause() != null){ + log.error("Something went wrong handling request {}{}", getRequestDetails(request), clientExceptionMessage, unwrappedException); + } else { + log.info("A {} occurred handling request {}{} at {}", + unwrappedException.getClass().getSimpleName() , + getRequestDetails(request), + clientExceptionMessage, + unwrappedException.getStackTrace().length > 0 ? unwrappedException.getStackTrace()[0] : "UNKNOWN"); + } + } + + public static String getRequestDetails(ServerHttpRequest request) { + return "%s %s".formatted(request.getMethod(), request.getURI()); + } +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ServiceException.java b/src/main/java/it/gov/pagopa/common/web/exception/ServiceException.java new file mode 100644 index 0000000..dfd8317 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ServiceException.java @@ -0,0 +1,25 @@ +package it.gov.pagopa.common.web.exception; + +import lombok.Getter; + +@Getter +public class ServiceException extends RuntimeException { + private final String code; + private final boolean printStackTrace; + private final ServiceExceptionPayload payload; + + public ServiceException(String code, String message) { + this(code, message, null); + } + public ServiceException(String code, String message, ServiceExceptionPayload payload) { + this(code, message, payload, false, null); + } + + public ServiceException(String code, String message, ServiceExceptionPayload payload, boolean printStackTrace, Throwable ex) { + super(message, ex); + this.code = code; + this.printStackTrace = printStackTrace; + this.payload = payload; + } + +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ServiceExceptionHandler.java b/src/main/java/it/gov/pagopa/common/web/exception/ServiceExceptionHandler.java new file mode 100644 index 0000000..4763292 --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ServiceExceptionHandler.java @@ -0,0 +1,56 @@ +package it.gov.pagopa.common.web.exception; + + +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ServiceExceptionHandler { + private final ErrorManager errorManager; + private final Map, HttpStatus> transcodeMap; + + public ServiceExceptionHandler(ErrorManager errorManager, Map, HttpStatus> transcodeMap) { + this.errorManager = errorManager; + this.transcodeMap = transcodeMap; + } + + @SuppressWarnings("squid:S1452") + @ExceptionHandler(ServiceException.class) + protected ResponseEntity handleException(ServiceException error, ServerHttpRequest request) { + if (null != error.getPayload()) { + return handleBodyProvidedException(error, request); + } + return errorManager.handleException(transcodeException(error), request); + } + + private ClientException transcodeException(ServiceException error) { + HttpStatus httpStatus = transcodeMap.get(error.getClass()); + + if (httpStatus == null) { + log.warn("Unhandled exception: {}", error.getClass().getName()); + httpStatus = HttpStatus.INTERNAL_SERVER_ERROR; + } + + return new ClientExceptionWithBody(httpStatus, error.getCode(), error.getMessage(), error.isPrintStackTrace(), error); + } + + private ResponseEntity handleBodyProvidedException(ServiceException error, ServerHttpRequest request) { + ClientException clientException = transcodeException(error); + ErrorManager.logClientException(clientException, request); + + return ResponseEntity.status(clientException.getHttpStatus()) + .contentType(MediaType.APPLICATION_JSON) + .body(error.getPayload()); + } +} \ No newline at end of file diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ServiceExceptionPayload.java b/src/main/java/it/gov/pagopa/common/web/exception/ServiceExceptionPayload.java new file mode 100644 index 0000000..32e805f --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ServiceExceptionPayload.java @@ -0,0 +1,7 @@ +package it.gov.pagopa.common.web.exception; + +import java.io.Serializable; + +public interface ServiceExceptionPayload extends Serializable{ + +} diff --git a/src/main/java/it/gov/pagopa/common/web/exception/ValidationExceptionHandler.java b/src/main/java/it/gov/pagopa/common/web/exception/ValidationExceptionHandler.java new file mode 100644 index 0000000..cecf4fd --- /dev/null +++ b/src/main/java/it/gov/pagopa/common/web/exception/ValidationExceptionHandler.java @@ -0,0 +1,73 @@ +package it.gov.pagopa.common.web.exception; + +import it.gov.pagopa.common.web.dto.ErrorDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.lang.Nullable; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.reactive.resource.NoResourceFoundException; +import org.springframework.web.server.MissingRequestValueException; + +import java.util.Optional; +import java.util.stream.Collectors; + +@RestControllerAdvice +@Slf4j +@Order(Ordered.HIGHEST_PRECEDENCE) +public class ValidationExceptionHandler { + + private final ErrorDTO templateValidationErrorDTO; + + public ValidationExceptionHandler(@Nullable ErrorDTO templateValidationErrorDTO) { + this.templateValidationErrorDTO = Optional.ofNullable(templateValidationErrorDTO) + .orElse(new ErrorDTO("INVALID_REQUEST", "Invalid request")); + } + + @ExceptionHandler(WebExchangeBindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorDTO handleWebExchangeBindException( + WebExchangeBindException ex, ServerHttpRequest request) { + + String message = ex.getBindingResult().getAllErrors().stream() + .map(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + return String.format("[%s]: %s", fieldName, errorMessage); + }).collect(Collectors.joining("; ")); + + log.info("A WebExchangeBindException occurred handling request {}: HttpStatus 400 - {}", + ErrorManager.getRequestDetails(request), message); + log.debug("Something went wrong while validating http request", ex); + + return new ErrorDTO(templateValidationErrorDTO.getCode(), message); + } + + @ExceptionHandler(MissingRequestValueException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorDTO handleMissingRequestValueException(MissingRequestValueException e, ServerHttpRequest request) { + + log.info("A MissingRequestValueException occurred handling request {}: HttpStatus 400 - {}", + ErrorManager.getRequestDetails(request), e.getMessage()); + log.debug("Something went wrong due to a missing request value", e); + + return new ErrorDTO(templateValidationErrorDTO.getCode(), templateValidationErrorDTO.getMessage()); + } + @ExceptionHandler(NoResourceFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public ErrorDTO handleNoResourceFoundException(NoResourceFoundException e, ServerHttpRequest request) { + + log.info("A NoResourceFoundException occurred handling request {}: HttpStatus 400 - {}", + ErrorManager.getRequestDetails(request), e.getMessage()); + log.debug("Something went wrong due to a missing request value", e); + + return new ErrorDTO(templateValidationErrorDTO.getCode(), templateValidationErrorDTO.getMessage()); + } + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/EmdCitizen.java b/src/main/java/it/gov/pagopa/onboarding/citizen/EmdCitizen.java new file mode 100644 index 0000000..94b6308 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/EmdCitizen.java @@ -0,0 +1,13 @@ +package it.gov.pagopa.onboarding.citizen; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = "it.gov.pagopa") +public class EmdCitizen { + + public static void main(String[] args) { + SpringApplication.run(EmdCitizen.class, args); + } + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/configuration/ExceptionMap.java b/src/main/java/it/gov/pagopa/onboarding/citizen/configuration/ExceptionMap.java new file mode 100644 index 0000000..1187e1a --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/configuration/ExceptionMap.java @@ -0,0 +1,49 @@ +package it.gov.pagopa.onboarding.citizen.configuration; + + +import it.gov.pagopa.common.web.exception.ClientException; +import it.gov.pagopa.common.web.exception.ClientExceptionWithBody; +import it.gov.pagopa.onboarding.citizen.constants.CitizenConstants; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpStatus; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +@Configuration +@Slf4j +public class ExceptionMap { + + private final Map> exceptions = new HashMap<>(); + + public ExceptionMap() { + exceptions.put(CitizenConstants.ExceptionName.CITIZEN_NOT_ONBOARDED, message -> + new ClientExceptionWithBody( + HttpStatus.NOT_FOUND, + CitizenConstants.ExceptionCode.CITIZEN_NOT_ONBOARDED, + message + ) + ); + + exceptions.put(CitizenConstants.ExceptionName.TPP_NOT_FOUND, message -> + new ClientExceptionWithBody( + HttpStatus.NOT_FOUND, + CitizenConstants.ExceptionCode.TPP_NOT_FOUND, + message + ) + ); + } + + public RuntimeException throwException(String exceptionKey, String message) { + if (exceptions.containsKey(exceptionKey)) { + return exceptions.get(exceptionKey).apply(message); + } else { + log.error("Exception Name Not Found: {}", exceptionKey); + return new RuntimeException(); + } + } + +} + diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnector.java b/src/main/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnector.java new file mode 100644 index 0000000..a712bf1 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnector.java @@ -0,0 +1,8 @@ +package it.gov.pagopa.onboarding.citizen.connector.tpp; + +import it.gov.pagopa.onboarding.citizen.dto.TppDTO; +import reactor.core.publisher.Mono; + +public interface TppConnector { + Mono get(String tppId); +} \ No newline at end of file diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnectorImpl.java b/src/main/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnectorImpl.java new file mode 100644 index 0000000..d3f09d7 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnectorImpl.java @@ -0,0 +1,27 @@ +package it.gov.pagopa.onboarding.citizen.connector.tpp; + +import it.gov.pagopa.onboarding.citizen.dto.TppDTO; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Service +public class TppConnectorImpl implements TppConnector { + private final WebClient webClient; + + public TppConnectorImpl(WebClient.Builder webClientBuilder, + @Value("${rest-client.tpp.baseUrl}") String baseUrl) { + this.webClient = webClientBuilder.baseUrl(baseUrl).build(); + } + + @Override + public Mono get(String tppId) { + return webClient.get() + .uri("/emd/tpp/" + tppId) + .retrieve() + .bodyToMono(new ParameterizedTypeReference<>() { + }); + } +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/constants/CitizenConstants.java b/src/main/java/it/gov/pagopa/onboarding/citizen/constants/CitizenConstants.java new file mode 100644 index 0000000..647e90f --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/constants/CitizenConstants.java @@ -0,0 +1,40 @@ +package it.gov.pagopa.onboarding.citizen.constants; + +public class CitizenConstants { + public static final class ExceptionCode { + + public static final String CITIZEN_NOT_ONBOARDED = "CITIZEN_NOT_ONBOARDED"; + public static final String GENERIC_ERROR = "GENERIC_ERROR"; + public static final String TPP_NOT_FOUND = "TPP_NOT_FOUND"; + + private ExceptionCode() {} + } + + public static final class ExceptionMessage { + + public static final String CITIZEN_NOT_ONBOARDED = "CITIZEN_NOT_ONBOARDED"; + public static final String GENERIC_ERROR = "GENERIC_ERROR"; + public static final String TPP_NOT_FOUND = "TPP does not exist or is not active"; + + private ExceptionMessage() {} + } + + public static final class ExceptionName { + + public static final String CITIZEN_NOT_ONBOARDED = "CITIZEN_NOT_ONBOARDED"; + public static final String GENERIC_ERROR = "GENERIC_ERROR"; + public static final String TPP_NOT_FOUND = "TPP_NOT_FOUND"; + + private ExceptionName() {} + } + + public static final class ValidationRegex { + + public static final String FISCAL_CODE_STRUCTURE_REGEX = "(^([A-Za-z]{6}[0-9lmnpqrstuvLMNPQRSTUV]{2}[abcdehlmprstABCDEHLMPRST][0-9lmnpqrstuvLMNPQRSTUV]{2}[A-Za-z][0-9lmnpqrstuvLMNPQRSTUV]{3}[A-Za-z])$)|(^(\\d{11})$)"; + + private ValidationRegex() {} + } + + + private CitizenConstants() {} +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/controller/CitizenController.java b/src/main/java/it/gov/pagopa/onboarding/citizen/controller/CitizenController.java new file mode 100644 index 0000000..397ba0f --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/controller/CitizenController.java @@ -0,0 +1,59 @@ +package it.gov.pagopa.onboarding.citizen.controller; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentStateUpdateDTO; +import jakarta.validation.Valid; + +import jakarta.validation.constraints.Pattern; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; + +import static it.gov.pagopa.onboarding.citizen.constants.CitizenConstants.ValidationRegex.FISCAL_CODE_STRUCTURE_REGEX; + + +@RequestMapping("/emd/citizen") +public interface CitizenController { + + @PostMapping("") + Mono> saveCitizenConsent(@Valid @RequestBody CitizenConsentDTO citizenConsentDTO); + + @PutMapping("/stateUpdate") + Mono> stateUpdate(@Valid @RequestBody CitizenConsentStateUpdateDTO citizenConsentStateUpdateDTO); + + @GetMapping("/filter/{fiscalCode}") + Mono> bloomFilterSearch(@PathVariable @Pattern(regexp = FISCAL_CODE_STRUCTURE_REGEX, message = "Invalid fiscal code format") String fiscalCode); + + @GetMapping("/list/{fiscalCode}/enabled/tpp") + Mono>> getTppEnabledList(@PathVariable @Pattern(regexp = FISCAL_CODE_STRUCTURE_REGEX, message = "Invalid fiscal code format") String fiscalCode); + + /** + * Get the consent status for a specific citizen and tpp. + * + * @param fiscalCode the fiscal code of the citizen + * @param tppId the ID of the tpp + * @return the citizen consent status + */ + @GetMapping("/{fiscalCode}/{tppId}") + Mono> getCitizenConsentStatus(@PathVariable @Pattern(regexp = FISCAL_CODE_STRUCTURE_REGEX, message = "Invalid fiscal code format") String fiscalCode, @PathVariable String tppId); + + + /** + * Get consents for a specific citizen. + * + * @param fiscalCode the fiscal code of the citizen + * @return a list of all channels with their consent statuses + */ + @GetMapping("/list/{fiscalCode}") + Mono> getCitizenConsentsList(@PathVariable @Pattern(regexp = FISCAL_CODE_STRUCTURE_REGEX, message = "Invalid fiscal code format") String fiscalCode); + + + @GetMapping("/list/{fiscalCode}/enabled") + Mono> getCitizenConsentsListEnabled(@PathVariable @Pattern(regexp = FISCAL_CODE_STRUCTURE_REGEX, message = "Invalid fiscal code format") String fiscalCode); + + @GetMapping("/{tppId}") + Mono>> getCitizenEnabled(@PathVariable String tppId); + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/controller/CitizenControllerImpl.java b/src/main/java/it/gov/pagopa/onboarding/citizen/controller/CitizenControllerImpl.java new file mode 100644 index 0000000..ef78227 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/controller/CitizenControllerImpl.java @@ -0,0 +1,82 @@ +package it.gov.pagopa.onboarding.citizen.controller; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentStateUpdateDTO; +import it.gov.pagopa.onboarding.citizen.service.BloomFilterServiceImpl; +import it.gov.pagopa.onboarding.citizen.service.CitizenServiceImpl; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.List; + +@RestController +public class CitizenControllerImpl implements CitizenController { + + private final BloomFilterServiceImpl bloomFilterService; + + private final CitizenServiceImpl citizenService; + + public CitizenControllerImpl(BloomFilterServiceImpl bloomFilterService, CitizenServiceImpl citizenService) { + this.bloomFilterService = bloomFilterService; + this.citizenService = citizenService; + } + + @Override + public Mono> saveCitizenConsent(@Valid CitizenConsentDTO citizenConsentDTO) { + return citizenService.createCitizenConsent(citizenConsentDTO) + .map(ResponseEntity::ok); + } + + @Override + public Mono> stateUpdate(@Valid CitizenConsentStateUpdateDTO citizenConsentStateUpdateDTO) { + return citizenService.updateTppState( + citizenConsentStateUpdateDTO.getFiscalCode(), + citizenConsentStateUpdateDTO.getTppId(), + citizenConsentStateUpdateDTO.getTppState()) + .map(ResponseEntity::ok); + } + + @Override + public Mono> getCitizenConsentStatus(String fiscalCode, String tppId) { + return citizenService.getCitizenConsentStatus(fiscalCode, tppId) + .map(ResponseEntity::ok); + } + + @Override + public Mono>> getTppEnabledList(String fiscalCode) { + return citizenService.getTppEnabledList(fiscalCode) + .map(ResponseEntity::ok); + } + + @Override + public Mono> getCitizenConsentsList(String fiscalCode) { + return citizenService.getCitizenConsentsList(fiscalCode) + .map(ResponseEntity::ok); + } + + @Override + public Mono> getCitizenConsentsListEnabled(String fiscalCode) { + return citizenService.getCitizenConsentsListEnabled(fiscalCode) + .map(ResponseEntity::ok); + } + + @Override + public Mono>> getCitizenEnabled(String tppId) { + return citizenService.getCitizenEnabled(tppId) + .map(ResponseEntity::ok); + } + + @Override + public Mono> bloomFilterSearch(String fiscalCode) { + return Mono.fromCallable(() -> + bloomFilterService.mightContain(fiscalCode) ? + ResponseEntity.ok("OK") : + ResponseEntity.status(HttpStatus.ACCEPTED).body("NO CHANNELS ENABLED") + ); + + } + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/dto/CitizenConsentDTO.java b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/CitizenConsentDTO.java new file mode 100644 index 0000000..3ed1f7a --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/CitizenConsentDTO.java @@ -0,0 +1,36 @@ +package it.gov.pagopa.onboarding.citizen.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; +import java.util.Map; + +import static it.gov.pagopa.onboarding.citizen.constants.CitizenConstants.ValidationRegex.FISCAL_CODE_STRUCTURE_REGEX; + +@Data +@SuperBuilder +@AllArgsConstructor +@NoArgsConstructor +public class CitizenConsentDTO { + @JsonAlias("fiscalCode") + @NotBlank(message = "Fiscal Code must not be blank") + @Pattern(regexp = FISCAL_CODE_STRUCTURE_REGEX, + message = "Fiscal Code must be 11 digits or up to 16 alphanumeric characters") + private String fiscalCode; + private Map consents; + + @Data + @SuperBuilder + @NoArgsConstructor + @AllArgsConstructor + public static class ConsentDTO { + private Boolean tppState; + private LocalDateTime tcDate; + } +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/dto/CitizenConsentStateUpdateDTO.java b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/CitizenConsentStateUpdateDTO.java new file mode 100644 index 0000000..edb0f7a --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/CitizenConsentStateUpdateDTO.java @@ -0,0 +1,16 @@ +package it.gov.pagopa.onboarding.citizen.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class CitizenConsentStateUpdateDTO { + private String fiscalCode; + private String tppId; + private Boolean tppState; +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/dto/Contact.java b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/Contact.java new file mode 100644 index 0000000..fa6c88d --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/Contact.java @@ -0,0 +1,15 @@ +package it.gov.pagopa.onboarding.citizen.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Contact { + + private String name; + private String number; + private String email; +} \ No newline at end of file diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/dto/TppDTO.java b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/TppDTO.java new file mode 100644 index 0000000..bc22e8b --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/TppDTO.java @@ -0,0 +1,26 @@ +package it.gov.pagopa.onboarding.citizen.dto; + + +import it.gov.pagopa.onboarding.citizen.enums.AuthenticationType; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +@Data +@SuperBuilder +@NoArgsConstructor +public class TppDTO { + @NotNull + private String tppId; + private String entityId; + private String businessName; + private String messageUrl; + private String authenticationUrl; + private AuthenticationType authenticationType; + private Contact contact; + private Boolean state; + + + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/dto/mapper/CitizenConsentObjectToDTOMapper.java b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/mapper/CitizenConsentObjectToDTOMapper.java new file mode 100644 index 0000000..9ba6a1a --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/dto/mapper/CitizenConsentObjectToDTOMapper.java @@ -0,0 +1,27 @@ +package it.gov.pagopa.onboarding.citizen.dto.mapper; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO.ConsentDTO; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class CitizenConsentObjectToDTOMapper { + + public CitizenConsentDTO map(CitizenConsent citizenConsent) { + Map consentsDTO = new HashMap<>(); + + citizenConsent.getConsents().forEach((tppId, consentDetails) -> consentsDTO.put(tppId, ConsentDTO.builder() + .tppState(consentDetails.getTppState()) + .tcDate(consentDetails.getTcDate()) + .build())); + + return CitizenConsentDTO.builder() + .fiscalCode(citizenConsent.getFiscalCode()) + .consents(consentsDTO) + .build(); + } +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/enums/AuthenticationType.java b/src/main/java/it/gov/pagopa/onboarding/citizen/enums/AuthenticationType.java new file mode 100644 index 0000000..26c74ce --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/enums/AuthenticationType.java @@ -0,0 +1,16 @@ +package it.gov.pagopa.onboarding.citizen.enums; + +import lombok.Getter; + +@Getter +public enum AuthenticationType { + OAUTH2("OAUTH2"); + + + private final String status; + + AuthenticationType(String status) { + this.status = status; + } + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/model/CitizenConsent.java b/src/main/java/it/gov/pagopa/onboarding/citizen/model/CitizenConsent.java new file mode 100644 index 0000000..d9862d6 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/model/CitizenConsent.java @@ -0,0 +1,21 @@ +package it.gov.pagopa.onboarding.citizen.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.Map; + +@Document(collection = "citizen_consents") +@Data +@SuperBuilder +@NoArgsConstructor +public class CitizenConsent { + + private String id; + private String fiscalCode; + private Map consents; + +} + diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/model/ConsentDetails.java b/src/main/java/it/gov/pagopa/onboarding/citizen/model/ConsentDetails.java new file mode 100644 index 0000000..bc91264 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/model/ConsentDetails.java @@ -0,0 +1,15 @@ +package it.gov.pagopa.onboarding.citizen.model; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +@Data +@NoArgsConstructor +@SuperBuilder +public class ConsentDetails { + private Boolean tppState; + private LocalDateTime tcDate; +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/model/mapper/CitizenConsentDTOToObjectMapper.java b/src/main/java/it/gov/pagopa/onboarding/citizen/model/mapper/CitizenConsentDTOToObjectMapper.java new file mode 100644 index 0000000..58b9bff --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/model/mapper/CitizenConsentDTOToObjectMapper.java @@ -0,0 +1,27 @@ +package it.gov.pagopa.onboarding.citizen.model.mapper; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.model.ConsentDetails; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +public class CitizenConsentDTOToObjectMapper { + + public CitizenConsent map(CitizenConsentDTO citizenConsentDTO) { + Map consents = new HashMap<>(); + + citizenConsentDTO.getConsents().forEach((tppId, consentDTO) -> consents.put(tppId, ConsentDetails.builder() + .tppState(consentDTO.getTppState()) + .tcDate(consentDTO.getTcDate()) + .build())); + + return CitizenConsent.builder() + .fiscalCode(citizenConsentDTO.getFiscalCode()) + .consents(consents) + .build(); + } +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenRepository.java b/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenRepository.java new file mode 100644 index 0000000..9263ea7 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenRepository.java @@ -0,0 +1,11 @@ +package it.gov.pagopa.onboarding.citizen.repository; + +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import org.springframework.data.mongodb.repository.ReactiveMongoRepository; +import reactor.core.publisher.Mono; + +public interface CitizenRepository extends ReactiveMongoRepository, CitizenSpecificRepository { + + Mono findByFiscalCode(String fiscalCode); + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepository.java b/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepository.java new file mode 100644 index 0000000..9305eb9 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepository.java @@ -0,0 +1,13 @@ +package it.gov.pagopa.onboarding.citizen.repository; + +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + + +public interface CitizenSpecificRepository { + + Mono findByFiscalCodeAndTppId(String fiscalCode, String tppId); + + Flux findByTppIdEnabled(String tppId); +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepositoryImpl.java b/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepositoryImpl.java new file mode 100644 index 0000000..a4e422f --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepositoryImpl.java @@ -0,0 +1,59 @@ +package it.gov.pagopa.onboarding.citizen.repository; + +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import lombok.Data; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.stereotype.Repository; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +@Repository +public class CitizenSpecificRepositoryImpl implements CitizenSpecificRepository { + + private final ReactiveMongoTemplate mongoTemplate; + + private static final String FISCAL_CODE = "fiscalCode"; + + public CitizenSpecificRepositoryImpl(ReactiveMongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + + public Mono findByFiscalCodeAndTppId(String fiscalCode, String tppId) { + if (tppId == null) { + return Mono.empty(); + } + + String consent = "consents." + tppId; + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(FISCAL_CODE).is(fiscalCode)), + Aggregation.match(Criteria.where(consent).exists(true)), + Aggregation.project(FISCAL_CODE).and(consent).as(consent) + ); + + return mongoTemplate.aggregate(aggregation, "citizen_consents", CitizenConsent.class) + .next(); + } + + public Flux findByTppIdEnabled(String tppId) { + String consent = "consents." + tppId; + String tppStatePath = consent + ".tppState"; + + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.match(Criteria.where(tppStatePath).is(true)), + Aggregation.project(FISCAL_CODE).and(consent).as(consent) + ); + + return mongoTemplate.aggregate(aggregation, "citizen_consents", CitizenConsent.class); + } + + + @Data + public static class ConsentKeyWrapper { + private String k; + } + + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterService.java b/src/main/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterService.java new file mode 100644 index 0000000..bce36f0 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterService.java @@ -0,0 +1,6 @@ +package it.gov.pagopa.onboarding.citizen.service; + +public interface BloomFilterService { + + boolean mightContain(String hashedFiscalCode); +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterServiceImpl.java b/src/main/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterServiceImpl.java new file mode 100644 index 0000000..efef5f1 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterServiceImpl.java @@ -0,0 +1,52 @@ +package it.gov.pagopa.onboarding.citizen.service; + + +import com.azure.cosmos.implementation.guava25.hash.BloomFilter; +import com.azure.cosmos.implementation.guava25.hash.Funnels; +import it.gov.pagopa.onboarding.citizen.repository.CitizenRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import java.nio.charset.StandardCharsets; + +@Service +@Slf4j +public class BloomFilterServiceImpl implements BloomFilterService{ + + private final CitizenRepository citizenRepository; + + private BloomFilter bloomFilter; + + public BloomFilterServiceImpl(CitizenRepository citizenRepository) { + this.citizenRepository = citizenRepository; + } + + + @PostConstruct + public void initializeBloomFilter() { + bloomFilter = BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8), 1000000, 0.01); + + citizenRepository.findAll() + .doOnNext(citizenConsent -> + bloomFilter.put(citizenConsent.getFiscalCode())) + .doOnComplete(() -> log.info("Bloom filter initialized")) + .subscribe(); + } + @Override + + public boolean mightContain(String fiscalCode) { + return bloomFilter.mightContain(fiscalCode); + } + + + @Scheduled(fixedRate = 3600000) + public void update() { + this.initializeBloomFilter(); + } + + public void add(String fiscalCode){ + bloomFilter.put(fiscalCode); + } +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/service/CitizenService.java b/src/main/java/it/gov/pagopa/onboarding/citizen/service/CitizenService.java new file mode 100644 index 0000000..5aabd00 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/service/CitizenService.java @@ -0,0 +1,17 @@ +package it.gov.pagopa.onboarding.citizen.service; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import reactor.core.publisher.Mono; + +import java.util.List; + +public interface CitizenService { + + Mono createCitizenConsent(CitizenConsentDTO citizenConsent); + Mono updateTppState(String fiscalCode, String tppId, boolean tppState); + Mono getCitizenConsentStatus(String fiscalCode, String tppId); + Mono> getTppEnabledList(String fiscalCode); + Mono getCitizenConsentsList(String fiscalCode); + Mono getCitizenConsentsListEnabled(String fiscalCode); + Mono> getCitizenEnabled(String tppId); +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/service/CitizenServiceImpl.java b/src/main/java/it/gov/pagopa/onboarding/citizen/service/CitizenServiceImpl.java new file mode 100644 index 0000000..83ddf29 --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/service/CitizenServiceImpl.java @@ -0,0 +1,154 @@ +package it.gov.pagopa.onboarding.citizen.service; + +import it.gov.pagopa.common.utils.Utils; +import it.gov.pagopa.onboarding.citizen.configuration.ExceptionMap; +import it.gov.pagopa.onboarding.citizen.connector.tpp.TppConnectorImpl; +import it.gov.pagopa.onboarding.citizen.constants.CitizenConstants.ExceptionMessage; +import it.gov.pagopa.onboarding.citizen.constants.CitizenConstants.ExceptionName; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.mapper.CitizenConsentObjectToDTOMapper; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.model.ConsentDetails; +import it.gov.pagopa.onboarding.citizen.model.mapper.CitizenConsentDTOToObjectMapper; +import it.gov.pagopa.onboarding.citizen.repository.CitizenRepository; +import it.gov.pagopa.onboarding.citizen.validation.CitizenConsentValidationServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static it.gov.pagopa.common.utils.Utils.inputSanify; + +@Service +@Slf4j +public class CitizenServiceImpl implements CitizenService { + + private final CitizenRepository citizenRepository; + private final CitizenConsentObjectToDTOMapper mapperToDTO; + private final CitizenConsentDTOToObjectMapper mapperToObject; + private final ExceptionMap exceptionMap; + private final TppConnectorImpl tppConnector; + private final CitizenConsentValidationServiceImpl validationService; + + public CitizenServiceImpl(CitizenRepository citizenRepository, CitizenConsentObjectToDTOMapper mapperToDTO, CitizenConsentDTOToObjectMapper mapperToObject, ExceptionMap exceptionMap, TppConnectorImpl tppConnector, CitizenConsentValidationServiceImpl validationService) { + this.citizenRepository = citizenRepository; + this.mapperToDTO = mapperToDTO; + this.mapperToObject = mapperToObject; + this.exceptionMap = exceptionMap; + this.tppConnector = tppConnector; + this.validationService = validationService; + } + + @Override + public Mono createCitizenConsent(CitizenConsentDTO citizenConsentDTO) { + + CitizenConsent citizenConsent = mapperToObject.map(citizenConsentDTO); + String fiscalCode = citizenConsent.getFiscalCode(); + + citizenConsent.getConsents().forEach((tppId, consentDetails) -> consentDetails.setTcDate(LocalDateTime.now())); + + log.info("[EMD-CITIZEN][CREATE] Received consent: {}", inputSanify(citizenConsent.toString())); + + String tppId = citizenConsent.getConsents().keySet().stream().findFirst().orElse(null); + if (tppId == null) { + return Mono.error(exceptionMap.throwException(ExceptionName.TPP_NOT_FOUND, ExceptionMessage.TPP_NOT_FOUND)); + } + + return citizenRepository.findByFiscalCode(fiscalCode) + .flatMap(existingConsent -> validationService.handleExistingConsent(existingConsent, tppId, citizenConsent)) + .switchIfEmpty(validationService.validateTppAndSaveConsent(fiscalCode, tppId, citizenConsent)); + } + + @Override + public Mono updateTppState(String fiscalCode, String tppId, boolean tppState) { + log.info("[EMD][CITIZEN][UPDATE-CHANNEL-STATE] Received hashedFiscalCode: {} and tppId: {} with state: {}", + Utils.createSHA256(fiscalCode), inputSanify(tppId), tppState); + + return tppConnector.get(tppId) + .onErrorMap(error ->exceptionMap.throwException(ExceptionName.TPP_NOT_FOUND, ExceptionMessage.TPP_NOT_FOUND)) + .flatMap(tppResponse -> citizenRepository.findByFiscalCodeAndTppId(fiscalCode, tppId) + .switchIfEmpty(Mono.error(exceptionMap.throwException + (ExceptionName.CITIZEN_NOT_ONBOARDED, "Citizen consent not founded during update state process"))) + .flatMap(citizenConsent -> { + ConsentDetails consentDetails = citizenConsent.getConsents().get(tppId); + consentDetails.setTppState(tppState); + return citizenRepository.save(citizenConsent); + }) + .map(mapperToDTO::map) + .doOnSuccess(savedConsent -> log.info("[EMD][CITIZEN][UPDATE-CHANNEL-STATE] Updated state"))); + } + + @Override + public Mono getCitizenConsentStatus(String fiscalCode, String tppId) { + log.info("[EMD-CITIZEN][GET-CONSENT-STATUS] Received hashedFiscalCode: {} and tppId: {}", Utils.createSHA256(fiscalCode), inputSanify(tppId)); + return citizenRepository.findByFiscalCodeAndTppId(fiscalCode, tppId) + .switchIfEmpty(Mono.error(exceptionMap.throwException + (ExceptionName.CITIZEN_NOT_ONBOARDED, "Citizen consent not founded during get process "))) + .map(mapperToDTO::map) + .doOnSuccess(consent -> log.info("[EMD-CITIZEN][GET-CONSENT-STATUS] Consent consent found:: {}", consent)); + + } + + @Override + public Mono> getTppEnabledList(String fiscalCode) { + log.info("[EMD-CITIZEN][FIND-CITIZEN-CONSENTS-ENABLED] Received hashedFiscalCode: {}", Utils.createSHA256(fiscalCode)); + + return citizenRepository.findByFiscalCode(fiscalCode) + .switchIfEmpty(Mono.empty()) + .map(citizenConsent -> citizenConsent.getConsents().entrySet().stream() + .filter(tpp -> tpp.getValue().getTppState()) + .map(Map.Entry::getKey) + .toList()) + .doOnSuccess(tppIdList -> { + if (tppIdList != null) { + log.info("EMD][CITIZEN][FIND-CITIZEN-CONSENTS-ENABLED] Consents found: {}", (tppIdList.size())); + } else { + log.info("EMD][CITIZEN][FIND-CITIZEN-CONSENTS-ENABLED] No consents found."); + } + }); + } + + @Override + public Mono getCitizenConsentsList(String fiscalCode) { + log.info("[EMD-CITIZEN][FIND-ALL-CITIZEN-CONSENTS] Received hashedFiscalCode: {}", (Utils.createSHA256(fiscalCode))); + return citizenRepository.findByFiscalCode(fiscalCode) + .map(mapperToDTO::map) + .doOnSuccess(consentList -> log.info("[EMD-CITIZEN][FIND-ALL-CITIZEN-CONSENTS] Consents found: {}", consentList)); + } + + @Override + public Mono getCitizenConsentsListEnabled(String fiscalCode) { + log.info("[EMD-CITIZEN][FIND-CITIZEN-CONSENTS-ENABLED] Received hashedFiscalCode: {}", Utils.createSHA256(fiscalCode)); + + return citizenRepository.findByFiscalCode(fiscalCode) + .switchIfEmpty(Mono.empty()) + .map(citizenConsent -> { + Map filteredConsents = citizenConsent.getConsents().entrySet().stream() + .filter(tpp -> tpp.getValue().getTppState()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + citizenConsent.setConsents(filteredConsents); + + return mapperToDTO.map(citizenConsent); + }) + .doOnSuccess(citizenConsent -> { + if (citizenConsent != null && citizenConsent.getConsents() != null) { + log.info("EMD][CITIZEN][FIND-CITIZEN-CONSENTS-ENABLED] Consents found: {}", citizenConsent.getConsents().size()); + } else { + log.info("EMD][CITIZEN][FIND-CITIZEN-CONSENTS-ENABLED] No consents found."); + } + }); + + + } + + @Override + public Mono> getCitizenEnabled(String tppId) { + return citizenRepository.findByTppIdEnabled(tppId) + .map(mapperToDTO::map) + .collectList(); + } +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationService.java b/src/main/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationService.java new file mode 100644 index 0000000..8cbabed --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationService.java @@ -0,0 +1,13 @@ +package it.gov.pagopa.onboarding.citizen.validation; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import reactor.core.publisher.Mono; + +public interface CitizenConsentValidationService { + + Mono handleExistingConsent(CitizenConsent existingConsent, String tppId, CitizenConsent citizenConsent); + + Mono validateTppAndSaveConsent(String fiscalCode, String tppId, CitizenConsent citizenConsent); + +} diff --git a/src/main/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationServiceImpl.java b/src/main/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationServiceImpl.java new file mode 100644 index 0000000..85ffdfd --- /dev/null +++ b/src/main/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationServiceImpl.java @@ -0,0 +1,76 @@ +package it.gov.pagopa.onboarding.citizen.validation; + +import it.gov.pagopa.common.utils.Utils; +import it.gov.pagopa.onboarding.citizen.configuration.ExceptionMap; +import it.gov.pagopa.onboarding.citizen.connector.tpp.TppConnectorImpl; +import it.gov.pagopa.onboarding.citizen.constants.CitizenConstants; +import it.gov.pagopa.onboarding.citizen.constants.CitizenConstants.ExceptionName; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.mapper.CitizenConsentObjectToDTOMapper; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.repository.CitizenRepository; +import it.gov.pagopa.onboarding.citizen.service.BloomFilterServiceImpl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +@Service +@Slf4j +public class CitizenConsentValidationServiceImpl implements CitizenConsentValidationService { + + private final CitizenRepository citizenRepository; + + private final BloomFilterServiceImpl bloomFilterService; + + private final TppConnectorImpl tppConnector; + private final CitizenConsentObjectToDTOMapper mapperToDTO; + private final ExceptionMap exceptionMap; + + public CitizenConsentValidationServiceImpl(CitizenRepository citizenRepository, BloomFilterServiceImpl bloomFilterService, TppConnectorImpl tppConnector, + CitizenConsentObjectToDTOMapper mapperToDTO, ExceptionMap exceptionMap) { + this.citizenRepository = citizenRepository; + this.bloomFilterService = bloomFilterService; + this.tppConnector = tppConnector; + this.mapperToDTO = mapperToDTO; + this.exceptionMap = exceptionMap; + } + + @Override + public Mono handleExistingConsent(CitizenConsent existingConsent, String tppId, CitizenConsent citizenConsent) { + if (existingConsent.getConsents().containsKey(tppId)) { + return Mono.just(mapperToDTO.map(citizenConsent)); + } else { + return validateTppAndUpdateConsent(existingConsent, tppId, citizenConsent); + } + } + + @Override + public Mono validateTppAndSaveConsent(String fiscalCode, String tppId, CitizenConsent citizenConsent) { + return tppConnector.get(tppId) + .onErrorMap(error -> exceptionMap.throwException(ExceptionName.TPP_NOT_FOUND, CitizenConstants.ExceptionMessage.TPP_NOT_FOUND)) + .flatMap(tppResponse -> { + if (Boolean.TRUE.equals(citizenConsent.getConsents().get(tppId).getTppState())) { + return citizenRepository.save(citizenConsent) + .doOnSuccess(savedConsent -> { + log.info("[EMD][CREATE-CITIZEN-CONSENT] Created new citizen consent for fiscal code: {}", Utils.createSHA256(fiscalCode)); + bloomFilterService.add(fiscalCode); + }) + .map(savedConsent -> mapperToDTO.map(citizenConsent)); + } else { + return Mono.error(exceptionMap.throwException(ExceptionName.TPP_NOT_FOUND, "TPP is not active or is invalid")); + } + }); + } + + + private Mono validateTppAndUpdateConsent(CitizenConsent existingConsent, String tppId, CitizenConsent citizenConsent) { + return tppConnector.get(tppId) + .onErrorMap(error -> exceptionMap.throwException(ExceptionName.TPP_NOT_FOUND, CitizenConstants.ExceptionMessage.TPP_NOT_FOUND)) + .flatMap(tppResponse -> { + existingConsent.getConsents().put(tppId, citizenConsent.getConsents().get(tppId)); + return citizenRepository.save(existingConsent) + .doOnSuccess(savedConsent -> log.info("[EMD][CREATE-CITIZEN-CONSENT] Updated citizen consent for TPP: {}", tppId)) + .map(savedConsent -> mapperToDTO.map(citizenConsent)); + }); + } +} diff --git a/src/main/resources/META-INF/openapi.yaml b/src/main/resources/META-INF/openapi.yaml new file mode 100644 index 0000000..06c9136 --- /dev/null +++ b/src/main/resources/META-INF/openapi.yaml @@ -0,0 +1,1306 @@ +openapi: 3.0.3 +info: + title: EMD CITIZEN CONSENT API + version: '1.0' + description: |- + EMD CITIZEN CONSENT + contact: + name: Stefano D'Elia + email: stefano11.delia@emeal.nttdata.com + +servers: + - description: Development Test + url: https://api-io.dev.cstar.pagopa.it/emd/citizen + x-internal: true + - description: User Acceptance Test + url: https://api-io.uat.cstar.pagopa.it/emd/citizen + x-internal: true + +security: + - bearerAuth: [ ] + +tags: + - name: Citizen consent + description: 'Citizen consent operation' +paths: + '/': + post: + tags: + - Citizen consent + summary: >- + ENG: Save citizen consent information - IT: Salvataggio dei consensi del cittadino + operationId: saveCitizenConsent + description: Save citizen consents + parameters: + - name: Accept-Language + in: header + description: 'ENG: Language - IT: Lingua' + schema: + type: string + pattern: "^[ -~]{2,5}$" + minLength: 2 + maxLength: 5 + example: it-IT + default: it-IT + required: true + requestBody: + description: 'ENG: Citizen consent details - IT: Dettagli consensi del cittadino' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentRequestDTO' + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentResponseDTO' + examples: + example1: + $ref: '#/components/examples/example1' + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_BAD_REQUEST + message: Something went wrong handling the request + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_AUTHENTICATION_FAILED + message: Something went wrong with authentication + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '404': + description: The citizen consent was not found + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_NOT_ONBOARDED + message: Citizen consent not inserted + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '429': + description: Too many Request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_TOO_MANY_REQUESTS + message: Too many requests + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '500': + description: Server ERROR + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_GENERIC_ERROR + message: Application error + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '/stateUpdate': + put: + tags: + - Citizen consent + summary: >- + ENG: Update citizen consent state - IT: Aggiornamento dello stato dei consensi del cittadino + operationId: updateState + description: Update citizen consents + parameters: + - name: Accept-Language + in: header + description: 'ENG: Language - IT: Lingua' + schema: + type: string + pattern: "^[ -~]{2,5}$" + minLength: 2 + maxLength: 5 + example: it-IT + default: it-IT + required: true + requestBody: + description: 'ENG: Citizen consent details - IT: Dettagli consensi del cittadino' + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentStateUpdateDTO' + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentResponseDTO' + examples: + example2: + $ref: '#/components/examples/example2' + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_BAD_REQUEST + message: Something went wrong handling the request + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_AUTHENTICATION_FAILED + message: Something went wrong with authentication + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '404': + description: The citizen consent was not found + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_NOT_ONBOARDED + message: Citizen consent not inserted + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '429': + description: Too many Request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_TOO_MANY_REQUESTS + message: Too many requests + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '500': + description: Server ERROR + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_GENERIC_ERROR + message: Application error + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '/{fiscalCode}/{tppId}': + get: + tags: + - Citizen consent + summary: >- + ENG: Returns the Citizen consent detalil from fiscalCode and tppId associated - IT: Ritorna il dettaglio dei consensi del cittadino tramite fiscalCode e tppId + operationId: getConsentStatus + description: Get citizen consent from fiscalCode and tppId + parameters: + - name: Accept-Language + in: header + description: 'ENG: Language - IT: Lingua' + schema: + type: string + pattern: "^[ -~]{2,5}$" + minLength: 2 + maxLength: 5 + example: it-IT + default: it-IT + required: true + - name: fiscalCode + in: path + description: 'ENG: Fiscal code of the citizen - IT: codice fiscale del cittadino' + required: true + schema: + type: string + description: "Fiscal Code or P.IVA of the citizen" + pattern: "^[A-Za-z0-9]{11,16}$" + minLength: 11 + maxLength: 16 + example: "RSSMRO92S18L048H" + - name: tppId + in: path + description: 'ENG: Unique ID that identify TPP on PagoPA systems - IT: Identificativo univoco della TPP sui sistemi PagoPA' + required: true + schema: + type: string + description: "Unique ID that identify TPP on PagoPA systems" + pattern: "^[ -~]{1,50}$" + minLength: 1 + maxLength: 50 + example: "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900" + responses: + '200': + description: Ok + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentResponseDTO' + examples: + example3: + $ref: '#/components/examples/example3' + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_BAD_REQUEST + message: Something went wrong handling the request + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_AUTHENTICATION_FAILED + message: Something went wrong with authentication + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '404': + description: The citizen consent was not found + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_NOT_ONBOARDED + message: Citizen consent not inserted + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '429': + description: Too many Request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_TOO_MANY_REQUESTS + message: Too many requests + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '500': + description: Server ERROR + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_GENERIC_ERROR + message: Application error + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + + '/list/{fiscalCode}': + get: + tags: + - Citizen consent + summary: >- + ENG: Returns the list of tpp associated with a specific citizen - IT: Restituisce la lista dei canali associati ad un determinato cittadino + operationId: get + description: Get citizen consents list from fiscalCode + parameters: + - name: Accept-Language + in: header + description: 'ENG: Language - IT: Lingua' + schema: + type: string + pattern: "^[ -~]{2,5}$" + minLength: 2 + maxLength: 5 + example: it-IT + default: it-IT + required: true + - name: fiscalCode + in: path + description: 'ENG: Fiscal code of the citizen - IT: codice fiscale del cittadino' + required: true + schema: + type: string + description: "Fiscal Code or P.IVA of the citizen" + pattern: "^[A-Za-z0-9]{11,16}$" + minLength: 11 + maxLength: 16 + example: "RSSMRO92S18L048H" + responses: + '200': + description: "A list of citizen consents" + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentResponseDTO' + examples: + example4: + $ref: '#/components/examples/example4' + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_BAD_REQUEST + message: Something went wrong handling the request + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_AUTHENTICATION_FAILED + message: Something went wrong with authentication + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '404': + description: The citizen consent was not found + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_NOT_ONBOARDED + message: Citizen consent not inserted + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '429': + description: Too many Request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_TOO_MANY_REQUESTS + message: Too many requests + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '500': + description: Server ERROR + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_GENERIC_ERROR + message: Application error + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + + '/list/{fiscalCode}/enabled': + get: + tags: + - Citizen consent + summary: >- + ENG: Returns the list of tpp enabled associated with a specific citizen - IT: Restituisce la lista dei canali abilitati associati ad un determinato cittadino + operationId: getTppEnabledList + description: Get citizen consents tppId enabled list from fiscalCode + parameters: + - name: Accept-Language + in: header + description: 'ENG: Language - IT: Lingua' + schema: + type: string + pattern: "^[ -~]{2,5}$" + minLength: 2 + maxLength: 5 + example: it-IT + default: it-IT + required: true + - name: fiscalCode + in: path + description: 'ENG: Fiscal code of the citizen - IT: codice fiscale del cittadino' + required: true + schema: + type: string + description: "Fiscal Code or P.IVA of the citizen" + pattern: "^[A-Za-z0-9]{11,16}$" + minLength: 11 + maxLength: 16 + example: "RSSMRO92S18L048H" + responses: + '200': + description: "Lista di tppId abilitati per il codice fiscale fornito" + content: + application/json: + schema: + type: array + items: + type: string + description: "Identificativo univoco di un TPP (tppId)" + pattern: "^[ -~]{1,50}$" + minLength: 1 + maxLength: 50 + maxItems: 100 + example: + - "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900" + - "bcdef234-5678-90ab-cdef-abcdef012345_1706869005678" + - "cafe1234-babe-5678-cafe-123456789abc_1706872007890" + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + + '400': + description: Bad request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_BAD_REQUEST + message: Something went wrong handling the request + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '401': + description: Authentication failed + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_AUTHENTICATION_FAILED + message: Something went wrong with authentication + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '404': + description: The citizen consent was not found + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_NOT_ONBOARDED + message: Citizen consent not inserted + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '429': + description: Too many Request + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_TOO_MANY_REQUESTS + message: Too many requests + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + '500': + description: Server ERROR + content: + application/json: + schema: + $ref: '#/components/schemas/CitizenConsentErrorDTO' + example: + code: CITIZEN_CONSENT_GENERIC_ERROR + message: Application error + headers: + Access-Control-Allow-Origin: + description: Indicates whether the response can be shared with requesting code from the given origin + required: false + schema: + $ref: '#/components/schemas/AccessControlAllowOrigin' + RateLimit-Limit: + description: The number of allowed requests in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitLimit' + RateLimit-Reset: + description: The number of seconds left in the current period + required: false + schema: + $ref: '#/components/schemas/RateLimitReset' + Retry-After: + description: The number of seconds to wait before allowing a follow-up request + required: false + schema: + $ref: '#/components/schemas/RetryAfter' + +components: + schemas: + + AccessControlAllowOrigin: + description: Indicates whether the response can be shared with requesting code from the given origin + type: string + pattern: "^[ -~]{1,2048}$" + minLength: 1 + maxLength: 2048 + + RateLimitLimit: + description: The number of allowed requests in the current period + type: integer + format: int32 + minimum: 1 + maximum: 240 + + RateLimitReset: + description: The number of seconds left in the current period + type: integer + format: int32 + minimum: 1 + maximum: 60 + + RetryAfter: + description: The number of seconds to wait before allowing a follow-up request + type: integer + format: int32 + minimum: 1 + maximum: 240 + + CitizenConsentRequestDTO: + type: object + description: "Schema di richiesta per aggiornare i consensi" + properties: + fiscalCode: + type: string + example: "RSSMRO92S18L048H" + description: "Codice fiscale dell'utente" + pattern: "^[A-Za-z0-9]{11,16}$" + minLength: 11 + maxLength: 16 + consents: + type: object + description: "Oggetto contenente consensi con chiavi dinamiche basate su tppId (UUID concatenati con timestamp)" + additionalProperties: + type: object + properties: + tppState: + type: boolean + description: "Stato del consenso TPP" + example: true + required: + - tppState + example: + "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900": + tppState: true + "a12cd3e4-5678-90ab-cdef-1234567890ab_1706868990123": + tppState: true + + required: + - fiscalCode + - consents + + + + CitizenConsentResponseDTO: + type: object + description: "Schema di risposta che contiene lo stato e la data dei consensi" + properties: + fiscalCode: + type: string + example: "RSSMRO92S18L048H" + description: "Codice fiscale del cittadino" + pattern: "^[A-Za-z0-9]{11,16}$" + minLength: 11 + maxLength: 16 + consents: + type: object + description: "Oggetto contenente consensi con chiavi dinamiche basate su tppId (UUID concatenati con timestamp)" + additionalProperties: + type: object + properties: + tppState: + type: boolean + description: "Stato del consenso TPP" + example: true + tcDate: + type: string + format: date-time + description: "Data e ora dell'ultimo aggiornamento del consenso" + example: "2024-11-01T11:25:40.695Z" + maxLength: 30 + pattern: "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$" + example: + "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900": + tppState: true + tcDate: "2024-11-01T11:25:40.695Z" + "a12cd3e4-5678-90ab-cdef-1234567890ab_1706868990123": + tppState: true + tcDate: "2024-11-05T11:25:40.695Z" + required: + - fiscalCode + - consents + + + CitizenConsentStateUpdateDTO: + type: object + description: "Schema che permette la modifica dei consensi del cittadino" + properties: + fiscalCode: + type: string + example: "RSSMRO92S18L048H" + description: "Codice fiscale del cittadino" + pattern: "^[A-Za-z0-9]{11,16}$" + minLength: 11 + maxLength: 16 + tppId: + type: string + example: "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900" + description: "ENG: Unique ID that identify TPP on PagoPA systems - IT: Identificativo univoco della TPP sui sistemi PagoPA" + pattern: "^[ -~]{1,50}$" + minLength: 1 + maxLength: 50 + tppState: + type: boolean + example: false + description: "Stato del consenso TPP" + + CitizenConsentErrorDTO: + type: object + required: + - code + - message + properties: + code: + type: string + enum: + - CITIZEN_CONSENT_BAD_REQUEST + - CITIZEN_CONSENT_NOT_ONBOARDED + - CITIZEN_CONSENT_TOO_MANY_REQUESTS + - CITIZEN_CONSENT_GENERIC_ERROR + - CITIZEN_CONSENT_AUTHENTICATION_FAILED + description: |- + "ENG: Error code: CITIZEN_CONSENT_BAD_REQUEST: Something went wrong handling the request, + CITIZEN_CONSENT_NOT_ONBOARDED: Citizen consent not inserted, + CITIZEN_CONSENT_TOO_MANY_REQUESTS: Too many requests, + CITIZEN_CONSENT_GENERIC_ERROR: Application Error, + CITIZEN_CONSENT_AUTHENTICATION_FAILED: Something went wrong with authentication - + IT: Codice di errore: + CITIZEN_CONSENT_BAD_REQUEST: Qualcosa è andato storto durante + l'invio della richiesta, + CITIZEN_CONSENT_NOT_ONBOARDED: Consensi del cittadino non inseriti, + CITIZEN_CONSENT_TOO_MANY_REQUESTS: Troppe richieste, + CITIZEN_CONSENT_GENERIC_ERROR: Errore generico, + CITIZEN_CONSENT_AUTHENTICATION_FAILED: Qualcosa è andato storto con l'autenticazione" + message: + type: string + description: 'ENG: Error message - IT: Messaggio di errore' + maxLength: 250 + pattern: "^[\\w\\s.,!?'\"-]+$" + + examples: + example1: + summary: "Esempio con più consensi" + value: + fiscalCode: "RSSMRO92S18L048H" + consents: + "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900": + tppState: true + tcDate: "2024-11-01T11:25:40.695Z" + "a12cd3e4-5678-90ab-cdef-1234567890ab_1706868990123": + tppState: true + tcDate: "2024-11-05T11:25:40.695Z" + example2: + summary: "Esempio con un solo consenso disattivo" + value: + fiscalCode: "RSSMRO92S18L048H" + consents: + "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900": + tppState: false + tcDate: "2024-11-06T11:25:40.695Z" + example3: + summary: "Esempio con un solo consenso attivo" + value: + fiscalCode: "RSSMRO92S18L048H" + consents: + "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900": + tppState: true + tcDate: "2024-11-06T11:25:40.695Z" + example4: + summary: "Esempio con un consenso attivo e uno non attivo" + value: + fiscalCode: "RSSMRO92S18L048H" + consents: + "0e3bee29-8753-447c-b0da-1f7965558ec2_1706867960900": + tppState: true + tcDate: "2024-11-01T11:25:40.695Z" + "a12cd3e4-5678-90ab-cdef-1234567890ab_1706868990123": + tppState: false + tcDate: "2024-11-05T11:25:40.695Z" + securitySchemes: + bearerAuth: + type: http + scheme: bearer diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..74fb1cc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,43 @@ +spring: + jackson: + time-zone: + Europe/Rome + application: + name: "@project.artifactId@" + version: "@project.version@" + jmx.enabled: true + config: + activate: + on-profile: default + data: + mongodb: + database: ${MONGODB_DBNAME:mil} + uri: ${MONGODB_URI:mongodb://localhost:27017} + config: + connectionPool: + maxSize: ${MONGODB_CONNECTIONPOOL_MAX_SIZE:100} + minSize: ${MONGODB_CONNECTIONPOOL_MIN_SIZE:5} + maxWaitTimeMS: ${MONGODB_CONNECTIONPOOL_MAX_WAIT_MS:120000} + maxConnectionLifeTimeMS: ${MONGODB_CONNECTIONPOOL_MAX_CONNECTION_LIFE_MS:0} + maxConnectionIdleTimeMS: ${MONGODB_CONNECTIONPOOL_MAX_CONNECTION_IDLE_MS:120000} + maxConnecting: ${MONGODB_CONNECTIONPOOL_MAX_CONNECTING:2} + + +management: + health: + mongo.enabled: ${HEALTH_MONGO_ENABLED:false} + endpoint: + health: + show-details: always + probes.enabled: true + group: + readiness.include: "*" + liveness.include: livenessState,diskSpace,ping + endpoints: + jmx: + exposure.include: "*" + web: + exposure.include: info, health +rest-client: + tpp: + baseUrl: ${EMD_TPP:http://emd-tpp} \ No newline at end of file diff --git a/src/test/java/it/gov/pagopa/common/configuration/CustomMongoHealthIndicatorTest.java b/src/test/java/it/gov/pagopa/common/configuration/CustomMongoHealthIndicatorTest.java new file mode 100644 index 0000000..8b0b8c5 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/configuration/CustomMongoHealthIndicatorTest.java @@ -0,0 +1,50 @@ +package it.gov.pagopa.common.configuration; + +import com.mongodb.MongoException; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.Status; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + + +class CustomMongoHealthIndicatorTest { + @Test + void testMongoIsUp() { + Document buildInfo = mock(Document.class); + given(buildInfo.getInteger("maxWireVersion")).willReturn(10); + ReactiveMongoTemplate reactiveMongoTemplate = mock(ReactiveMongoTemplate.class); + given(reactiveMongoTemplate.executeCommand("{ isMaster: 1 }")).willReturn(Mono.just(buildInfo)); + CustomReactiveMongoHealthIndicator customReactiveMongoHealthIndicator = new CustomReactiveMongoHealthIndicator( + reactiveMongoTemplate); + Mono health = customReactiveMongoHealthIndicator.health(); + StepVerifier.create(health).consumeNextWith(h -> { + assertThat(h.getStatus()).isEqualTo(Status.UP); + assertThat(h.getDetails()).containsOnlyKeys("maxWireVersion"); + assertThat(h.getDetails()).containsEntry("maxWireVersion", 10); + }).expectComplete().verify(Duration.ofSeconds(30)); + } + + @Test + void testMongoIsDown() { + ReactiveMongoTemplate reactiveMongoTemplate = mock(ReactiveMongoTemplate.class); + given(reactiveMongoTemplate.executeCommand("{ isMaster: 1 }")).willThrow(new MongoException("Connection failed")); + CustomReactiveMongoHealthIndicator customReactiveMongoHealthIndicator = new CustomReactiveMongoHealthIndicator( + reactiveMongoTemplate); + Mono health = customReactiveMongoHealthIndicator.health(); + StepVerifier.create(health).consumeNextWith(h -> { + assertThat(h.getStatus()).isEqualTo(Status.DOWN); + assertThat(h.getDetails()).containsOnlyKeys("error"); + assertThat(h.getDetails()).containsEntry("error", MongoException.class.getName() + ": Connection failed"); + }).expectComplete().verify(Duration.ofSeconds(30)); + } + +} diff --git a/src/test/java/it/gov/pagopa/common/configuration/MongoConfigTest.java b/src/test/java/it/gov/pagopa/common/configuration/MongoConfigTest.java new file mode 100644 index 0000000..242c5fb --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/configuration/MongoConfigTest.java @@ -0,0 +1,111 @@ +package it.gov.pagopa.common.configuration; + +import com.mongodb.MongoClientSettings; +import it.gov.pagopa.common.utils.CommonConstants; +import org.bson.types.Decimal128; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.mongo.MongoClientSettingsBuilderCustomizer; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(classes = { + MongoConfig.class +}) +@TestPropertySource(properties = { + "spring.data.mongodb.config.connectionPool.maxSize=50", + "spring.data.mongodb.config.connectionPool.minSize=5", + "spring.data.mongodb.config.connectionPool.maxWaitTimeMS=1000", + "spring.data.mongodb.config.connectionPool.maxConnectionLifeTimeMS=60000", + "spring.data.mongodb.config.connectionPool.maxConnectionIdleTimeMS=30000", + "spring.data.mongodb.config.connectionPool.maxConnecting=2" +}) +class MongoConfigTest { + + @Autowired + private MongoConfig.MongoDbCustomProperties mongoDbCustomProperties; + + + @Test + void testConnectionPoolSettings() { + assertThat(mongoDbCustomProperties).isNotNull(); + assertThat(mongoDbCustomProperties.getConnectionPool()).isNotNull(); + + Assertions.assertEquals(1000L, mongoDbCustomProperties.getConnectionPool().getMaxWaitTimeMS()); + Assertions.assertEquals(2, mongoDbCustomProperties.getConnectionPool().getMaxConnecting()); + Assertions.assertEquals(5L, mongoDbCustomProperties.getConnectionPool().getMinSize()); + Assertions.assertEquals(50L, mongoDbCustomProperties.getConnectionPool().getMaxSize()); + Assertions.assertEquals(60000, mongoDbCustomProperties.getConnectionPool().getMaxConnectionLifeTimeMS()); + Assertions.assertEquals(30000, mongoDbCustomProperties.getConnectionPool().getMaxConnectionIdleTimeMS()); + } + + @Test + void testCustomizer() { + MongoClientSettingsBuilderCustomizer customizer = new MongoConfig().customizer(mongoDbCustomProperties); + + MongoClientSettings.Builder builder = MongoClientSettings.builder(); + customizer.customize(builder); + + MongoClientSettings settings = builder.build(); + + Assertions.assertEquals(mongoDbCustomProperties.getConnectionPool().getMaxSize(), settings.getConnectionPoolSettings().getMaxSize()); + Assertions.assertEquals(mongoDbCustomProperties.getConnectionPool().getMinSize(), settings.getConnectionPoolSettings().getMinSize()); + Assertions.assertEquals(mongoDbCustomProperties.getConnectionPool().getMaxWaitTimeMS(), settings.getConnectionPoolSettings().getMaxWaitTime(TimeUnit.MILLISECONDS)); + Assertions.assertEquals(mongoDbCustomProperties.getConnectionPool().getMaxConnectionLifeTimeMS(), settings.getConnectionPoolSettings().getMaxConnectionLifeTime(TimeUnit.MILLISECONDS)); + Assertions.assertEquals(mongoDbCustomProperties.getConnectionPool().getMaxConnectionIdleTimeMS(), settings.getConnectionPoolSettings().getMaxConnectionIdleTime(TimeUnit.MILLISECONDS)); + Assertions.assertEquals(mongoDbCustomProperties.getConnectionPool().getMaxConnecting(), settings.getConnectionPoolSettings().getMaxConnecting()); + } + + @Test + void testBigDecimalToDecimal128Conversion() { + BigDecimal bigDecimal = new BigDecimal("12345.6789"); + + MongoConfig.BigDecimalDecimal128Converter converter = new MongoConfig.BigDecimalDecimal128Converter(); + Decimal128 decimal128 = converter.convert(bigDecimal); + + Assertions.assertEquals(new Decimal128(bigDecimal), decimal128); + } + + @Test + void testDecimal128ToBigDecimalConversion() { + BigDecimal bigDecimal = new BigDecimal("12345.6789"); + Decimal128 decimal128 = new Decimal128(bigDecimal); + + MongoConfig.Decimal128BigDecimalConverter converter = new MongoConfig.Decimal128BigDecimalConverter(); + BigDecimal result = converter.convert(decimal128); + + Assertions.assertEquals(bigDecimal, result); + } + + @Test + void testOffsetDateTimeToDateConversion() { + OffsetDateTime offsetDateTime = OffsetDateTime.now(); + + MongoConfig.OffsetDateTimeWriteConverter converter = new MongoConfig.OffsetDateTimeWriteConverter(); + Date date = converter.convert(offsetDateTime); + + Assertions.assertEquals(Date.from(offsetDateTime.toInstant()), date); + } + + @Test + void testDateToOffsetDateTimeConversion() { + Date date = new Date(); + + MongoConfig.OffsetDateTimeReadConverter converter = new MongoConfig.OffsetDateTimeReadConverter(); + OffsetDateTime offsetDateTime = converter.convert(date); + + + Assertions.assertEquals(date.toInstant().atZone(CommonConstants.ZONEID).toOffsetDateTime(), offsetDateTime); + } + + +} + diff --git a/src/test/java/it/gov/pagopa/common/reactive/kafka/BaseKafkaConsumerTest.java b/src/test/java/it/gov/pagopa/common/reactive/kafka/BaseKafkaConsumerTest.java new file mode 100644 index 0000000..c1a0f70 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/reactive/kafka/BaseKafkaConsumerTest.java @@ -0,0 +1,87 @@ +package it.gov.pagopa.common.reactive.kafka; + +import it.gov.pagopa.common.kafka.utils.KafkaConstants; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.kafka.support.KafkaHeaders; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.nio.charset.StandardCharsets; + +class BaseKafkaConsumerTest { + + private TestKafkaConsumer testConsumer; + + @BeforeEach + void setUp() { + testConsumer = new TestKafkaConsumer("test-app"); + } + + @Test + void testExecuteSuccess() { + + Flux> messageFlux = Flux.just( + createMessage("message1"), + createMessage("message2"), + createMessage("message3") + ); + + + StepVerifier.create(Flux.defer(() -> { + testConsumer.execute(messageFlux); + return messageFlux.map(Message::getPayload); + })) + .expectNext("{\"message\": \"message1\"}", "{\"message\": \"message2\"}", "{\"message\": \"message3\"}") + .verifyComplete(); + } + + @Test + void testExecuteWithErrorMessage() { + + Flux> messageFlux = Flux.just( + createMessage("message1"), + createMessage("error"), + createMessage("message2") + ); + + + StepVerifier.create(Flux.defer(() -> { + testConsumer.execute(messageFlux); + return messageFlux.map(Message::getPayload); + })) + .expectNext("{\"message\": \"message1\"}", "{\"message\": \"error\"}", "{\"message\": \"message2\"}") + .verifyComplete(); + } + + @Test + void testDiscardForeignMessages() { + + Message foreignMessage = MessageBuilder.withPayload("foreign-message") + .setHeader(KafkaConstants.ERROR_MSG_HEADER_APPLICATION_NAME, "other-app".getBytes(StandardCharsets.UTF_8)) + .setHeader(KafkaHeaders.RECEIVED_PARTITION, 0) + .setHeader(KafkaHeaders.OFFSET, 1L) + .build(); + + Flux> messageFlux = Flux.just(foreignMessage); + + StepVerifier.create(Flux.defer(() -> { + testConsumer.execute(messageFlux); + return messageFlux.map(Message::getPayload); + })) + .expectNext("foreign-message") + .verifyComplete(); + } + + private Message createMessage(String payload) { + String jsonPayload = "{\"message\": \"" + payload + "\"}"; + return MessageBuilder.withPayload(jsonPayload) + .setHeader(KafkaHeaders.RECEIVED_PARTITION, 0) + .setHeader(KafkaHeaders.OFFSET, 1L) + .setHeader(KafkaConstants.ERROR_MSG_HEADER_APPLICATION_NAME, "test-app".getBytes(StandardCharsets.UTF_8)) + .build(); + } + +} \ No newline at end of file diff --git a/src/test/java/it/gov/pagopa/common/reactive/kafka/TestKafkaConsumer.java b/src/test/java/it/gov/pagopa/common/reactive/kafka/TestKafkaConsumer.java new file mode 100644 index 0000000..3842340 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/reactive/kafka/TestKafkaConsumer.java @@ -0,0 +1,51 @@ +package it.gov.pagopa.common.reactive.kafka; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; +import it.gov.pagopa.common.reactive.kafka.consumer.BaseKafkaConsumer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.Message; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +@Slf4j + +class TestKafkaConsumer extends BaseKafkaConsumer { + + protected TestKafkaConsumer(String applicationName) { + super(applicationName); + } + + @Override + protected Duration getCommitDelay() { + return Duration.ofMillis(500); + } + @Override + protected void subscribeAfterCommits(Flux> afterCommits2subscribe) { + afterCommits2subscribe + .buffer(getCommitDelay()) + .subscribe(r -> log.info("Processed offsets committed successfully")); + } + @Override + protected ObjectReader getObjectReader() { + return new ObjectMapper().readerFor(Map.class); + } + @Override + protected Consumer onDeserializationError(Message message) { + return e -> log.info("Unexpected JSON : {}", e.getMessage()); + } + + @Override + protected Mono execute(String payload, Message message, Map ctx) { + if ("error".equals(payload)) { + return Mono.error(new RuntimeException("Error")); + } + return Mono.just("Processed: " + payload); + } +} + diff --git a/src/test/java/it/gov/pagopa/common/reactive/utils/PerformanceLoggerTest.java b/src/test/java/it/gov/pagopa/common/reactive/utils/PerformanceLoggerTest.java new file mode 100644 index 0000000..7e64831 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/reactive/utils/PerformanceLoggerTest.java @@ -0,0 +1,71 @@ +package it.gov.pagopa.common.reactive.utils; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import java.util.function.Function; + +class PerformanceLoggerTest { + +//region mono ops + @Test + void testMonoLogTimingOnNext(){ + testMonoLogTimingOnNext(null); + testMonoLogTimingOnNext(Object::toString); + } + + private static void testMonoLogTimingOnNext(Function data2LogPayload) { + boolean[] result=new boolean[]{false}; + Mono mono = PerformanceLogger.logTimingOnNext("PROVA", Mono.defer(() -> Mono.just(result[0] = true)), data2LogPayload); + Assertions.assertFalse(result[0]); + mono.block(); + Assertions.assertTrue(result[0]); + } + + @Test + void testMonoLogTimingFinally(){ + testMonoLogTimingFinally(null); + testMonoLogTimingFinally("END"); + } + + private static void testMonoLogTimingFinally(String logPayload) { + boolean[] result=new boolean[]{false}; + Mono mono = PerformanceLogger.logTimingFinally("PROVA", Mono.defer(() -> Mono.just(result[0] = true)), logPayload); + Assertions.assertFalse(result[0]); + mono.block(); + Assertions.assertTrue(result[0]); + } +//endregion + + //region flux ops + @Test + void testFluxLogTimingOnNext(){ + testFluxLogTimingOnNext(null); + testFluxLogTimingOnNext(Object::toString); + } + + private static void testFluxLogTimingOnNext(Function data2LogPayload) { + boolean[] result=new boolean[]{false}; + Flux flux = PerformanceLogger.logTimingOnNext("PROVA", Flux.defer(() -> Flux.just(result[0] = true)), data2LogPayload); + Assertions.assertFalse(result[0]); + flux.collectList().block(); + Assertions.assertTrue(result[0]); + } + + @Test + void testFluxLogTimingFinally(){ + testFluxLogTimingFinally(null); + testFluxLogTimingFinally("END"); + } + + private static void testFluxLogTimingFinally(String logPayload) { + boolean[] result=new boolean[]{false}; + Flux flux = PerformanceLogger.logTimingFinally("PROVA", Flux.defer(() -> Flux.just(result[0] = true)), logPayload); + Assertions.assertFalse(result[0]); + flux.collectList().block(); + Assertions.assertTrue(result[0]); + } +//endregion +} diff --git a/src/test/java/it/gov/pagopa/common/utils/CommonUtilitiesTest.java b/src/test/java/it/gov/pagopa/common/utils/CommonUtilitiesTest.java new file mode 100644 index 0000000..624d754 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/utils/CommonUtilitiesTest.java @@ -0,0 +1,128 @@ +package it.gov.pagopa.common.utils; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectReader; +import it.gov.pagopa.common.web.exception.EmdEncryptionException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageHeaders; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CommonUtilitiesTest { + + @Mock + private Message messageMock; + @Mock + private ObjectReader objectReaderMock; + + @Test + void createSHA256_Ko_NoSuchAlgorithm() { + try (MockedStatic mockedStatic = Mockito.mockStatic(MessageDigest.class)) { + mockedStatic.when(() -> MessageDigest.getInstance(any())) + .thenThrow(new NoSuchAlgorithmException("SHA-256 not available")); + + EmdEncryptionException exception = assertThrows(EmdEncryptionException.class, () -> CommonUtilities.createSHA256("")); + + assertEquals("SHA-256 not available", exception.getCause().getMessage()); + } + } + + @Test + void createSHA256_Ok() { + String toHash = "RSSMRA98B18L049O"; + String hashedExpected = "0b393cbe68a39f26b90c80a8dc95abc0fe4c21821195b4671a374c1443f9a1bb"; + String actualHash = CommonUtilities.createSHA256(toHash); + assertEquals(hashedExpected, actualHash); + } + + @Test + void deserializeMessage_Ko_JsonProcessingException() { + Consumer errorHandler = e -> Assertions.assertTrue(e instanceof JsonProcessingException); + when(messageMock.getPayload()).thenReturn("invalid payload"); + + try { + when(objectReaderMock.readValue("invalid payload")).thenThrow(new JsonProcessingException("Invalid JSON") {}); + + // Act + Object result = CommonUtilities.deserializeMessage(messageMock, objectReaderMock, errorHandler); + + // Assert + Assertions.assertNull(result); + } catch (JsonProcessingException e) { + Assertions.fail("Exception should be handled in the method"); + } + } + + @Test + void deserializeMessage_Ok() { + // Setup + String validJson = "{\"name\":\"John\"}"; + MyObject expectedObject = new MyObject("John"); + Consumer errorHandler = e -> Assertions.fail("Should not have thrown an error"); + + when(messageMock.getPayload()).thenReturn(validJson); + try { + when(objectReaderMock.readValue(validJson)).thenReturn(expectedObject); + + MyObject result = CommonUtilities.deserializeMessage(messageMock, objectReaderMock, errorHandler); + + Assertions.assertNotNull(result); + assertEquals(expectedObject.name(), result.name()); + } catch (JsonProcessingException e) { + Assertions.fail("Exception should not be thrown"); + } + } + + @Test + void readMessagePayload_StringPayload() { + String expectedPayload = "test message"; + when(messageMock.getPayload()).thenReturn(expectedPayload); + + String actualPayload = CommonUtilities.readMessagePayload(messageMock); + + assertEquals(expectedPayload, actualPayload); + } + + @Test + void getHeaderValue_Test() { + String headerName = "testHeader"; + String headerValue = "headerValue"; + MessageHeaders headers = new MessageHeaders(Map.of(headerName, headerValue)); + + when(messageMock.getHeaders()).thenReturn(headers); + + Object result = CommonUtilities.getHeaderValue(messageMock, headerName); + + assertEquals(headerValue, result); + } + + @Test + void getByteArrayHeaderValue_Test() { + String headerName = "byteArrayHeader"; + String headerValue = "headerValue"; + MessageHeaders headers = new MessageHeaders(Map.of(headerName, headerValue.getBytes(StandardCharsets.UTF_8))); + when(messageMock.getHeaders()).thenReturn(headers); + + String result = CommonUtilities.getByteArrayHeaderValue(messageMock, headerName); + + assertEquals(headerValue, result); + } + record MyObject(String name) { } +} diff --git a/src/test/java/it/gov/pagopa/common/utils/MemoryAppender.java b/src/test/java/it/gov/pagopa/common/utils/MemoryAppender.java new file mode 100644 index 0000000..fb46b58 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/utils/MemoryAppender.java @@ -0,0 +1,27 @@ +package it.gov.pagopa.common.utils; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +import java.util.Collections; +import java.util.List; + +public class MemoryAppender extends ListAppender { + public void reset() { + this.list.clear(); + } + + public boolean contains(ch.qos.logback.classic.Level level, String string) { + return this.list.stream() + .anyMatch(event -> event.toString().contains(string) + && event.getLevel().equals(level)); + } + + public int getSize() { + return this.list.size(); + } + + public List getLoggedEvents() { + return Collections.unmodifiableList(this.list); + } +} diff --git a/src/test/java/it/gov/pagopa/common/utils/UtilsTest.java b/src/test/java/it/gov/pagopa/common/utils/UtilsTest.java new file mode 100644 index 0000000..f2534a1 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/utils/UtilsTest.java @@ -0,0 +1,42 @@ +package it.gov.pagopa.common.utils; + + + +import it.gov.pagopa.common.web.exception.EmdEncryptionException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; + +@ExtendWith(MockitoExtension.class) +class UtilsTest { + + + @Test + void createSHA256_Ko_NoSuchAlgorithm() { + try (MockedStatic mockedStatic = Mockito.mockStatic(MessageDigest.class)) { + mockedStatic.when(() -> MessageDigest.getInstance(any())) + .thenThrow(new NoSuchAlgorithmException("SHA-256 not available")); + + EmdEncryptionException exception = assertThrows(EmdEncryptionException.class, () -> Utils.createSHA256("")); + + assertEquals("SHA-256 not available", exception.getCause().getMessage()); + } + } + @Test + void createSHA256_Ok(){ + String toHash = "RSSMRA98B18L049O"; + String hashedExpected = "0b393cbe68a39f26b90c80a8dc95abc0fe4c21821195b4671a374c1443f9a1bb"; + String actualHash = Utils.createSHA256(toHash); + assertEquals(actualHash,hashedExpected); + } + +} diff --git a/src/test/java/it/gov/pagopa/common/web/exception/ErrorManagerTest.java b/src/test/java/it/gov/pagopa/common/web/exception/ErrorManagerTest.java new file mode 100644 index 0000000..03247b9 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/web/exception/ErrorManagerTest.java @@ -0,0 +1,228 @@ +package it.gov.pagopa.common.web.exception; + +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.classic.spi.IThrowableProxy; +import ch.qos.logback.classic.spi.StackTraceElementProxy; +import it.gov.pagopa.common.utils.MemoryAppender; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.regex.Pattern; + +@WebFluxTest(value = { + ErrorManagerTest.TestController.class}, excludeAutoConfiguration = SecurityAutoConfiguration.class) +@ContextConfiguration(classes = {ErrorManagerTest.TestController.class, ErrorManager.class}) +@Slf4j +class ErrorManagerTest { + private static final String EXPECTED_GENERIC_ERROR = "{\"code\":\"Error\",\"message\":\"Something gone wrong\"}"; + + @Autowired + private WebTestClient webTestClient; + @SpyBean + private TestController testControllerSpy; + + private static MemoryAppender memoryAppender; + + @RestController + static class TestController { + @GetMapping("/test") + String testEndpoint() { + return "OK"; + } + } + + @BeforeAll + static void configureMemoryAppender() { + memoryAppender = new MemoryAppender(); + memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + memoryAppender.start(); + } + + @BeforeEach + void clearMemoryAppender() { + memoryAppender.reset(); + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ErrorManager.class.getName()); + logger.setLevel(ch.qos.logback.classic.Level.INFO); + logger.addAppender(memoryAppender); + } + + @Test + void handleExceptionClientExceptionNoBody() { + Mockito.doThrow( + new ClientExceptionNoBody(HttpStatus.BAD_REQUEST, "NOTFOUND ClientExceptionNoBody")) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest(); + + checkStackTraceSuppressedLog(memoryAppender, + "A ClientExceptionNoBody occurred handling request GET /test: HttpStatus 400 BAD_REQUEST - NOTFOUND ClientExceptionNoBody at it.gov.pagopa.common.web.exception.ErrorManagerTest\\$TestController.testEndpoint\\(ErrorManagerTest.java:[0-9]+\\)"); + + memoryAppender.reset(); + + Throwable throwable = new Exception("Cause of the exception"); + + Mockito.doThrow( + new ClientExceptionNoBody(HttpStatus.BAD_REQUEST, "ClientExceptionNoBody with Throwable", throwable)) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest(); + + checkStackTraceSuppressedLog(memoryAppender, + "Something went wrong handling request GET /test: HttpStatus 400 BAD_REQUEST - ClientExceptionNoBody with Throwable"); + + memoryAppender.reset(); + + Mockito.doThrow( + new ClientExceptionNoBody(HttpStatus.BAD_REQUEST, "ClientExceptionNoBody", true, throwable)) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest(); + + checkStackTraceSuppressedLog(memoryAppender, + "Something went wrong handling request GET /test: HttpStatus 400 BAD_REQUEST - ClientExceptionNoBody"); + + } + + @Test + void handleExceptionClientExceptionWithBody() { + Mockito.doThrow( + new ClientExceptionWithBody(HttpStatus.BAD_REQUEST, "Error", "Error ClientExceptionWithBody")) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody() + .json("{\"code\":\"Error\",\"message\":\"Error ClientExceptionWithBody\"}"); + + Mockito.doThrow( + new ClientExceptionWithBody(HttpStatus.BAD_REQUEST, "Error", "Error ClientExceptionWithBody", + new Exception())) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody() + .json("{\"code\":\"Error\",\"message\":\"Error ClientExceptionWithBody\"}"); + } + + @Test + void handleExceptionClientExceptionTest() { + Mockito.doThrow(ClientException.class) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .json(EXPECTED_GENERIC_ERROR); + + checkStackTraceSuppressedLog(memoryAppender, "A ClientException occurred handling request GET /test: HttpStatus null - null at UNKNOWN"); + memoryAppender.reset(); + + Mockito.doThrow( + new ClientException(HttpStatus.BAD_REQUEST, "ClientException with httpStatus and message")) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .json(EXPECTED_GENERIC_ERROR); + + checkStackTraceSuppressedLog(memoryAppender, "A ClientException occurred handling request GET /test: HttpStatus 400 BAD_REQUEST - ClientException with httpStatus and message at it.gov.pagopa.common.web.exception.ErrorManagerTest\\$TestController.testEndpoint\\(ErrorManagerTest.java:[0-9]+\\)"); + memoryAppender.reset(); + + Mockito.doThrow(new ClientException(HttpStatus.BAD_REQUEST, + "ClientException with httpStatus, message and throwable", new Throwable())) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .json(EXPECTED_GENERIC_ERROR); + + checkLog(memoryAppender, + "Something went wrong handling request GET /test: HttpStatus 400 BAD_REQUEST - ClientException with httpStatus, message and throwable", + "it.gov.pagopa.common.web.exception.ClientException: ClientException with httpStatus, message and throwable", + "it.gov.pagopa.common.web.exception.ErrorManagerTest$TestController.testEndpoint" + ); + } + + @Test + void handleExceptionRuntimeException() { + Mockito.doThrow(RuntimeException.class) + .when(testControllerSpy).testEndpoint(); + + webTestClient.get() + .uri("/test") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .json(EXPECTED_GENERIC_ERROR); + } + + public static void checkStackTraceSuppressedLog(MemoryAppender memoryAppender, String expectedLoggedMessage) { + String loggedMessage = memoryAppender.getLoggedEvents().get(0).getFormattedMessage(); + Assertions.assertTrue(Pattern.matches(expectedLoggedMessage, loggedMessage), + "Unexpected logged message: " + loggedMessage); + } + + public static void checkLog(MemoryAppender memoryAppender, String expectedLoggedMessageRegexp, String expectedLoggedExceptionMessage, String expectedLoggedExceptionOccurrencePosition) { + ILoggingEvent loggedEvent = memoryAppender.getLoggedEvents().get(0); + IThrowableProxy loggedException = loggedEvent.getThrowableProxy(); + StackTraceElementProxy loggedExceptionOccurrenceStackTrace = loggedException.getStackTraceElementProxyArray()[0]; + + String loggedMessage = loggedEvent.getFormattedMessage(); + Assertions.assertTrue(Pattern.matches(expectedLoggedMessageRegexp, + loggedEvent.getFormattedMessage()), + "Unexpected logged message: " + loggedMessage); + + Assertions.assertEquals(expectedLoggedExceptionMessage, + loggedException.getClassName() + ": " + loggedException.getMessage()); + + Assertions.assertEquals(expectedLoggedExceptionOccurrencePosition, + loggedExceptionOccurrenceStackTrace.getStackTraceElement().getClassName() + "." + loggedExceptionOccurrenceStackTrace.getStackTraceElement().getMethodName()); + } +} diff --git a/src/test/java/it/gov/pagopa/common/web/exception/ServiceExceptionHandlerTest.java b/src/test/java/it/gov/pagopa/common/web/exception/ServiceExceptionHandlerTest.java new file mode 100644 index 0000000..0896c70 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/web/exception/ServiceExceptionHandlerTest.java @@ -0,0 +1,104 @@ +package it.gov.pagopa.common.web.exception; + +import ch.qos.logback.classic.LoggerContext; +import it.gov.pagopa.common.utils.MemoryAppender; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@WebFluxTest(value = { + ServiceExceptionHandlerTest.TestController.class}, excludeAutoConfiguration = SecurityAutoConfiguration.class) +@ContextConfiguration(classes = {ServiceExceptionHandler.class, + ServiceExceptionHandlerTest.TestController.class, ErrorManager.class}) +class ServiceExceptionHandlerTest { + @Autowired + private WebTestClient webTestClient; + + private static MemoryAppender memoryAppender; + + @BeforeAll + static void configureMemoryAppender(){ + memoryAppender = new MemoryAppender(); + memoryAppender.setContext((LoggerContext) LoggerFactory.getILoggerFactory()); + memoryAppender.start(); + } + + @BeforeEach + void clearMemoryAppender(){ + memoryAppender.reset(); + + ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ErrorManager.class.getName()); + logger.setLevel(ch.qos.logback.classic.Level.INFO); + logger.addAppender(memoryAppender); + } + + @RestController + @Slf4j + static class TestController { + + @GetMapping("/test") + String test() { + throw new ServiceException("DUMMY_CODE", "DUMMY_MESSAGE"); + } + + @GetMapping("/test/customBody") + String testCustomBody() { + throw new ServiceException("DUMMY_CODE", "DUMMY_MESSAGE", new ErrorPayloadTest("RESPONSE",0), true, null); + } + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class ErrorPayloadTest implements ServiceExceptionPayload { + private String stringCode; + private long longCode; + } + + @Test + void testSimpleException(){ + webTestClient.method(HttpMethod.GET) + .uri("/test") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .json("{\"code\":\"DUMMY_CODE\",\"message\":\"DUMMY_MESSAGE\"}", false); + + ErrorManagerTest.checkStackTraceSuppressedLog(memoryAppender, "A ServiceException occurred handling request GET /test: HttpStatus 500 INTERNAL_SERVER_ERROR - DUMMY_CODE: DUMMY_MESSAGE at it.gov.pagopa.common.web.exception.ServiceExceptionHandlerTest\\$TestController.test\\(ServiceExceptionHandlerTest.java:[0-9]+\\)"); + + } + + @Test + void testCustomBodyException(){ + + webTestClient.method(HttpMethod.GET) + .uri("/test/customBody") + .contentType(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().is5xxServerError() + .expectBody() + .json("{\"stringCode\":\"RESPONSE\",\"longCode\":0}", false); + + ErrorManagerTest.checkLog(memoryAppender, + "Something went wrong handling request GET /test/customBody: HttpStatus 500 INTERNAL_SERVER_ERROR - DUMMY_CODE: DUMMY_MESSAGE", + "it.gov.pagopa.common.web.exception.ServiceException: DUMMY_MESSAGE", + "it.gov.pagopa.common.web.exception.ServiceExceptionHandlerTest$TestController.testCustomBody" + + ); + } +} diff --git a/src/test/java/it/gov/pagopa/common/web/exception/ValidationExceptionHandlerTest.java b/src/test/java/it/gov/pagopa/common/web/exception/ValidationExceptionHandlerTest.java new file mode 100644 index 0000000..7e95f91 --- /dev/null +++ b/src/test/java/it/gov/pagopa/common/web/exception/ValidationExceptionHandlerTest.java @@ -0,0 +1,103 @@ +package it.gov.pagopa.common.web.exception; + + +import it.gov.pagopa.common.web.dto.ErrorDTO; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +import static org.assertj.core.api.Assertions.assertThat; + +@WebFluxTest(value = {ValidationExceptionHandlerTest.TestController.class}, excludeAutoConfiguration = SecurityAutoConfiguration.class) +@ContextConfiguration(classes = { + ValidationExceptionHandlerTest.TestController.class, + ValidationExceptionHandler.class}) +class ValidationExceptionHandlerTest { + + @Autowired + private WebTestClient webTestClient; + + + @RestController + static class TestController { + + @PutMapping("/test") + String testEndpoint(@RequestBody @Valid ValidationDTO body, @RequestHeader("data") String data) { + return "OK"; + } + } + + @Data + @AllArgsConstructor + @NoArgsConstructor + static class ValidationDTO { + @NotBlank(message = "The field is mandatory!") + private String data; + } + + @Test + void testHandleValueNotValidException() { + String invalidJson = "{}"; + webTestClient.put() + .uri("/test") + .contentType(MediaType.APPLICATION_JSON) + .header("data", "someValue") + .bodyValue(invalidJson) + .exchange() + .expectStatus().isBadRequest() + .expectBody(ErrorDTO.class) + .consumeWith(response -> { + ErrorDTO errorDTO = response.getResponseBody(); + assertThat(errorDTO).isNotNull(); + assertThat(errorDTO.getCode()).isEqualTo("INVALID_REQUEST"); + assertThat(errorDTO.getMessage()).isEqualTo("[data]: The field is mandatory!"); + }); + } + @Test + void testHandleHeaderNotValidException() { + webTestClient.put() + .uri("/test") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(new ValidationDTO("data")) + .exchange() + .expectStatus().isBadRequest() + .expectBody(ErrorDTO.class) + .consumeWith(response -> { + ErrorDTO errorDTO = response.getResponseBody(); + assertThat(errorDTO).isNotNull(); + assertThat(errorDTO.getCode()).isEqualTo("INVALID_REQUEST"); + assertThat(errorDTO.getMessage()).isEqualTo("Invalid request"); + + }); + } + + @Test + void testHandleNoResourceFoundException() { + webTestClient.put() + .uri("/test/missing") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(new ValidationDTO("someData")) + .exchange() + .expectStatus().isNotFound() // Expect 404 Not Found + .expectBody(ErrorDTO.class) + .consumeWith(response -> { + ErrorDTO errorDTO = response.getResponseBody(); + assertThat(errorDTO).isNotNull(); + assertThat(errorDTO.getCode()).isEqualTo("INVALID_REQUEST"); // Check the code from ErrorDTO + assertThat(errorDTO.getMessage()).isEqualTo("Invalid request"); // Check the message from ErrorDTO + }); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnectorImplTest.java b/src/test/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnectorImplTest.java new file mode 100644 index 0000000..ec0594e --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/connector/tpp/TppConnectorImplTest.java @@ -0,0 +1,58 @@ +package it.gov.pagopa.onboarding.citizen.connector.tpp; + +import it.gov.pagopa.onboarding.citizen.dto.TppDTO; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import static it.gov.pagopa.onboarding.citizen.enums.AuthenticationType.OAUTH2; +import static org.assertj.core.api.Assertions.assertThat; + +class TppConnectorImplTest { + + private MockWebServer mockWebServer; + private TppConnectorImpl tppConnector; + + @BeforeEach + void setUp() throws Exception { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + WebClient.Builder webClientBuilder = WebClient.builder(); + + tppConnector = new TppConnectorImpl(webClientBuilder, mockWebServer.url("/").toString()); + } + + @AfterEach + void tearDown() throws Exception { + mockWebServer.shutdown(); + } + + @Test + void testGetTppInfoOk() { + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody("{\"tppId\":\"TPP_OK_1\",\"entityId\":\"ENTITY_OK_1\",\"businessName\":\"Test Business\",\"messageUrl\":\"https://example.com/message\",\"authenticationUrl\":\"https://example.com/auth\",\"authenticationType\":\"OAUTH2\",\"contact\":{\"name\":\"John Doe\",\"number\":\"+1234567890\",\"email\":\"contact@example.com\"},\"state\":true}") + .addHeader("Content-Type", "application/json")); + + Mono resultMono = tppConnector.get("TPP_OK_1"); + TppDTO tppDTO = resultMono.block(); + + assertThat(tppDTO).isNotNull(); + assertThat(tppDTO.getTppId()).isEqualTo("TPP_OK_1"); + assertThat(tppDTO.getEntityId()).isEqualTo("ENTITY_OK_1"); + assertThat(tppDTO.getBusinessName()).isEqualTo("Test Business"); + assertThat(tppDTO.getMessageUrl()).isEqualTo("https://example.com/message"); + assertThat(tppDTO.getAuthenticationUrl()).isEqualTo("https://example.com/auth"); + assertThat(tppDTO.getAuthenticationType()).isEqualTo(OAUTH2); + assertThat(tppDTO.getContact().getName()).isEqualTo("John Doe"); + assertThat(tppDTO.getContact().getNumber()).isEqualTo("+1234567890"); + assertThat(tppDTO.getContact().getEmail()).isEqualTo("contact@example.com"); + assertThat(tppDTO.getState()).isTrue(); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/controller/CitizenControllerTest.java b/src/test/java/it/gov/pagopa/onboarding/citizen/controller/CitizenControllerTest.java new file mode 100644 index 0000000..eb686dc --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/controller/CitizenControllerTest.java @@ -0,0 +1,213 @@ +package it.gov.pagopa.onboarding.citizen.controller; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentStateUpdateDTO; +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentDTOFaker; +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentStateUpdateDTOFaker; +import it.gov.pagopa.onboarding.citizen.service.BloomFilterServiceImpl; +import it.gov.pagopa.onboarding.citizen.service.CitizenServiceImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.WebFluxTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.MediaType; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; + +import java.util.List; + +@WebFluxTest(CitizenControllerImpl.class) +class CitizenControllerTest { + + @MockBean + private CitizenServiceImpl citizenService; + + @MockBean + private BloomFilterServiceImpl bloomFilterService; + + @Autowired + private WebTestClient webClient; + + + + private static final String FISCAL_CODE = "MLXHZZ43A70H203T"; + private static final String TPP_ID = "tppId"; + + + @Test + void saveCitizenConsent_Ok() { + CitizenConsentDTO citizenConsentDTO = CitizenConsentDTOFaker.mockInstance(true); + + Mockito.when(citizenService.createCitizenConsent(citizenConsentDTO)).thenReturn(Mono.just(citizenConsentDTO)); + + webClient.post() + .uri("/emd/citizen") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(citizenConsentDTO) + .exchange() + .expectStatus().isOk() + .expectBody(CitizenConsentDTO.class) + .consumeWith(response -> { + CitizenConsentDTO resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals(citizenConsentDTO, resultResponse); + }); + } + + @Test + void stateUpdate_Ok() { + CitizenConsentStateUpdateDTO citizenConsentStateUpdateDTO = CitizenConsentStateUpdateDTOFaker.mockInstance(true); + + CitizenConsentDTO expectedResponseDTO = CitizenConsentDTOFaker.mockInstance(true); + + Mockito.when(citizenService.updateTppState( + citizenConsentStateUpdateDTO.getFiscalCode(), + citizenConsentStateUpdateDTO.getTppId(), + citizenConsentStateUpdateDTO.getTppState())) + .thenReturn(Mono.just(expectedResponseDTO)); + + webClient.put() + .uri("/emd/citizen/stateUpdate") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(citizenConsentStateUpdateDTO) + .exchange() + .expectStatus().isOk() + .expectBody(CitizenConsentDTO.class) + .consumeWith(response -> { + CitizenConsentDTO resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals(expectedResponseDTO, resultResponse); + }); + } + + @Test + void getConsentStatus_Ok() { + + CitizenConsentDTO citizenConsentDTO = CitizenConsentDTOFaker.mockInstance(true); + + Mockito.when(citizenService.getCitizenConsentStatus(FISCAL_CODE, TPP_ID)) + .thenReturn(Mono.just(citizenConsentDTO)); + + webClient.get() + .uri("/emd/citizen/{fiscalCode}/{tppId}", FISCAL_CODE, TPP_ID) + .exchange() + .expectStatus().isOk() + .expectBody(CitizenConsentDTO.class) + .consumeWith(response -> { + CitizenConsentDTO resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals(citizenConsentDTO, resultResponse); + }); + } + + @Test + void getTppEnabledList_Ok() { + List tppEnabledList = List.of("TPP1", "TPP2"); + + Mockito.when(citizenService.getTppEnabledList(FISCAL_CODE)) + .thenReturn(Mono.just(tppEnabledList)); + + webClient.get() + .uri("/emd/citizen/list/{fiscalCode}/enabled/tpp", FISCAL_CODE) + .exchange() + .expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() {}) + .consumeWith(response -> { + List resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals(tppEnabledList.size(), resultResponse.size()); + }); + } + + @Test + void get_Ok() { + CitizenConsentDTO citizenConsentDTO = CitizenConsentDTOFaker.mockInstance(true); + + Mockito.when(citizenService.getCitizenConsentsList(FISCAL_CODE)) + .thenReturn(Mono.just(citizenConsentDTO)); + + webClient.get() + .uri("/emd/citizen/list/{fiscalCode}", FISCAL_CODE) + .exchange() + .expectStatus().isOk() + .expectBody(CitizenConsentDTO.class) + .consumeWith(response -> { + CitizenConsentDTO resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals(citizenConsentDTO, resultResponse); + }); + } + + @Test + void getAllFiscalCode_Ok() { + Mockito.when(bloomFilterService.mightContain(FISCAL_CODE)) + .thenReturn(true); + + webClient.get() + .uri("/emd/citizen/filter/{fiscalCode}", FISCAL_CODE) + .exchange() + .expectStatus().isOk() + .expectBody(String.class) + .consumeWith(response -> { + String resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals("OK", resultResponse); + }); + } + @Test + void getAllFiscalCode_NoChannelsEnabled() { + Mockito.when(bloomFilterService.mightContain(FISCAL_CODE)) + .thenReturn(false); + + webClient.get() + .uri("/emd/citizen/filter/{fiscalCode}", FISCAL_CODE) + .exchange() + .expectStatus().isAccepted() + .expectBody(String.class) + .consumeWith(response -> { + String resultResponse = response.getResponseBody(); + Assertions.assertNotNull(resultResponse); + Assertions.assertEquals("NO CHANNELS ENABLED", resultResponse); + }); + } + + @Test + void getCitizenConsentsListEnabled_ShouldReturnCitizenConsent() { + CitizenConsentDTO mockConsent = CitizenConsentDTOFaker.mockInstance(true); + Mockito.when(citizenService.getCitizenConsentsListEnabled(FISCAL_CODE)) + .thenReturn(Mono.just(mockConsent)); + + webClient.get() + .uri("/emd/citizen/list/{fiscalCode}/enabled", FISCAL_CODE) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBody(CitizenConsentDTO.class) + .value(response -> { + assert response != null; + Assertions.assertEquals(response, mockConsent); + }); + + } + + @Test + void getCitizenEnabled_ShouldReturnListOfCitizens() { + String tppId = "TPP123"; + List mockConsents = List.of(CitizenConsentDTOFaker.mockInstance(true)); + Mockito.when(citizenService.getCitizenEnabled(tppId)) + .thenReturn(Mono.just(mockConsents)); + + webClient.get() + .uri("/emd/citizen/{tppId}", tppId) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isOk() + .expectBodyList(CitizenConsentDTO.class) + .value(response -> Assertions.assertEquals(1, response.size())); + + } + +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentDTOFaker.java b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentDTOFaker.java new file mode 100644 index 0000000..d6a1442 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentDTOFaker.java @@ -0,0 +1,22 @@ +package it.gov.pagopa.onboarding.citizen.faker; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +public class CitizenConsentDTOFaker { + + private CitizenConsentDTOFaker() {} + + public static CitizenConsentDTO mockInstance(Boolean bias) { + Map consents = new HashMap<>(); + + consents.put("tppId", new CitizenConsentDTO.ConsentDTO(bias, LocalDateTime.now())); + + return CitizenConsentDTO.builder() + .fiscalCode("MLXHZZ43A70H203T") + .consents(consents) + .build(); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentFaker.java b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentFaker.java new file mode 100644 index 0000000..03a2755 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentFaker.java @@ -0,0 +1,29 @@ +package it.gov.pagopa.onboarding.citizen.faker; + +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.model.ConsentDetails; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +public class CitizenConsentFaker { + + private CitizenConsentFaker() {} + + public static CitizenConsent mockInstance(Boolean bias) { + Map consents = new HashMap<>(); + + ConsentDetails consentDetails = ConsentDetails.builder() + .tppState(bias) + .tcDate(LocalDateTime.now()) + .build(); + + consents.put("tppId", consentDetails); + + return CitizenConsent.builder() + .fiscalCode("fiscalCode") + .consents(consents) + .build(); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentStateUpdateDTOFaker.java b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentStateUpdateDTOFaker.java new file mode 100644 index 0000000..31464f6 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/CitizenConsentStateUpdateDTOFaker.java @@ -0,0 +1,16 @@ +package it.gov.pagopa.onboarding.citizen.faker; + +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentStateUpdateDTO; + +public class CitizenConsentStateUpdateDTOFaker { + + private CitizenConsentStateUpdateDTOFaker() {} + + public static CitizenConsentStateUpdateDTO mockInstance(Boolean tppState) { + return CitizenConsentStateUpdateDTO.builder() + .fiscalCode("hashedFiscalCode") + .tppId("tppId") + .tppState(tppState) + .build(); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/faker/TppDTOFaker.java b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/TppDTOFaker.java new file mode 100644 index 0000000..b5befd4 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/faker/TppDTOFaker.java @@ -0,0 +1,12 @@ +package it.gov.pagopa.onboarding.citizen.faker; + +import it.gov.pagopa.onboarding.citizen.dto.TppDTO; + +public class TppDTOFaker { + private TppDTOFaker(){} + public static TppDTO mockInstance() { + return TppDTO.builder() + .tppId("id") + .build(); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepositoryImplTest.java b/src/test/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepositoryImplTest.java new file mode 100644 index 0000000..9688b58 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/repository/CitizenSpecificRepositoryImplTest.java @@ -0,0 +1,148 @@ +package it.gov.pagopa.onboarding.citizen.repository; + +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.model.ConsentDetails; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.mongodb.core.ReactiveMongoTemplate; +import org.springframework.data.mongodb.core.aggregation.Aggregation; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; + +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CitizenSpecificRepositoryImplTest { + + @Mock + private ReactiveMongoTemplate mongoTemplate; + + @InjectMocks + private CitizenSpecificRepositoryImpl repository; + + @Test + void testFindByFiscalCodeAndTppId() { + String hashedFiscalCode = "hashedCode"; + String tppId = "tpp1"; + CitizenConsent citizenConsent = createMockCitizenConsent(hashedFiscalCode, tppId); + + when(mongoTemplate.aggregate( + Mockito.any(Aggregation.class), + Mockito.eq("citizen_consents"), + Mockito.eq(CitizenConsent.class) + )).thenReturn(Flux.just(citizenConsent)); + + StepVerifier.create(repository.findByFiscalCodeAndTppId(hashedFiscalCode, tppId)) + .expectNextMatches(result -> result.getFiscalCode().equals(hashedFiscalCode)) + .verifyComplete(); + + Mockito.verify(mongoTemplate).aggregate( + Mockito.any(Aggregation.class), + Mockito.eq("citizen_consents"), + Mockito.eq(CitizenConsent.class) + ); + } + + @Test + void testFindByFiscalCodeAndTppId_TppIdNull() { + String hashedFiscalCode = "hashedCode"; + String tppId = null; + + StepVerifier.create(repository.findByFiscalCodeAndTppId(hashedFiscalCode, tppId)) + .expectComplete() // Expect an empty Mono + .verify(); + } + + @Test + void testFindByFiscalCodeAndTppId_EmptyResult() { + String hashedFiscalCode = "hashedCode"; + String tppId = "tpp1"; + + when(mongoTemplate.aggregate( + Mockito.any(Aggregation.class), + Mockito.eq("citizen_consents"), + Mockito.eq(CitizenConsent.class) + )).thenReturn(Flux.empty()); + + StepVerifier.create(repository.findByFiscalCodeAndTppId(hashedFiscalCode, tppId)) + .expectComplete() // Expect an empty Mono + .verify(); + + Mockito.verify(mongoTemplate).aggregate( + Mockito.any(Aggregation.class), + Mockito.eq("citizen_consents"), + Mockito.eq(CitizenConsent.class) + ); + } + + @Test + void testConsentKeyWrapperGetterSetter() { + CitizenSpecificRepositoryImpl.ConsentKeyWrapper consentKeyWrapper = new CitizenSpecificRepositoryImpl.ConsentKeyWrapper(); + + consentKeyWrapper.setK("testKey"); + + Assertions.assertEquals("testKey", consentKeyWrapper.getK()); + } + + @Test + void testConsentKeyWrapperToString() { + CitizenSpecificRepositoryImpl.ConsentKeyWrapper consentKeyWrapper = new CitizenSpecificRepositoryImpl.ConsentKeyWrapper(); + consentKeyWrapper.setK("testKey"); + + Assertions.assertEquals("CitizenSpecificRepositoryImpl.ConsentKeyWrapper(k=testKey)", consentKeyWrapper.toString()); + } + + @Test + void testConsentKeyWrapperEqualsAndHashCode() { + CitizenSpecificRepositoryImpl.ConsentKeyWrapper consentKeyWrapper1 = new CitizenSpecificRepositoryImpl.ConsentKeyWrapper(); + CitizenSpecificRepositoryImpl.ConsentKeyWrapper consentKeyWrapper2 = new CitizenSpecificRepositoryImpl.ConsentKeyWrapper(); + + consentKeyWrapper1.setK("testKey"); + consentKeyWrapper2.setK("testKey"); + + Assertions.assertEquals(consentKeyWrapper1, consentKeyWrapper2); + Assertions.assertEquals(consentKeyWrapper1.hashCode(), consentKeyWrapper2.hashCode()); + + consentKeyWrapper2.setK("differentKey"); + Assertions.assertNotEquals(consentKeyWrapper1, consentKeyWrapper2); + } + + private CitizenConsent createMockCitizenConsent(String hashedFiscalCode, String tppId) { + CitizenConsent citizenConsent = new CitizenConsent(); + citizenConsent.setId("1"); + citizenConsent.setFiscalCode(hashedFiscalCode); + + Map consents = new HashMap<>(); + ConsentDetails consentDetails = new ConsentDetails(); + consentDetails.setTppState(true); + consents.put(tppId, consentDetails); + citizenConsent.setConsents(consents); + + return citizenConsent; + } + + @Test + void testFindByTppIdEnabled_Success() { + String tppId = "tpp1"; + CitizenConsent citizenConsent = createMockCitizenConsent("hashedCode", tppId); + + when(mongoTemplate.aggregate( + Mockito.any(Aggregation.class), + Mockito.eq("citizen_consents"), + Mockito.eq(CitizenConsent.class) + )).thenReturn(Flux.just(citizenConsent)); + + StepVerifier.create(repository.findByTppIdEnabled(tppId)) + .expectNextMatches(result -> result.getConsents().containsKey(tppId) && result.getConsents().get(tppId).getTppState()) + .verifyComplete(); + + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterServiceTest.java b/src/test/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterServiceTest.java new file mode 100644 index 0000000..9516cb4 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/service/BloomFilterServiceTest.java @@ -0,0 +1,52 @@ +package it.gov.pagopa.onboarding.citizen.service; + +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentFaker; +import it.gov.pagopa.onboarding.citizen.repository.CitizenRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BloomFilterServiceTest { + + @Mock + private CitizenRepository citizenRepository; + + @InjectMocks + private BloomFilterServiceImpl bloomFilterService; + + @BeforeEach + void setUp() { + + when(citizenRepository.findAll()).thenReturn(Flux.just(CitizenConsentFaker.mockInstance(true))); + bloomFilterService.initializeBloomFilter(); + } + + @Test + void testMightContain() { + assertTrue(bloomFilterService.mightContain("fiscalCode")); + assertFalse(bloomFilterService.mightContain("nonExistentFiscalCode")); + } + + @Test + void testUpdate() { + when(citizenRepository.findAll()).thenReturn(Flux.just(CitizenConsentFaker.mockInstance(true))); + bloomFilterService.update(); + assertTrue(bloomFilterService.mightContain("fiscalCode")); + assertFalse(bloomFilterService.mightContain("nonexistentHashedFiscalCode")); + } + + @Test + void testAdd() { + bloomFilterService.add("fiscalCode3"); + assertTrue(bloomFilterService.mightContain("fiscalCode3")); + } +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/service/CitizenServiceTest.java b/src/test/java/it/gov/pagopa/onboarding/citizen/service/CitizenServiceTest.java new file mode 100644 index 0000000..b917005 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/service/CitizenServiceTest.java @@ -0,0 +1,332 @@ +package it.gov.pagopa.onboarding.citizen.service; + +import it.gov.pagopa.common.web.exception.ClientExceptionWithBody; +import it.gov.pagopa.onboarding.citizen.configuration.ExceptionMap; +import it.gov.pagopa.onboarding.citizen.connector.tpp.TppConnectorImpl; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.TppDTO; +import it.gov.pagopa.onboarding.citizen.dto.mapper.CitizenConsentObjectToDTOMapper; +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentDTOFaker; +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentFaker; +import it.gov.pagopa.onboarding.citizen.faker.TppDTOFaker; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.model.ConsentDetails; +import it.gov.pagopa.onboarding.citizen.model.mapper.CitizenConsentDTOToObjectMapper; +import it.gov.pagopa.onboarding.citizen.repository.CitizenRepository; +import it.gov.pagopa.onboarding.citizen.validation.CitizenConsentValidationServiceImpl; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.function.Executable; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith({SpringExtension.class, MockitoExtension.class}) +@ContextConfiguration(classes = { + CitizenServiceImpl.class, + CitizenConsentValidationServiceImpl.class, + CitizenConsentObjectToDTOMapper.class, + CitizenConsentDTOToObjectMapper.class, + ExceptionMap.class +}) +class CitizenServiceTest { + + @Autowired + CitizenServiceImpl citizenService; + + @MockBean + CitizenConsentValidationServiceImpl validationService; + + @MockBean + CitizenRepository citizenRepository; + + @MockBean + TppConnectorImpl tppConnector; + + @Autowired + CitizenConsentObjectToDTOMapper dtoMapper; + + private static final String FISCAL_CODE = "fiscalCode"; + private static final String TPP_ID = "tppId"; + private static final boolean TPP_STATE = true; + private static final CitizenConsent CITIZEN_CONSENT = CitizenConsentFaker.mockInstance(true); + private static final CitizenConsentDTO CITIZEN_CONSENT_DTO = CitizenConsentDTOFaker.mockInstance(true); + private static final TppDTO TPP_DTO = TppDTOFaker.mockInstance(); + + @Test + void createCitizenConsent_Ok() { + + CitizenConsentDTO expectedConsentDTO = dtoMapper.map(CITIZEN_CONSENT); + TppDTO activeTppDTO = TPP_DTO; + activeTppDTO.setState(true); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(activeTppDTO)); + when(citizenRepository.save(Mockito.any())).thenReturn(Mono.just(CITIZEN_CONSENT)); + when(citizenRepository.findByFiscalCode(anyString())).thenReturn(Mono.empty()); + when(validationService.validateTppAndSaveConsent(anyString(), anyString(), any(CitizenConsent.class))) + .thenReturn(Mono.just(expectedConsentDTO)); + + CitizenConsentDTO response = citizenService.createCitizenConsent(CITIZEN_CONSENT_DTO).block(); + assertNotNull(response); + assertEquals(expectedConsentDTO, response); + } + + @Test + void createCitizenConsent_AlreadyExists() { + + CitizenConsentDTO expectedConsentDTO = dtoMapper.map(CITIZEN_CONSENT); + TppDTO activeTppDTO = TPP_DTO; + activeTppDTO.setState(true); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(activeTppDTO)); + + when(citizenRepository.save(Mockito.any())).thenReturn(Mono.just(CITIZEN_CONSENT)); + when(citizenRepository.findByFiscalCode(anyString())).thenReturn(Mono.just(CITIZEN_CONSENT)); + when(citizenRepository.findByFiscalCodeAndTppId(anyString(), anyString())).thenReturn(Mono.just(CITIZEN_CONSENT)); + + when(validationService.handleExistingConsent(any(), anyString(), any())) + .thenReturn(Mono.just(expectedConsentDTO)); + + when(validationService.validateTppAndSaveConsent(anyString(), anyString(), any(CitizenConsent.class))) + .thenReturn(Mono.just(expectedConsentDTO)); + + CitizenConsentDTO response = citizenService.createCitizenConsent(CITIZEN_CONSENT_DTO).block(); + + assertNotNull(response); + assertEquals(expectedConsentDTO, response); + } + + @Test + void createCitizenConsent_Ko_TppNotProvided() { + + CitizenConsentDTO incompleteConsentDTO = CitizenConsentDTOFaker.mockInstance(true); + incompleteConsentDTO.getConsents().clear(); + + when(tppConnector.get(anyString())).thenReturn(Mono.empty()); + when(citizenRepository.findByFiscalCodeAndTppId(anyString(), anyString())).thenReturn(Mono.empty()); + + when(validationService.validateTppAndSaveConsent(anyString(), anyString(), any(CitizenConsent.class))) + .thenReturn(Mono.error(new ClientExceptionWithBody(HttpStatus.BAD_REQUEST, "TPP_NOT_FOUND", "TPP does not exist or is not active"))); + + StepVerifier.create(citizenService.createCitizenConsent(incompleteConsentDTO)) + .expectErrorMatches(throwable -> throwable instanceof ClientExceptionWithBody && + "TPP does not exist or is not active".equals(throwable.getMessage()) && + "TPP_NOT_FOUND".equals(((ClientExceptionWithBody) throwable).getCode())) + .verify(); + } + + @Test + void createCitizenConsent_Ko_TppInactive() { + + TppDTO inactiveTppDTO = TPP_DTO; + inactiveTppDTO.setState(false); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(inactiveTppDTO)); + when(citizenRepository.findByFiscalCodeAndTppId(anyString(), anyString())).thenReturn(Mono.empty()); + when(citizenRepository.findByFiscalCode(anyString())).thenReturn(Mono.empty()); + + when(validationService.validateTppAndSaveConsent(anyString(), anyString(), any(CitizenConsent.class))) + .thenReturn(Mono.error(new ClientExceptionWithBody(HttpStatus.BAD_REQUEST, "TPP_NOT_FOUND", "TPP does not exist or is not active"))); + + CitizenConsentDTO citizenConsentDTO = dtoMapper.map(CITIZEN_CONSENT); + + StepVerifier.create(citizenService.createCitizenConsent(citizenConsentDTO)) + .expectErrorMatches(throwable -> throwable instanceof ClientExceptionWithBody && + "TPP does not exist or is not active".equals(throwable.getMessage()) && + "TPP_NOT_FOUND".equals(((ClientExceptionWithBody) throwable).getCode())) + .verify(); + + Mockito.verify(citizenRepository, Mockito.never()).save(Mockito.any()); + } + + @Test + void updateChannelState_Ok() { + + TppDTO mockTppDTO = TppDTOFaker.mockInstance(); + mockTppDTO.setState(true); + + when(tppConnector.get(anyString())) + .thenReturn(Mono.just(mockTppDTO)); + + when(citizenRepository.findByFiscalCodeAndTppId(FISCAL_CODE, TPP_ID)) + .thenReturn(Mono.just(CITIZEN_CONSENT)); + + when(citizenRepository.save(any())) + .thenReturn(Mono.just(CITIZEN_CONSENT)); + + + CitizenConsentDTO response = citizenService.updateTppState(FISCAL_CODE, TPP_ID, TPP_STATE).block(); + + assertNotNull(response); + + assertEquals(TPP_STATE, response.getConsents().get(TPP_ID).getTppState()); + } + + @Test + void updateChannelState_Ko_CitizenNotOnboarded() { + + TppDTO mockTppDTO = TppDTOFaker.mockInstance(); + mockTppDTO.setState(true); + + when(tppConnector.get(anyString())) + .thenReturn(Mono.just(mockTppDTO)); + + when(citizenRepository.findByFiscalCodeAndTppId(FISCAL_CODE, TPP_ID)) + .thenReturn(Mono.empty()); + + + + Executable executable = () -> citizenService.updateTppState(FISCAL_CODE, TPP_ID, true).block(); + ClientExceptionWithBody exception = assertThrows(ClientExceptionWithBody.class, executable); + + assertEquals("Citizen consent not founded during update state process", exception.getMessage()); + } + + @Test + void getConsentStatus_Ok() { + + + when(citizenRepository.findByFiscalCodeAndTppId(FISCAL_CODE, TPP_ID)) + .thenReturn(Mono.just(CITIZEN_CONSENT)); + + CitizenConsentDTO response = citizenService.getCitizenConsentStatus(FISCAL_CODE, TPP_ID).block(); + assertNotNull(response); + } + + @Test + void getConsentStatus_Ko_CitizenNotOnboarded() { + + when(citizenRepository.findByFiscalCodeAndTppId(FISCAL_CODE, TPP_ID)) + .thenReturn(Mono.empty()); + + Executable executable = () -> citizenService.getCitizenConsentStatus(FISCAL_CODE, TPP_ID).block(); + ClientExceptionWithBody exception = assertThrows(ClientExceptionWithBody.class, executable); + + assertEquals("Citizen consent not founded during get process ", exception.getMessage()); + } + + @Test + void testGetTppEnabledList_Success() { + + Map consents = new HashMap<>(); + + consents.put("Tpp1", ConsentDetails.builder() + .tppState(true) + .tcDate(LocalDateTime.now()) + .build()); + + consents.put("Tpp2", ConsentDetails.builder() + .tppState(false) + .tcDate(LocalDateTime.now()) + .build()); + + CitizenConsent citizenConsent = CitizenConsent.builder() + .fiscalCode(FISCAL_CODE) + .consents(consents) + .build(); + + when(citizenRepository.findByFiscalCode(FISCAL_CODE)).thenReturn(Mono.just(citizenConsent)); + + Mono> result = citizenService.getTppEnabledList(FISCAL_CODE); + + StepVerifier.create(result) + .expectNext(List.of("Tpp1")) + .verifyComplete(); + } + + @Test + void get_Ok() { + + when(citizenRepository.findByFiscalCode(FISCAL_CODE)) + .thenReturn(Mono.just(CITIZEN_CONSENT)); + + CitizenConsentDTO response = citizenService.getCitizenConsentsList(FISCAL_CODE).block(); + + assertNotNull(response); + + assertEquals(CITIZEN_CONSENT.getFiscalCode(), response.getFiscalCode()); + } + + @Test + void getCitizenConsentsListEnabled_Ok() { + Map consents = new HashMap<>(); + consents.put("Tpp1", ConsentDetails.builder().tppState(true).tcDate(LocalDateTime.now()).build()); + consents.put("Tpp2", ConsentDetails.builder().tppState(false).tcDate(LocalDateTime.now()).build()); + + CitizenConsent citizenConsent = CitizenConsent.builder() + .fiscalCode(FISCAL_CODE) + .consents(consents) + .build(); + + CitizenConsentDTO expectedDTO = dtoMapper.map(citizenConsent); + expectedDTO.getConsents().remove("Tpp2"); // Filter out disabled TPP + + when(citizenRepository.findByFiscalCode(FISCAL_CODE)).thenReturn(Mono.just(citizenConsent)); + + CitizenConsentDTO response = citizenService.getCitizenConsentsListEnabled(FISCAL_CODE).block(); + + assertNotNull(response); + assertEquals(1, response.getConsents().size()); + assertTrue(response.getConsents().containsKey("Tpp1")); + assertFalse(response.getConsents().containsKey("Tpp2")); + } + + @Test + void getCitizenConsentsListEnabled_Empty() { + when(citizenRepository.findByFiscalCode(FISCAL_CODE)).thenReturn(Mono.empty()); + + CitizenConsentDTO response = citizenService.getCitizenConsentsListEnabled(FISCAL_CODE).block(); + + assertNull(response); + } + + @Test + void getCitizenEnabled_Ok() { + CitizenConsent citizenConsent1 = CitizenConsent.builder() + .fiscalCode("FiscalCode1") + .consents(Map.of(TPP_ID, ConsentDetails.builder().tppState(true).tcDate(LocalDateTime.now()).build())) + .build(); + + CitizenConsent citizenConsent2 = CitizenConsent.builder() + .fiscalCode("FiscalCode2") + .consents(Map.of(TPP_ID, ConsentDetails.builder().tppState(true).tcDate(LocalDateTime.now()).build())) + .build(); + + when(citizenRepository.findByTppIdEnabled(TPP_ID)).thenReturn(Flux.just(citizenConsent1, citizenConsent2)); + + List response = citizenService.getCitizenEnabled(TPP_ID).block(); + + assertNotNull(response); + assertEquals(2, response.size()); + assertEquals("FiscalCode1", response.get(0).getFiscalCode()); + assertEquals("FiscalCode2", response.get(1).getFiscalCode()); + } + + @Test + void getCitizenEnabled_Empty() { + when(citizenRepository.findByTppIdEnabled(TPP_ID)).thenReturn(Flux.empty()); + + List response = citizenService.getCitizenEnabled(TPP_ID).block(); + + assertNotNull(response); + assertTrue(response.isEmpty()); + } + +} diff --git a/src/test/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationServiceImplTest.java b/src/test/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationServiceImplTest.java new file mode 100644 index 0000000..1b16ab4 --- /dev/null +++ b/src/test/java/it/gov/pagopa/onboarding/citizen/validation/CitizenConsentValidationServiceImplTest.java @@ -0,0 +1,189 @@ +package it.gov.pagopa.onboarding.citizen.validation; + +import it.gov.pagopa.common.web.exception.ClientExceptionWithBody; +import it.gov.pagopa.onboarding.citizen.connector.tpp.TppConnectorImpl; +import it.gov.pagopa.onboarding.citizen.dto.CitizenConsentDTO; +import it.gov.pagopa.onboarding.citizen.dto.TppDTO; +import it.gov.pagopa.onboarding.citizen.dto.mapper.CitizenConsentObjectToDTOMapper; +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentDTOFaker; +import it.gov.pagopa.onboarding.citizen.faker.CitizenConsentFaker; +import it.gov.pagopa.onboarding.citizen.faker.TppDTOFaker; +import it.gov.pagopa.onboarding.citizen.model.CitizenConsent; +import it.gov.pagopa.onboarding.citizen.model.ConsentDetails; +import it.gov.pagopa.onboarding.citizen.repository.CitizenRepository; +import it.gov.pagopa.onboarding.citizen.service.BloomFilterServiceImpl; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@SpringBootTest +class CitizenConsentValidationServiceImplTest { + + @MockBean + BloomFilterServiceImpl bloomFilterService; + + @MockBean + CitizenRepository citizenRepository; + + @MockBean + TppConnectorImpl tppConnector; + + @MockBean + CitizenConsentObjectToDTOMapper dtoMapper; + + @Autowired + CitizenConsentValidationServiceImpl validationService; + + private static final CitizenConsent CITIZEN_CONSENT = CitizenConsentFaker.mockInstance(true); + private static final CitizenConsentDTO CITIZEN_CONSENT_DTO = CitizenConsentDTOFaker.mockInstance(true); + private static final TppDTO TPP_DTO = TppDTOFaker.mockInstance(); + + @Test + void handleExistingConsent_ConsentAlreadyExists() { + + CitizenConsent existingConsent = CITIZEN_CONSENT; + String tppId = "existingTppId"; + existingConsent.getConsents().put(tppId, CITIZEN_CONSENT.getConsents().get(tppId)); + + when(dtoMapper.map(existingConsent)).thenReturn(CITIZEN_CONSENT_DTO); + + CitizenConsentDTO result = validationService.handleExistingConsent(existingConsent, tppId, CITIZEN_CONSENT).block(); + + assertNotNull(result); + assertEquals(CITIZEN_CONSENT_DTO, result); + } + + @Test + void handleExistingConsent_NewConsentForTpp() { + + CitizenConsent existingConsent = CITIZEN_CONSENT; + TppDTO activeTppDTO = TPP_DTO; + activeTppDTO.setState(true); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(activeTppDTO)); + when(citizenRepository.save(existingConsent)).thenReturn(Mono.just(existingConsent)); + when(dtoMapper.map(existingConsent)).thenReturn(CITIZEN_CONSENT_DTO); + + CitizenConsentDTO result = validationService.handleExistingConsent(existingConsent, activeTppDTO.getTppId(), CITIZEN_CONSENT).block(); + + assertNotNull(result); + assertEquals(CITIZEN_CONSENT_DTO, result); + } + + @Test + void validateTppAndSaveConsent_TppValidAndActive() { + + CitizenConsent citizenConsentWithValidConsent = CITIZEN_CONSENT; + String fiscalCode = citizenConsentWithValidConsent.getFiscalCode(); + TppDTO activeTppDTO = TPP_DTO; + activeTppDTO.setState(true); + + ConsentDetails consentDetails = new ConsentDetails(); + consentDetails.setTppState(true); + + citizenConsentWithValidConsent.getConsents().put(activeTppDTO.getTppId(), consentDetails); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(activeTppDTO)); + when(citizenRepository.save(CITIZEN_CONSENT)).thenReturn(Mono.just(CITIZEN_CONSENT)); + when(dtoMapper.map(CITIZEN_CONSENT)).thenReturn(CITIZEN_CONSENT_DTO); + + CitizenConsentDTO result = validationService.validateTppAndSaveConsent(fiscalCode, activeTppDTO.getTppId(), CITIZEN_CONSENT).block(); + + assertNotNull(result); + assertEquals(CITIZEN_CONSENT_DTO, result); + verify(bloomFilterService, times(1)).add(fiscalCode); + } + + @Test + void validateTppAndSaveConsent_TppInvalid() { + + CitizenConsent citizenConsentWithInvalidConsent = CITIZEN_CONSENT; + String fiscalCode = citizenConsentWithInvalidConsent.getFiscalCode(); + + TppDTO inactiveTppDTO = TPP_DTO; + inactiveTppDTO.setState(false); + + ConsentDetails consentDetails = new ConsentDetails(); + consentDetails.setTppState(false); + + citizenConsentWithInvalidConsent.getConsents().put(inactiveTppDTO.getTppId(), consentDetails); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(inactiveTppDTO)); + + StepVerifier.create(validationService.validateTppAndSaveConsent(fiscalCode, inactiveTppDTO.getTppId(), CITIZEN_CONSENT)) + .expectErrorMatches(throwable -> throwable instanceof ClientExceptionWithBody && + "TPP is not active or is invalid".equals(throwable.getMessage()) && + "TPP_NOT_FOUND".equals(((ClientExceptionWithBody) throwable).getCode())) + .verify(); + + verify(citizenRepository, never()).save(any()); + verify(bloomFilterService, never()).add(fiscalCode); + } + + @Test + void validateTppAndSaveConsent_TppNotFound() { + + String fiscalCode = CITIZEN_CONSENT.getFiscalCode(); + String tppId = "nonExistentTppId"; + + when(tppConnector.get(tppId)).thenReturn(Mono.error(new RuntimeException("TPP not found"))); + + StepVerifier.create(validationService.validateTppAndSaveConsent(fiscalCode, tppId, CITIZEN_CONSENT)) + .expectErrorMatches(throwable -> throwable instanceof ClientExceptionWithBody && + "TPP does not exist or is not active".equals(throwable.getMessage()) && + "TPP_NOT_FOUND".equals(((ClientExceptionWithBody) throwable).getCode())) + .verify(); + + verify(citizenRepository, never()).save(any()); + verify(bloomFilterService, never()).add(fiscalCode); + } + + @Test + void validateTppAndSaveConsent_TppInactive() { + + String fiscalCode = CITIZEN_CONSENT.getFiscalCode(); + TppDTO inactiveTppDTO = TPP_DTO; + inactiveTppDTO.setState(false); + + ConsentDetails consentDetails = new ConsentDetails(); + consentDetails.setTppState(false); + + CITIZEN_CONSENT.getConsents().put(inactiveTppDTO.getTppId(), consentDetails); + + when(tppConnector.get(anyString())).thenReturn(Mono.just(inactiveTppDTO)); + + StepVerifier.create(validationService.validateTppAndSaveConsent(fiscalCode, inactiveTppDTO.getTppId(), CITIZEN_CONSENT)) + .expectErrorMatches(throwable -> throwable instanceof ClientExceptionWithBody && + "TPP is not active or is invalid".equals(throwable.getMessage()) && + "TPP_NOT_FOUND".equals(((ClientExceptionWithBody) throwable).getCode())) + .verify(); + + verify(citizenRepository, never()).save(any()); + verify(bloomFilterService, never()).add(fiscalCode); + } + + @Test + void handleExistingConsent_TppNotFound() { + + String tppId = "nonExistentTppId"; + + when(tppConnector.get(anyString())).thenReturn(Mono.error(new RuntimeException("TPP not found"))); + + StepVerifier.create(validationService.handleExistingConsent(CITIZEN_CONSENT, tppId, CITIZEN_CONSENT)) + .expectErrorMatches(throwable -> throwable instanceof ClientExceptionWithBody && + "TPP does not exist or is not active".equals(throwable.getMessage()) && + "TPP_NOT_FOUND".equals(((ClientExceptionWithBody) throwable).getCode())) + .verify(); + + verify(citizenRepository, never()).save(any()); + verify(bloomFilterService, never()).add(anyString()); + } +}