diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c0879a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,42 @@ +**Repository:** `management-node` +**Description:** `Details steps and information required to report issues with the software` +**SPDX-License-Identifier:** OGL-UK-3.0 + +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + - OS: [e.g. iOS] + - Browser [e.g. chrome, safari] + - Version [e.g. 22] + +**Smartphone (please complete the following information):** + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..2626541 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,24 @@ +**Repository:** `management-node` +**Description:** `Details steps and information required to request new features within the software` +**SPDX-License-Identifier:** OGL-UK-3.0 + +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..68ec382 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +## Sensitive Credential Checks + +- [ ] As the author of these changes, I have checked for any sensitive credentials prior to this review being requested. +- [ ] As a reviewer of these changes, I have checked for any sensitive credentials prior to approving this merge. + + + +## Motivation and Context + + + + +## Description + +- Describe your changes in detail + +## How Has This Been Tested? + + + + + +## Screenshots (if appropriate): + +## Checklist: + + + +- [ ] It contains only changes required by issue (does not contain other PR) +- [ ] Includes link to an issue (if apply) +- [ ] I have added tests to cover my changes. + diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml new file mode 100644 index 0000000..5a1c2c5 --- /dev/null +++ b/.github/workflows/maven.yml @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme +# and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +# Licensed 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 +# +# http://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. + +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-java-with-maven + +name: Automated tests + +on: + push: + branches: + - 'develop' + - 'main' + pull_request: + branches: + - 'develop' + - 'main' + workflow_call: + +permissions: + contents: read + +env: + MAVEN_CLI_OPTS: "--batch-mode --no-transfer-progress" + +jobs: + build: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + server-password: 'GH_PACKAGES_PAT' + - name: Build and Test + env: + GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + run: ./mvnw $MAVEN_CLI_OPTS verify + - name: Upload JaCoCo HTML report + if: always() + uses: actions/upload-artifact@v4 + with: + name: jacoco-report + path: target/site/jacoco + if-no-files-found: warn + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + java-version: '21' + distribution: 'temurin' + cache: maven + server-password: 'GH_PACKAGES_PAT' + - name: Lint + env: + GH_PACKAGES_PAT: ${{ secrets.GH_PACKAGES_PAT }} + run: ./mvnw $MAVEN_CLI_OPTS spotless:check + diff --git a/.github/workflows/oss-checker.yml b/.github/workflows/oss-checker.yml new file mode 100644 index 0000000..65b5f1b --- /dev/null +++ b/.github/workflows/oss-checker.yml @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +name: Run OSS check helper + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + oss-checks: + runs-on: ubuntu-latest + + steps: + - name: Fetch GitHub App token for target repo + id: target_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} + private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} + permission-contents: read + + - name: Fetch GitHub App token for OSPO source repo (read-only) + id: ospo_token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OSPO_WORKFLOW_APP_ID }} + private-key: ${{ secrets.OSPO_WORKFLOW_PRIVATE_KEY }} + owner: National-Digital-Twin + repositories: ospo-resources + permission-contents: read + + - name: Checkout target repository + uses: actions/checkout@v5 + with: + token: ${{ steps.target_token.outputs.token }} + + - name: Checkout OSPO source repository + uses: actions/checkout@v5 + with: + repository: National-Digital-Twin/ospo-resources + path: ospo-resources + token: ${{ steps.ospo_token.outputs.token }} + + - name: Checkout archetypes source repository + uses: actions/checkout@v5 + with: + repository: National-Digital-Twin/archetypes + path: archetypes + + - name: Test for presence of OSS files and variation from templated content + run: | + missing_files=() + unchanged_files=() + + while IFS= read -r file || [ -n "$file" ]; do + # Skip comments and empty lines + if [[ -z "$file" || "$file" == \#* ]]; then + continue + fi + + target_path="$file" + archetypes_path="archetypes/$file" + + if [ ! -f "$target_path" ]; then + echo "Missing OSS file in target repository: $target_path" + missing_files+=("$file") + elif cmp -s "$target_path" "$archetypes_path"; then + echo "OSS file unchanged from archetypes template: $target_path" + unchanged_files+=("$file") + else + echo "OSS file present and different from the archetypes template: $target_path" + fi + done < ospo-resources/oss-checklist-files.txt + + echo "" + if [ ${#missing_files[@]} -ne 0 ]; then + echo "The following OSS required files are missing:" + printf '%s\n' "${missing_files[@]}" + fi + + if [ ${#unchanged_files[@]} -ne 0 ]; then + echo "The following OSS required files are unchanged from the archetypes template:" + printf '%s\n' "${unchanged_files[@]}" + fi + + if [ ${#missing_files[@]} -ne 0 ] || [ ${#unchanged_files[@]} -ne 0 ]; then + echo "OSS required file check failed." + exit 1 + else + echo "All OSS files are present and have been updated from their original templated content." + fi + + - name: Check GitHub template files are present + run: | + echo "Checking for pull request and issue template files" + + missing_templates=() + + files_to_check=( + ".github/PULL_REQUEST_TEMPLATE.md" + ".github/ISSUE_TEMPLATE/bug_report.md" + ".github/ISSUE_TEMPLATE/feature_request.md" + ) + + for file in "${files_to_check[@]}"; do + if [ ! -f "$file" ]; then + missing_templates+=("$file") + fi + done + + if [ ${#missing_templates[@]} -ne 0 ]; then + echo "" + echo "Required GitHub template files not found:" + printf ' - %s\n' "${missing_templates[@]}" + echo "" + echo "These files help improve project collaboration and are considered best practice." + echo "These need to be included in repository contents to improve the developer and repository consumer experience." + + # Fail the job + echo "Missing required GitHub template files." + exit 1 + else + echo "Required pull request and issue template files present." + fi diff --git a/.github/workflows/publish-github-release.yml b/.github/workflows/publish-github-release.yml new file mode 100644 index 0000000..8e35068 --- /dev/null +++ b/.github/workflows/publish-github-release.yml @@ -0,0 +1,117 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +# This workflow is triggered when a pull request is merged into the main branch +# from a release/* branch. It extracts the release version from the source branch, +# generates a Software Bill of Materials (SBOM) using the GitHub API, +# creates a Git tag with the version, and publishes a GitHub release including the SBOM file. + +name: Generate SBOM, Tag and Publish GitHub Release + +on: + pull_request: + types: + - closed + branches: + - main + +permissions: + contents: write + +jobs: + versioning: + if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'release/') + name: Extract Release Version + runs-on: ubuntu-latest + outputs: + version: ${{ steps.extract_version.outputs.VERSION }} + steps: + - name: Extract Version from Source Branch Name + id: extract_version + run: | + SOURCE_BRANCH="${{ github.head_ref }}" + VERSION=$(echo "$SOURCE_BRANCH" | grep -o '[0-9]\+\.[0-9]\+\.[0-9]\+') + + if [ -z "$VERSION" ]; then + echo "Error: No semantic release version found in source branch: $SOURCE_BRANCH" + exit 1 + fi + + echo "VERSION=$VERSION" >> $GITHUB_ENV + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + + - name: Validate Version Format (Semantic Versioning) + run: | + if [[ ! "${{ env.VERSION }}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: Invalid version format found. Expected semantic version in release branch name (e.g., release/0.9.0)" + exit 1 + fi + + - name: Print Tag Version + run: | + echo "Identified release semantic version: ${{ steps.extract_version.outputs.version }}" + + generate-sbom: + name: Generate SPDX SBOM + runs-on: ubuntu-latest + needs: [versioning] + steps: + - name: Checkout Code + uses: actions/checkout@v5 + + - name: Generate SPDX SBOM + run: | + # Call GitHub API to generate SBOM + api_response=$(curl -sSL \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/${{ github.repository }}/dependency-graph/sbom") + + # Extract nested "sbom" object into a valid SPDX file + echo "$api_response" | jq '.sbom' > sbom.spdx.json + + - name: Upload SBOM Artifact + uses: actions/upload-artifact@v4 + with: + name: sbom + path: sbom.spdx.json + + create-git-tag: + name: Create Git Tag + needs: [versioning, generate-sbom] + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Create Git Tag + uses: rickstaa/action-create-tag@v1 + with: + tag: "v${{ needs.versioning.outputs.version }}" + message: "Release v${{ needs.versioning.outputs.version }}" + force_push_tag: true + + create-git-release: + name: Create GitHub Release + needs: [versioning, generate-sbom, create-git-tag] + runs-on: ubuntu-latest + steps: + - name: Download SBOM Artifact + uses: actions/download-artifact@v5 + with: + name: sbom + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: "v${{ needs.versioning.outputs.version }}" + name: "Release v${{ needs.versioning.outputs.version }}" + body: "Automated release for version ${{ needs.versioning.outputs.version }}. For details of fixes, new features and changes in this release, please see [CHANGELOG.md](${{ github.server_url }}/${{ github.repository }}/blob/main/CHANGELOG.md)." + draft: false + prerelease: false + files: | + sbom.spdx.json + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9838bfb --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +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/ + +### Development certificates ### +*.key +*.csr +*.crt +*.p12 +*.jks +*.ext +*.pem + +### Env files ### +.env + +#### States +.terraform/ +*.tfstate +*.tfstate.backup +*.hcl \ No newline at end of file diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..d58dfb7 --- /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 +# +# http://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.9/apache-maven-3.9.9-bin.zip diff --git a/ACKNOWLEDGEMENTS.md b/ACKNOWLEDGEMENTS.md new file mode 100644 index 0000000..a510a81 --- /dev/null +++ b/ACKNOWLEDGEMENTS.md @@ -0,0 +1,29 @@ +# Acknowledgements + +**Repository:** `management-node` +**Description:** `Recognises suppliers, partner organisations, and other contributors to the repository’s development.` +**SPDX-License-Identifier:** `OGL-UK-3.0` + +--- +The National Digital Twin Programme (NDTP) would like to acknowledge the contributions of various organisations and individuals +who have supported the development of this repository. +## Organisational contributions +Over time, the following organisations have provided technical expertise, development support, and domain knowledge +that have contributed to the evolution of this project: + +- [Informed Solutions](https://informed.com) + +We are grateful for the collaboration that has helped shape this repository. +## Individual contributions + +For a list of individual contributors who have made direct commits to this repository, see +GitHub’s auto-generated contributor insights: [Contributors](https://github.com/National-Digital-Twin/your-repo/graphs/contributors). + +--- + +**Note:** This acknowledgment does not confer any legal rights, ownership, or imply ongoing involvement by any of the named organisations or individuals. +All contributions are made in accordance with the repository’s licensing terms. +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. +Licensed under the Open Government Licence v3.0. +For full licensing terms, see [OGL_LICENSE.md](OGL_LICENSE.md). + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..044731d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +**Repository:** `management-node` +**Description:** `Tracks all notable changes, version history, and roadmap toward 1.0.0 following Semantic Versioning.` +**SPDX-License-Identifier:** OGL-UK-3.0 + + +All notable changes to this repository will be documented in this file. + +This project follows **Semantic Versioning (SemVer)** ([semver.org](https://semver.org/)), using the format: + + +`[MAJOR].[MINOR].[PATCH]` +- **MAJOR** (`X.0.0`) – Incompatible API/feature changes that break backward compatibility. +- **MINOR** (`0.X.0`) – Backward-compatible new features, enhancements, or functionality changes. +- **PATCH** (`0.0.X`) – Backward-compatible bug fixes, security updates, or minor corrections. +- **Pre-release versions** – Use suffixes such as `-alpha`, `-beta`, `-rc.1` (e.g., `2.1.0-beta.1`). +- **Build metadata** – If needed, use `+build` (e.g., `2.1.0+20250314`). + +--- + + + +## [1.0.0] - 2025-10-1 + +### Initial release +- This is the first initial changelog entry for the management-node. It introduces the baseline feature set and establishes the changelog structure following Semantic Versioning. + +### Added +- Core domain and persistence for producers and consumers (JPA entities, repositories, and services). +- REST APIs for managing producers/consumers and related configurations (v1 controllers and DTOs). +- Configuration management provider for node settings and environment-driven overrides. +- Security integration with Keycloak (realm configuration and OAuth2/OIDC resource server setup). +- TLS/Mutual‑TLS support and related documentation (see docs/MTLS_CONFIGURATION.md). +- Health, readiness, and metrics endpoints (Spring Boot Actuator defaults where applicable). +- Test coverage setup and guidance (Mockito usage and JaCoCo reporting docs). +- Docker and local development assets (compose files, Keycloak realm, publish script, local certs/truststore). + +--- + +## Future Roadmap to `1.0.0` + +The `0.90.x` series is part of NDTP’s **pre-stable development cycle**, meaning: +- **Minor versions (`0.91.0`, `0.92.0`...) introduce features and improvements** leading to a stable `1.0.0`. +- **Patch versions (`0.90.1`, `0.90.2`...) contain only bug fixes and security updates**. +- **Backward compatibility is NOT guaranteed until `1.0.0`**, though NDTP aims to minimise breaking changes. + +Once `1.0.0` is reached, future versions will follow **strict SemVer rules**. + +--- + +## Versioning Policy +1. **MAJOR updates (`X.0.0`)** – Typically introduce breaking changes that require users to modify their code or configurations. + - **Breaking changes (default rule)**: Any backward-incompatible modifications require a major version bump. + - **Non-breaking major updates (exceptional cases)**: A major version may also be incremented if the update represents a significant milestone, such as a shift in governance, a long-term stability commitment, or substantial new functionality that redefines the project’s scope. +2. **MINOR updates (`0.X.0`)** – New functionality that is backward-compatible. +3. **PATCH updates (`0.0.X`)** – Bug fixes, performance improvements, or security patches. +4. **Dependency updates** – A **major dependency upgrade** that introduces breaking changes should trigger a **MAJOR** version bump (once at `1.0.0`). + +--- + +## How to Update This Changelog + +1. When making changes, update this file under the **Unreleased** section. +2. Before a new release, move changes from **Unreleased** to a new dated section with a version number. +3. Follow **Semantic Versioning** rules to categorise changes correctly. +4. If pre-release versions are used, clearly mark them as `-alpha`, `-beta`, or `-rc.X`. + +--- + +**Maintained by the National Digital Twin Programme (NDTP).** + +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +Licensed under the Open Government Licence v3.0. + +For full licensing terms, see [LICENSE.md](LICENSE.md). + diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0dccab8 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,55 @@ +# Code of Conduct + +**Repository:** `management-node` +**Description:** `Defines expected behaviors, rules, and the enforcement process to ensure professional engagement.` +**SPDX-License-Identifier:** OGL-UK-3.0 + +## Introduction +The National Digital Twin Programme (NDTP) is committed to fostering an open, inclusive, and professional environment in all its public repositories. +This Code of Conduct outlines the expectations for behaviour when engaging with NDTP repositories, including issue reporting, documentation feedback, +and discussions with repository maintainers. + +By participating in this repository, you agree to follow this Code of Conduct. + +--- +## Expected Behaviour +All contributors, maintainers, and public users are expected to: +- **Be respectful and professional** – Treat others with courtesy and professionalism. +- **Communicate constructively** – Offer feedback that is clear, helpful, and focused on improving the repository. +- **Engage in a welcoming manner** – Encourage participation and provide a positive experience for all users. +- **Provide relevant and clear information** – When submitting issues or feedback, be specific and include details that help maintainers understand the request. +--- +## Unacceptable Behaviour +The following behaviour will not be tolerated: +- **Harassment, discrimination, or personal attacks** – Any form of offensive behaviour towards individuals or groups. +- **Trolling, disruptive comments, or inflammatory language** – Intentionally provoking arguments or making non-constructive comments. +- **Excessive demands or unrealistic expectations of maintainers** – This includes repeated requests for prioritisation outside of programme priorities. +- **Spamming or promotional content** – Off-topic discussions unrelated to the repository's purpose. +- **Disclosing sensitive information** – Sharing security vulnerabilities or confidential details outside of the responsible disclosure process. +--- +## Reporting Concerns +If you believe someone is violating this Code of Conduct, please report it by following these steps: +1. **For general issues** – Raise a concern with the repository maintainers by emailing ndtp@businessandtrade.gov.uk. +2. **For security-related concerns** – Follow the responsible disclosure process outlined in [SECURITY.md](SECURITY.md). +3. **For incidents requiring escalation** – NDTP reserves the right to take appropriate action, including restricting access to contributors who violate this policy. + All reports will be reviewed confidentially, and NDTP will take appropriate action to address the issue. +--- +## Enforcement +Violations of this Code of Conduct may result in: +- A formal warning +- Temporary suspension from participation +- Permanent exclusion from engaging with NDTP repositories + Decisions on enforcement are made at NDTP’s discretion. +--- +## Scope +This Code of Conduct applies to all interactions in NDTP repositories, including but not limited to: +- Issue tracking and reporting +- Documentation suggestions and feedback +- Discussions with maintainers +- Any other interactions in public NDTP projects +--- +**Maintained by the National Digital Twin Programme (NDTP).** +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. +Licensed under the Open Government Licence v3.0. +For full licensing terms, see [OGL_LICENSE.md](OGL_LICENSE.md). + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e82223c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,91 @@ +# Contribution Guidelines + +**Repository:** `management-node` +**Description:** `Guidelines for issue reporting, documentation suggestions, and NDTP’s controlled contribution model.` +**SPDX-License-Identifier:** `OGL-UK-3.0` + +--- + +Thank you for your interest in this repository. +The National Digital Twin Programme (NDTP) develops and maintains this repository in collaboration with suppliers and partner organisations, including other parts of +government and their suppliers. +NDTP follows an **open-source governance model** where all code is **publicly available** under open-source licences, and collaboration is invited from **approved +partners**. Contributions from the general public are not currently accepted, but **feedback, issue reporting, and documentation suggestions are encouraged**. +If you want to see which suppliers and organisations have contributed to this repository in the past, refer to [ACKNOWLEDGEMENTS.md](ACKNOWLEDGEMENTS.md) and the GitHub +contributor insights page at [Contributors](https://github.com/National-Digital-Twin/your-repo/graphs/contributors). + +--- + +## How You Can Contribute +Public users and NDTP partners are encouraged to engage in the following ways: +- **Reporting bugs and issues** – If you find a problem, please open a GitHub issue. +- **Suggesting documentation improvements** – Propose clarifications or additions to the existing documentation. +- **Providing structured feedback** – If you have suggestions for improvements, let us know via GitHub Issues. + While we review all input, NDTP prioritises development based on programme goals, supplier development cycles, and strategic objectives. + NDTP does not currently accept **public pull requests (PRs) or direct code contributions** to this repository. Contributions are limited to **approved suppliers and + partner organisations** under formal agreements. + For details on repository maintainers and how to contact them, refer to [MAINTAINERS.md](MAINTAINERS.md). +--- +## Reporting Issues +If you encounter a bug, error, or inconsistency, please follow these steps: +1. Check for an existing issue under [Issues](https://github.com/National-Digital-Twin/management-node/issues). +2. Open a new issue if no one has reported it yet. Use one of the provided issue templates. +3. Provide a clear, detailed description of the issue, including steps to reproduce it if applicable. +4. Label the issue appropriately (bug, documentation, enhancement, etc.). + For security-related issues, do not submit a public issue. Instead, follow our [Responsible Disclosure process](SECURITY.md). +--- +## Documentation Feedback +If you find an error in the documentation, need more clarity, or have suggestions for additional documentation, you can: +1. Open a GitHub issue under the `documentation` label. +2. Describe the improvement you are suggesting, including references to existing documentation where applicable. +3. Submit structured feedback – specific examples help us make updates faster. + We prioritise documentation updates based on user impact and alignment with programme goals. +--- +## NDTP's Approach to Open-Source Development +- **All NDTP code is publicly available under open-source licences.** +- **Development is led by approved suppliers and partners** who have been engaged through a formal process. +- **We welcome feedback and ideas**, but implementation is subject to programme priorities. + To see what we’re working on, check out our [Project Roadmap](https://github.com/National-Digital-Twin/management-node/projects). If no roadmap is currently available, + please note that it is being actively developed and will be published in due course. +--- +## Branching Strategy +This repository follows a **GitFlow-based branching model** to manage development efficiently. Key conventions include: +- **Main Branch (`main`)**: The stable, production-ready branch. Only tested and approved changes are merged here. +- **Develop Branch (`develop`)**: The integration branch where features and fixes are merged before reaching `main`. +- **Feature Branches (`feature/*`)**: Used for new developments. Named based on functionality, e.g., `feature/new-auth-method`. +- **Bugfix Branches (`bugfix/*`)**: Address minor issues in `develop` before release. +- **Release Branches (`release/*`)**: Used to prepare a new stable release, ensuring final testing and versioning updates. +- **Hotfix Branches (`hotfix/*`)**: Critical fixes applied directly to `main` and merged back into `develop`. + For more details, refer to [GitFlow Workflow](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow). +--- +## Pull Request Policy +To maintain high-quality contributions, NDTP enforces the following **minimum pull request (PR) requirements** for approved contributors: +- **All PRs must be reviewed by at least one maintainer** before merging. +- **PRs should reference a corresponding issue** where applicable. +- **Code changes must include relevant tests** to ensure stability. +- **Commit messages should follow best practices**, including referencing issue numbers when relevant. +- **Documentation updates should accompany PRs that impact functionality.** +- **PRs should use "squash and merge" as the preferred merge strategy**, ensuring a clean history. +- **Feature and bugfix branches should be deleted after merge** to keep the repository tidy. +- **Force pushing to the `main` branch is strictly prohibited** to protect repository integrity. +- **CI builds must pass before merging** to enforce basic validation checks. + For further details, see [CONTRIBUTING.md](CONTRIBUTING.md). +--- +## Contribution Licensing +By submitting feedback, documentation suggestions, or issue reports, you acknowledge that any resulting changes will be licensed under the same open-source terms +as this repository: +- Code contributions (if ever accepted) will be licensed under Apache 2.0. +- Documentation updates will be licensed under OGL v3.0. + For supplier-contracted development, NDTP ensures that all contributions align with Crown Copyright and public sector open-source standards. +--- +## Repository Maintainers +For details on who maintains this repository and how to contact them, refer to [MAINTAINERS.md](MAINTAINERS.md). + +NDTP repository maintainers review reported issues, evaluate documentation suggestions, and oversee ongoing development. + +--- +Maintained by the National Digital Twin Programme (NDTP). +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) +as the governing entity. +Licensed under the Open Government Licence v3.0. +For full licensing terms, see [OGL_LICENSE.md](OGL_LICENSE.md). \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 261eeb9..0000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed 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 - - http://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. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e8f3ef1 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,223 @@ +# License + +**Repository:** `management-node` +**Description:** `Defines the licensing terms for the source code in this repository.` +**SPDX-License-Identifier:** `Apache-2.0` + + +## Copyright Notice + +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +This work is licensed under the Apache License, Version 2.0. +**Note:** All documentation in this repository is licensed under the Open Government Licence v3.0 (OGL-3.0). See [OGL_LICENSE.md](OGL_LICENSE.md) for full terms. + +--- + +# Apache License + +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2025 Crown Copyright, National Digital Twin Programme, +legally attributed to the Department for Business and Trade (UK) + +Licensed 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 + + http://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. \ No newline at end of file diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 0000000..a3f7d46 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,69 @@ +# Maintainers + +**Repository:** `management-node` +**Description:** `Lists maintainers responsible for reviewing issues, security, and documentation updates.` +**SPDX-License-Identifier:** OGL-UK-3.0 + +## Introduction + +This repository is maintained by the **National Digital Twin Programme (NDTP)** in collaboration with contracted +suppliers and partner organisations. + +Maintainers are responsible for reviewing issues, evaluating documentation suggestions, and overseeing +supplier-led development. + +If you need to report a problem, suggest improvements, or seek guidance on using this repository, please refer to the +contacts listed below. + +--- + +## Responsibilities of Maintainers + +Maintainers are responsible for: + +- Reviewing and responding to **GitHub Issues**. +- Assessing **documentation updates and corrections**. +- Overseeing **code updates** developed by NDTP-approved suppliers. +- Ensuring compliance with **NDTP’s licensing and security policies**. + +NDTP does not accept public code contributions, but we welcome **bug reports and documentation feedback**. + +--- + +## Current Maintainers + +| Name | Organisation | Role | Contact | +|----------------|--------------------|--------------------|-----------------------| +| Nikan Negaresh | Informed Solutions | Lead Maintainer | NDTP-OSS@informed.com | +| Nikan Negaresh | Informed Solutions | Security Contact | NDTP-OSS@informed.com | +| Nikan Negaresh | Informed Solutions | Documentation Lead | NDTP-OSS@informed.com | + +For general issues, please **open a GitHub issue** rather than contacting maintainers directly. + +--- + +## Escalation Contacts + +If you need to escalate an issue that has not been addressed within a reasonable time: + +1. **Security vulnerabilities** – Follow the responsible disclosure process in [SECURITY.md](./SECURITY.md). +2. **Governance and policy queries** – Contact NDTP at **ndtp@businessandtrade.gov.uk**. +3. **Urgent operational issues** – If an issue affects critical systems, contact the **Lead Maintainer** listed above. + +--- + +## Updating this File + +Maintainer details may change over time. If you are an NDTP-approved maintainer and need to update this file, please +submit a request through the designated NDTP repository administrator. + +--- + +**Maintained by the National Digital Twin Programme (NDTP).** + +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to +the Department for Business and Trade (UK) as the governing entity. + +Licensed under the Open Government Licence v3.0. + +For full licensing terms, see [LICENSE.md](LICENSE.md). \ No newline at end of file diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..6799602 --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,19 @@ +# NOTICE + +**Repository:** `management-node` +**Description:** `Attribution and legal notices related to the use of this repository, including acknowledgments of external contributions.` +**SPDX-License-Identifier:** `OGL-UK-3.0` + +--- +This repository contains software developed as part of the +National Digital Twin Programme (NDTP), a UK Government initiative. +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the +Department for Business and Trade (UK) as the governing entity. +## License +This repository contains **both source code and documentation**, each covered by different licenses: +- **Code**: Licensed under the **[Apache License 2.0](LICENSE.md)**. +- **Documentation**: Licensed under the **[Open Government Licence v3.0 (OGL-UK-3.0)](OGL_LICENSE.md)**. + See `LICENSE.md` and `OGL_LICENCE.md`for details. + This project has been developed to support NDTP’s mission of enabling + secure, scalable, and interoperable data-sharing across organisations. + For a list of acknowledgments, see [ACKNOWLEDGEMENTS.md](ACKNOWLEDGEMENTS.md). \ No newline at end of file diff --git a/OGL_LICENSE.md b/OGL_LICENSE.md new file mode 100644 index 0000000..a56cdaf --- /dev/null +++ b/OGL_LICENSE.md @@ -0,0 +1,17 @@ +# Open Government Licence v3.0 + +**Repository:** `management-node` +**Description:** `Covers all documentation files in this repository that are released under the Open Government Licence v3.0.` +**SPDX-License-Identifier:** `OGL-UK-3.0` + + +This repository contains documentation licensed under the Open Government Licence (OGL) v3.0. +You are encouraged to use and re-use the information that is available under this licence. + +## Copyright Notice + +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. +Licensed under the Open Government Licence v3.0. + +You can view the full license at: +https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb2bd14 --- /dev/null +++ b/README.md @@ -0,0 +1,590 @@ +# README + +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- + +## Overview + +The Management Node Module is a Spring Boot application that provides APIs to be accessed by Consumer and Producer Federators. It implements a secure communication architecture using Mutual TLS (MTLS) connectivity between Federator instances and itself, as well as establishing zero trust connectivity with Keycloak for authentication and authorization. + +--- + +## Database Schema + +For a full description of the database tables, relationships, and constraints, see the Database Schema documentation: [docs/DATABASE_SCHEMA.md](docs/DATABASE_SCHEMA.md). + +--- + +## Prerequisites +- Java 21 +- Maven 3.9+ +- Docker and Docker Compose +- OpenSSL (for certificate generation) +- Keycloak (for authentication and authorization) + +--- + +## Quick Start + +### Run the Spring Boot application + +This project is a Spring Boot application. You can run it by supplying configuration via either: +- A default application.yml (or application.yaml) file, or +- A profile-specific file application-{profile}.yml and passing the profile argument at startup. + +Quick options: + +1. Provide a default config: + - Create src/main/resources/application.yml with your local settings (SSL keystore/truststore, Keycloak client, DB, etc.). See the Configuration and Certificate Setup sections below. + - Run: + ```bash + mvn spring-boot:run + ``` + or: + ```bash + java -jar target/management-node-0.0.1.jar + ``` + +2. Use a profile-specific config: + - Create src/main/resources/application-local.yml (replace "local" with your profile name) with your settings. + - Run with the profile: + ```bash + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + or: + ```bash + java -jar target/management-node-0.0.1.jar --spring.profiles.active=local + ``` + - You can also set the environment variable: + ```bash + export SPRING_PROFILES_ACTIVE=local + ``` + +Notes: +- Spring Boot will load application.yml and then override with application-{profile}.yml if a profile is active. +- You may also point to an external YAML using: + ```bash + java -jar target/management-node-0.0.1.jar --spring.config.location=/path/to/your.yml + ``` +- The application serves HTTPS on port 8090 by default (see server.ssl in configuration). + +### Setting up Keycloak with Docker Compose + +The application uses Keycloak for authentication and authorization. Follow these steps to set up Keycloak using Docker Compose: + +1. Navigate to the docker directory: + ```bash + cd docker + ``` + +2. Make sure you have the required certificates in the `docker` directory: + - `keystore.jks` - Java keystore containing the server certificate + - `truststore.jks` - Java truststore containing trusted certificates + - `localhost.p12` - PKCS12 keystore for client authentication + - `localhost.crt` - Certificate file + - `localhost.key` - Private key file + + If you need to generate these files for development, see the [Certificate Setup](#certificate-setup) section. + +3. Start Keycloak and PostgreSQL using Docker Compose: + ```bash + docker compose -f keycloak/docker-compose.yml up -d + ``` + +4. Verify that Keycloak is running: + ```bash + curl -k https://localhost:8443/health + ``` + +5. Access the Keycloak admin console at https://localhost:8443/admin with the following credentials: + - Username: `admin` + - Password: `password` + +### Configuration + +For Docker Compose to run successfully, you need to create a `.env` file in the `docker/keycloak` directory with the following settings: + +``` +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME_STRICT_BACKCHANNEL=false +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +KC_HOSTNAME=keycloak +KC_HOSTNAME_PORT=8080 +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO +``` + +This file contains essential environment variables for both PostgreSQL and Keycloak configuration. You can modify these values as needed for your environment, but make sure to create this file before running Docker Compose. + +## Certificate Setup + +The Management Node Module implements a zero-trust security architecture using Mutual TLS (MTLS) for secure communication between all components. This section explains why certificates are needed, how to generate them, and where they are used in the system. + +> **Note:** For detailed instructions on configuring MTLS for both Keycloak and the Management Node, see the [MTLS Configuration Guide](docs/MTLS_CONFIGURATION.md). + +### Why Certificates Are Needed + +1. **Zero-Trust Security Model**: The system follows a zero-trust approach where all communications must be authenticated and encrypted, regardless of whether they occur inside or outside the network perimeter. + +2. **Mutual TLS (MTLS)**: Unlike standard TLS where only the server authenticates itself to the client, MTLS requires both parties to authenticate each other using X.509 certificates. + +3. **Service-to-Service Authentication**: Certificates provide a secure way for services to verify each other's identity without relying on passwords or API keys. + +### Certificate Types and Their Purpose + +The system requires several certificate files: + +1. **Private Key (`localhost.key`)**: + - The private key used to sign and decrypt data + - Must be kept secure and never shared + - Used by both Keycloak and the Management Node + +2. **Certificate (`localhost.crt`)**: + - The public certificate containing the public key + - Shared with other services to verify the identity + - Used in both server and client authentication + +3. **PKCS12 Keystore (`localhost.p12`)**: + - A container format that stores the private key and certificate + - Used primarily for client authentication + - Imported by Keycloak for client certificate validation + +4. **Java Keystore (`keystore.jks`)**: + - Java-specific format for storing the server's private key and certificate + - Used by both Keycloak and the Management Node for their TLS endpoints + +5. **Java Truststore (`truststore.jks`)**: + - Contains certificates that the server trusts + - Used to validate client certificates during MTLS + +### Step-by-Step Certificate Generation + +For development purposes, follow these steps to generate certificates for mTLS. All passwords used are `changeit`. When generating these certficates, for the `Country Name`, you can use the value of 'UK'. All remaining certificate fields can be left to their default values. + +1. **Generate a Root CA certificate**: + ```bash + openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt + ``` + This creates a Root Certificate Authority (CA) that will be used to sign other certificates. The certificate is valid for 10 years (3650 days). + +2. **Generate a host certificate**: + ```bash + openssl req -new -newkey rsa:4096 -keyout localhost.key -out localhost.csr -nodes + ``` + This creates a private key and certificate signing request (CSR) for the host. + +3. **Sign the host certificate with the Root CA**: + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext + ``` + This signs the host CSR with the Root CA, creating a certificate valid for 365 days. + + The content of the `localhost.ext` file should be: + ``` + authorityKeyIdentifier=keyid,issuer + basicConstraints=CA:FALSE + subjectAltName = @alt_names + [alt_names] + DNS.1 = localhost + DNS.2 = keycloak + ``` + This configuration specifies that the certificate is valid for both `localhost` and `keycloak` hostnames. + +4. **Create a PKCS12 keystore for the server**: + ```bash + openssl pkcs12 -export -out localhost.p12 -name "localhost" -inkey localhost.key -in localhost.crt + ``` + This bundles the host certificate and private key into a PKCS12 format. + +5. **Create a PEM file for Linux keystore**: + ```bash + openssl pkcs12 -in localhost.p12 -clcerts -nokeys -out localhost.pem + ``` + This extracts the certificate (without the private key) in PEM format. + +6. **Add the Root CA to the Trust Store**: + ```bash + keytool -importcert -file rootCA.crt -alias clientca -keystore localhost.p12 -storetype PKCS12 -storepass changeit + ``` + This adds the Root CA to the trust store so that clients signed by this CA will be trusted. + +7. **Generate a client certificate**: + ```bash + openssl req -new -newkey rsa:4096 -nodes -keyout client.key -out client.csr + ``` + This creates a private key and CSR for the client. + +8. **Sign the client certificate with the Root CA**: + ```bash + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in client.csr -out client.crt -days 365 -CAcreateserial + ``` + This signs the client CSR with the Root CA, creating a certificate valid for 365 days. + +9. **Create a PKCS12 keystore for the client**: + ```bash + openssl pkcs12 -export -out client.p12 -name "client" -inkey client.key -in client.crt + ``` + This bundles the client certificate and private key into a PKCS12 format for use in browsers or client applications. + +10. **Create a Java keystore using keytool**: + ```bash + keytool -importkeystore -destkeystore keystore.jks -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" + ``` + This converts the PKCS12 keystore to a Java KeyStore (JKS) format used by Java applications. + +11. **Create a Java truststore using keytool**: + ```bash + keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks + ``` + This creates a truststore containing the Root CA certificate, which will be used to validate client certificates. + +12. **Import the Root CA into the truststore**: + ```bash + keytool -importcert -file rootCA.crt -alias rootCA -keystore truststore.jks -storetype JKS + ``` + This ensures the Root CA is properly imported into the Java truststore. + +### Certificate Placement and Configuration + +After generating the certificates, place them in the appropriate locations: + +1. **For Keycloak**: + - Place all certificate files in the `docker` directory + - The docker-compose.yml maps these files into the Keycloak container: + ```yaml + volumes: + - ./localhost.p12:/keystores/localhost.p12 + - ./localhost.crt:/cert/localhost.crt + - ./localhost.key:/key/localhost.key + - ./keystore.jks:/cert/keystore.jks + - ./truststore.jks:/cert/truststore.jks + ``` + - Keycloak uses these certificates for: + - Securing its HTTPS endpoint (port 8443) + - Validating client certificates for MTLS + +2. **For Management Node**: + - The application.yml references the certificate files: + ```yaml + server: + ssl: + key-store: /path/to/keystore.jks + key-store-password: changeit + trust-store: /path/to/truststore.jks + trust-store-password: changeit + ``` + - When running in Docker, the Dockerfile copies these files: + ```dockerfile + COPY docker/keystore.jks /app/docker/keystore.jks + COPY docker/truststore.jks /app/docker/truststore.jks + ``` + +3. **For Client Applications**: + - Client applications connecting to the Management Node need: + - The client certificate and private key for authentication + - The server's certificate in their truststore to validate the server + +### Certificate Password Management + +All certificates use the password "changeit" for development. These passwords are configured in the `.env` file: + +``` +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +``` + +For production environments, use strong, unique passwords and secure storage solutions for managing these credentials. + +## Keycloak Realm Setup + +After starting Keycloak, you need to set up a realm for the Management Node. You can either import the pre-configured realm or create it manually. To access the administrative interface at https://localhost:8443/admin, you will need to first import your client.p12 digital certificate file into your local browser, else the request will be rejected. + +### Option 1: Import the Realm Configuration (Recommended) + +1. Log in to the Keycloak admin console at https://localhost:8443/admin +2. Click on the dropdown menu in the top-left corner (it may show "master" if you haven't created any realms yet) +3. Click on "Create Realm" or "Add realm" button +4. Click on the "Browse" or "Select file" button +5. Navigate to and select the `docker/keycloak/management-node-realm.json` file from your project directory +6. Click "Create" or "Import" +7. After the import is complete, verify that the `management-node` realm has been created with all the necessary configurations +8. Note the client secret for the `ztf-client` from the Credentials tab (Clients → ztf-client → Credentials) and update it in your application.yml if needed + +### Option 2: Manual Configuration + +If you prefer to set up the realm manually: + +1. Log in to the Keycloak admin console at https://localhost:8443/admin +2. Create a new realm named `management-node` +3. Create a client with the following settings: + - Client ID: `ztf-client` + - Client Protocol: `openid-connect` + - Access Type: `confidential` + - Valid Redirect URIs: `https://localhost:8090/*` + - Web Origins: `+` +4. Note the client secret from the Credentials tab and update it in your application.yml if needed + +### Testing mTLS connectivity: + +Once KeyCloak is running and configured, you can test mTLS connectivity using the below command: + + ```bash + curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ + --cert client.crt --key client.key \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=ztf-client' \ + --data-urlencode 'grant_type=client_credentials' + ``` + +This tests the mTLS setup by attempting to obtain a token from Keycloak using client certificate authentication. + +## Building and Running with Maven + +### Building the Application + +The Management Node Module uses Maven for dependency management and build automation. To build the application: + +1. Ensure you have Maven 3.9+ installed: + ```bash + mvn --version + ``` + +2. Build the application: + ```bash + mvn clean package + ``` + This command will: + - Clean the target directory + - Compile the source code + - Run the tests + - Package the application into a JAR file + +3. If you want to skip tests during the build: + ```bash + mvn clean package -DskipTests + ``` + +### Running the Application + +After building, you can run the application using one of these methods: + +1. Using the Java command: + ```bash + java -jar target/management-node-0.0.1.jar + ``` + +2. Using the Maven Spring Boot plugin: + ```bash + mvn spring-boot:run + ``` + +3. Using Docker: + ```bash + docker build -t management-node -f docker/Dockerfile . + docker run -p 8090:8090 management-node + ``` + +The application will be available at https://localhost:8090 + +### Using Profile-Specific Configuration Files + +Spring Boot supports profile-specific property files, which are essential for local development environments where you need to configure sensitive information like passwords and URLs without committing them to version control. + +#### Why Use Profile-Specific Configuration? + +1. **Security**: Keep sensitive information like passwords and API keys out of version control +2. **Environment-Specific Settings**: Configure different settings for development, testing, and production +3. **Local Development**: Each developer can have their own configuration without affecting others + +#### Creating a Profile-Specific YAML File + +1. Create a file named `application-{profile}.yml` in the `src/main/resources` directory, where `{profile}` is the name of your profile (e.g., `application-local.yml` for a "local" profile) + +2. Add your environment-specific configuration to this file. For example: + + ```yaml + spring: + security: + oauth2: + resourceserver: + opaquetoken: + client-secret: your-client-secret-here + client-id: ztf-client + datasource: + password: your-database-password-here + + server: + ssl: + key-store-password: your-keystore-password-here + trust-store-password: your-truststore-password-here + key-store: /path/to/your/local/keystore.jks + trust-store: /path/to/your/local/truststore.jks + ``` + +3. Make sure not to commit this file to version control by adding it to your `.gitignore` file: + ``` + src/main/resources/application-local.yml + ``` + +#### Running the Application with a Specific Profile + +To run the application with your profile, use one of these methods: + +1. Using the Java command with the `spring.profiles.active` parameter: + ```bash + java -jar target/management-node-0.0.1.jar --spring.profiles.active=local + ``` + +2. Using the Maven Spring Boot plugin: + ```bash + mvn spring-boot:run -Dspring-boot.run.profiles=local + ``` + +3. Using environment variables: + ```bash + export SPRING_PROFILES_ACTIVE=local + java -jar target/management-node-0.0.1.jar + ``` + +4. When running with Docker, you can pass the profile as an environment variable: + ```bash + docker run -p 8090:8090 -e "SPRING_PROFILES_ACTIVE=local" management-node + ``` + +The application will load both the default `application.yml` and your profile-specific `application-local.yml`, with the latter overriding any duplicate properties. + +## Code Coverage with JaCoCo + +The project uses JaCoCo for code coverage analysis. For detailed information about the JaCoCo setup, thresholds, and recommendations, see the [JaCoCo Coverage Documentation](docs/JACOCO_COVERAGE.md). + +### Running Code Coverage + +To generate code coverage reports: + +1. Run the Maven verify goal: + ```bash + mvn clean verify + ``` + +2. The JaCoCo report will be generated in the `target/site/jacoco` directory. + +3. Open `target/site/jacoco/index.html` in a web browser to view the detailed coverage report. + +The current configuration aims for 80% code coverage across instructions, branches, lines, methods, and 50% for classes. + +## Troubleshooting + +### Common Issues + +1. **Certificate Issues**: + - Ensure that the paths to the keystore and truststore files in application.yml are correct + - Verify that the certificate passwords match those in the .env file + +2. **Keycloak Connection Issues**: + - Check that Keycloak is running and accessible at https://localhost:8443 + - Verify that the client secret in application.yml matches the one in Keycloak + +3. **Database Connection Issues**: + - Ensure PostgreSQL is running and accessible + - Check the database credentials in the .env file + +## Security Considerations + +This setup implements a zero-trust security model with: +- MTLS for all service-to-service communication +- JWT-based authentication and authorization via Keycloak +- HTTPS for all endpoints +- Client certificate authentication + +For production deployments, consider: +- Using properly signed certificates from a trusted CA +- Implementing network segmentation +- Regularly rotating secrets and certificates +- Setting up monitoring and alerting for security events +## API Documentation + +The project includes interactive API documentation powered by Springdoc OpenAPI (OAS 3.1). This exposes both a human-friendly Swagger UI and machine-readable OpenAPI definitions. + +How to access locally (default settings): +- Swagger UI: https://localhost:8090/swagger-ui.html +- OpenAPI JSON: https://localhost:8090/v3/api-docs + + +Notes +- HTTPS: The application serves over HTTPS by default (see server.ssl in application.yml). If you use development certificates, your browser may warn about trust; proceed after trusting the dev CA as described in Certificate Setup. +- Security: The security configuration explicitly permits unauthenticated access to the documentation endpoints (/v3/api-docs/**, /swagger-ui/**, /swagger-ui.html) while keeping all other endpoints protected via OAuth2 Resource Server (JWT). See src/main/java/.../config/SecurityConfig.java for details. +- Port/environment: If you run on a different port or behind a reverse proxy, adjust the base URL accordingly. + +How Springdoc OpenAPI works in this project +- Auto-scanning: The springdoc-openapi-starter-webmvc-ui dependency scans Spring MVC controllers at startup and automatically builds an OpenAPI 3.1 specification from your request mappings, parameters, request/response bodies, and status codes. +- Annotations (optional but recommended): + - @Operation(summary = "...", description = "...") adds summaries, descriptions, and operation-level metadata. + - @Tag(name = "...") groups endpoints in the UI. + - @Parameter, @Schema, @ApiResponse add fine-grained control over params, models, and responses. +- Security schema: Because this app is an OAuth2 Resource Server (JWT), you can declare a bearerAuth security scheme to document Authorization: Bearer . Example: + + @io.swagger.v3.oas.annotations.security.SecurityScheme( + name = "bearerAuth", + type = io.swagger.v3.oas.annotations.enums.SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT" + ) + + Then add @SecurityRequirement(name = "bearerAuth") on secured controllers or operations. +- Global metadata: You can set title, version, and contact details using @OpenAPIDefinition on a @Configuration class if desired. + + +## Authentication Requirements + +All protected endpoints require JWT bearer tokens. Tokens must: +- Include the audience (aud) "management-node". +- Contain a `resource_access` claim with client roles used for authorization. + +Role-specific access: +- Producer Federator: must have role `access_producer_configurations` to access `/api/v1/configuration/producer`. +- Consumer Federator: must have role `access_consumer_configurations` to access `/api/v1/configuration/consumer`. + +Read the full details, examples, and Keycloak mapping guidance in [Authentication Requirements](docs/AUTHENTICATION_REQUIREMENTS.md). + +## Public Funding Acknowledgment +This repository has been developed with public funding as part of the National Digital Twin Programme (NDTP), a UK Government initiative. NDTP, alongside its partners, has invested in this work to advance open, secure, and reusable digital twin technologies for any organisation, whether from the public or private sector, irrespective of size. +## License +This repository contains both source code and documentation, which are covered by different licenses: +- **Code:** Developed and maintained by National Digital Twin Programme. Licensed under the Apache License 2.0. +- **Documentation:** Licensed under the Open Government Licence v3.0. + See `LICENSE.md`, `OGL_LICENCE.md`, and `NOTICE.md` for details. +## Security and Responsible Disclosure +We take security seriously. If you believe you have found a security vulnerability in this repository, please follow our responsible disclosure process outlined in `SECURITY.md`. +## Software Bill of Materials (SBOM) +This project provides a Software Bill of Materials (SBOM) to help users and integrators understand its dependencies. +### Current SBOM +Download the [latest SBOM for this codebase](https://github.com/National-digital-twin/management-node/dependency-graph/sbom) to view the current list of components used in this repository. +## Contributing +We welcome contributions that align with the Programme’s objectives. Please read our `CONTRIBUTING.md` guidelines before submitting pull requests. +## Acknowledgements +This repository has benefited from collaboration with various organisations. For a list of acknowledgments, see `ACKNOWLEDGEMENTS.md`. +## Support and Contact +For questions or support, check our Issues or contact the NDTP team on ndtp@businessandtrade.gov.uk. + +**Maintained by the National Digital Twin Programme (NDTP).** +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entityright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. \ No newline at end of file diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..22fb8d2 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,66 @@ +# Security Policy + +**Repository:** `management-node` +**Description:** `Details the responsible disclosure process for security vulnerabilities.` +**SPDX-License-Identifier:** `OGL-UK-3.0` +## Responsible Disclosure +The National Digital Twin Programme (NDTP) follows a **Coordinated Vulnerability Disclosure (CVD)** process to ensure security risks are addressed responsibly. + +By reporting security vulnerabilities through the responsible channels, you agree to: +- Not disclose details of the vulnerability publicly until NDTP has had a reasonable opportunity to fix it. +- Provide NDTP with adequate time to assess and mitigate the risk. +- Act in good faith and follow ethical security research principles. + +NDTP reserves the right to take necessary action against unauthorised or harmful security testing activities. + +--- + +## Reporting Security Issues + +NDTP takes security seriously and encourages responsible reporting of vulnerabilities. + +If you believe you have found a security vulnerability in this repository, **please do not report it publicly**. Instead, follow the steps below to disclose the issue responsibly. +### **How to Report a Security Issue** +1. **Do not open a public issue on GitHub.** Instead, report security concerns via email to **[ndtp@businessandtrade.gov.uk]**. +2. **Provide detailed information about the vulnerability**, including: + - A clear description of the issue. + - Steps to reproduce the vulnerability. + - Potential impact or risk level. + - Any suggested mitigation strategies. +3. **Allow time for assessment and response.** NDTP will review the report and respond within **10 working days** to acknowledge receipt. +4. **Cooperate with NDTP to validate and address the issue.** + +Once a resolution has been identified, NDTP may choose to: +- **Release a patch** as part of the next scheduled update. +- **Issue a security advisory** if the issue is critical. +- **Provide acknowledgments** where appropriate (subject to NDTP’s disclosure policy). +--- +## Scope + +This security policy applies to: +- All NDTP repositories released as open source. +- Code, configuration files, and infrastructure deployed as part of NDTP’s **Integration Architecture (IA)**. +- **Third-party dependencies** included within NDTP repositories. If you identify a vulnerability in a third-party component that NDTP relies on (e.g., outdated libraries + or known security flaws in dependencies), we encourage you to report it. + Out of scope: +- Issues related to third-party services or software **not used within NDTP repositories**. +- Vulnerabilities in user environments that are unrelated to this repository. +- Unsolicited security testing or penetration testing without NDTP’s explicit permission. + +--- + +## Security Best Practices + +To help maintain security across NDTP repositories, we follow these principles: +- Dependencies are **scanned and updated regularly** (e.g., using automated tools like Dependabot). +- Sensitive credentials **must not be included** in public repositories. +- Security patches are applied in a timely manner, with priority given to critical vulnerabilities. + +--- + +**Maintained by the National Digital Twin Programme (NDTP).** + +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the +governing entity. +Licensed under the Open Government Licence v3.0. +For full licensing terms, see [OGL_LICENSE.md](OGL_LICENSE.md). diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..dacf6f9 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,44 @@ + +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +# Build stage +FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +WORKDIR /build + +# Copy the project files +COPY pom.xml . +COPY src ./src + +# Build the application +RUN mvn -B clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:21-jdk-alpine + +# Create non-root user and group +RUN addgroup -S app && adduser -S -G app -u 10001 app + +WORKDIR /app + +# Create directories and set permissions +RUN mkdir -p /app/docker /app/logs /app/tmp && chown -R app:app /app + +# Copy application jar from build stage +COPY --from=build /build/target/management-node-0.90.0.jar /app/app.jar +RUN chown app:app /app/app.jar + +# Use non-root user from here on +USER app:app + +# Expose HTTPS port +EXPOSE 8443 + +# Helpful defaults for Java in containers +ENV JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom -XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/app/tmp" + +# Use sh -c so JAVA_OPTS is expanded +ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"] \ No newline at end of file diff --git a/docker/Dockerfile-dev b/docker/Dockerfile-dev new file mode 100644 index 0000000..cc4073a --- /dev/null +++ b/docker/Dockerfile-dev @@ -0,0 +1,34 @@ + +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +# Build stage +FROM maven:3.9.6-eclipse-temurin-21-alpine AS build +WORKDIR /build + +# Copy the project files +COPY pom.xml . +COPY src ./src + +# Build the application +RUN mvn clean package -DskipTests + +# Runtime stage +FROM eclipse-temurin:23-jdk-alpine + +WORKDIR /app + +# Create directory for certificates +RUN mkdir -p /app/docker + +# Copy application jar from build stage and certificates +COPY --from=build /build/target/management-node-0.90.0.jar /app/app.jar +COPY docker/keystore.jks /app/docker/keystore.jks +COPY docker/truststore.jks /app/docker/truststore.jks + +EXPOSE 8443 + +ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/docker/build.sh b/docker/build.sh new file mode 100755 index 0000000..33a1489 --- /dev/null +++ b/docker/build.sh @@ -0,0 +1,10 @@ +#!/bin/sh -e + +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +cd .. +sudo docker build -f docker/Dockerfile -t ndtp/management-node . diff --git a/docker/keycloak/.env.template b/docker/keycloak/.env.template new file mode 100644 index 0000000..74a1a01 --- /dev/null +++ b/docker/keycloak/.env.template @@ -0,0 +1,21 @@ +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME_STRICT_BACKCHANNEL=false +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit +KC_HOSTNAME=keycloak +KC_HOSTNAME_PORT=8080 +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO \ No newline at end of file diff --git a/docker/keycloak/README.md b/docker/keycloak/README.md new file mode 100644 index 0000000..6625d1c --- /dev/null +++ b/docker/keycloak/README.md @@ -0,0 +1,158 @@ +**Repository:** `management-node` +**Description:** `The Management Node Module is a Spring Boot application that provides APIs to be accessed by Consumer and Producer Federators. It implements a secure communication architecture using Mutual TLS (MTLS) connectivity between Federator instances and itself, as well as establishing zero trust connectivity with Keycloak for authentication and authorization.` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + + +# mTLS with KeyCloak + +## Create X.509 certificates + + +All passwords: _changeit_ + +## RootCA + + openssl req -x509 -sha256 -days 3650 -newkey rsa:4096 -keyout rootCA.key -out rootCA.crt + +## Host certificate + + openssl req -new -newkey rsa:4096 -keyout localhost.key -out localhost.csr -nodes + +Sign host csr with rootCA (see below for file `localhost.ext`): + + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in localhost.csr -out localhost.crt -days 365 -CAcreateserial -extfile localhost.ext + +### Create pkcs12 file for server +Import local key and crt in keystore to create the "certificate" to be used in keyCloak Server Config: + + openssl pkcs12 -export -out localhost.p12 -name "localhost" -inkey localhost.key -in localhost.crt + +PEM file creation to be used in linux keystore + + openssl pkcs12 -in localhost.p12 -clcerts -nokeys -out localhost.pem + +adding CA Root to Trust Store + + keytool -importcert -file rootCA.crt -alias clientca -keystore localhost.p12 -storetype PKCS12 -storepass changeit + +--- + +## Client (user) certificate + + openssl req -new -newkey rsa:4096 -nodes -keyout client.key -out client.csr + +Sign client csr with rootCA: + + openssl x509 -req -CA rootCA.crt -CAkey rootCA.key -in client.csr -out client.crt -days 365 -CAcreateserial + +Import client key and crt in keystore to create the "certificate" to be used in the browser: + + openssl pkcs12 -export -out client.p12 -name "client" -inkey client.key -in client.crt + + + + +### Create a keystore using keytool + + keytool -importkeystore -destkeystore keystore.jks -srckeystore localhost.p12 -srcstoretype PKCS12 -alias "localhost" + +--- + + +### Create a truststore using keytool + + keytool -import -trustcacerts -noprompt -alias ca -ext san=dns:localhost,ip:127.0.0.1 -file rootCA.crt -keystore truststore.jks + + ##Or in pkcs12 + openssl pkcs12 -export -in rootCA.crt -inkey rootCA.key -out truststore.p12 -name "server certificate" -chain -CAfile rootCA.crt -caname "self signed ca certificate" -passin pass:$PW -passout pass:$PW + +### import the Root CA into TrustStore + + keytool -importcert -file rootCA.crt -alias rootCA -keystore truststore.jks -storetype JKS +--- + + +## To Test MTLS: + + curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ +--cert client.crt --key client.key \ +--header 'Content-Type: application/x-www-form-urlencoded' \ +--data-urlencode 'client_id=ztf-client' \ +--data-urlencode 'grant_type=client_credentials' + +--- + +## Docker Setup + +### Prerequisites +- Docker and Docker Compose installed on your system +- Maven installed for building the application + +### Building the Application +Before running the Docker containers, build the Spring Boot application: + +```bash +cd /path/to/managementNode +mvn clean package +``` + +### Running with Docker Compose +1. Set up environment variables in a `.env` file in the docker directory: + +``` +# Database configuration +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password + +# Keycloak admin credentials +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password + +# SSL configuration +SERVER_SSL_KEY_STORE_PASSWORD=changeit +SERVER_SSL_TRUST_STORE_PASSWORD=changeit +KC_HTTPS_KEY_STORE_PASSWORD=changeit +KC_HTTPS_TRUST_STORE_PASSWORD=changeit +KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit + +# Keycloak configuration +KC_HOSTNAME=localhost +KC_HOSTNAME_PORT=8080 +KC_HOSTNAME_STRICT_BACKCHANNEL=false +KC_HTTP_ENABLED=false +KC_HOSTNAME_STRICT_HTTPS=false +KC_HEALTH_ENABLED=true +KC_DB=postgres +KC_HTTPS_CLIENT_AUTH=required +KC_HTTPS_ENABLED=true +KC_HTTPS_PORT=8443 +KC_LOG_LEVEL=INFO +``` + +2. Start all services using Docker Compose: + +```bash +cd docker +docker-compose up -d +``` + +This will start: +- PostgreSQL database +- Keycloak authentication server +- Management Node application + +3. Access the application at https://localhost:8090 + +### Stopping the Services + +```bash +cd docker +docker-compose down +``` + +To remove volumes as well: + +```bash +docker-compose down -v +``` \ No newline at end of file diff --git a/docker/keycloak/docker-compose.yml b/docker/keycloak/docker-compose.yml new file mode 100644 index 0000000..a28e706 --- /dev/null +++ b/docker/keycloak/docker-compose.yml @@ -0,0 +1,75 @@ + +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# +services: + postgres: + image: postgres:16.2 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + networks: + - keycloak_network + ports: + - "5433:5432" + keycloak: + image: quay.io/keycloak/keycloak:26.3.2 + command: start --verbose + hostname: localhost + container_name: keycloak + environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: ${KC_HOSTNAME_PORT} + KC_HOSTNAME_STRICT_BACKCHANNEL: ${KC_HOSTNAME_STRICT_BACKCHANNEL} + KC_HTTP_ENABLED: ${KC_HTTP_ENABLED} + KC_HOSTNAME_STRICT_HTTPS: ${KC_HOSTNAME_STRICT_HTTPS} + KC_HEALTH_ENABLED: ${KC_HEALTH_ENABLED} + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_DB: ${KC_DB} + KC_DB_URL: jdbc:postgresql://postgres/${POSTGRES_DB} + KC_DB_USERNAME: ${POSTGRES_USER} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + ################################## + KC_HTTPS_CLIENT_AUTH: ${KC_HTTPS_CLIENT_AUTH} + KC_HTTPS_ENABLED: ${KC_HTTPS_ENABLED} + KC_HTTPS_PORT: ${KC_HTTPS_PORT} + KC_HTTPS_KEY_STORE_FILE: /cert/keystore.jks + KC_HTTPS_KEY_STORE_PASSWORD: ${KC_HTTPS_KEY_STORE_PASSWORD} + KC_HTTPS_CERTIFICATE_FILE: /cert/localhost.crt + KC_HTTPS_CERTIFICATE_KEY_FILE: /key/localhost.key + KC_HTTPS_TRUST_STORE_FILE: /keystores/localhost.p12 + KC_HTTPS_TRUST_STORE_PASSWORD: ${KC_HTTPS_TRUST_STORE_PASSWORD} + KC_SPI_TRUSTSTORE_FILE_FILE: /cert/truststore.jks + KC_SPI_TRUSTSTORE_FILE_PASSWORD: ${KC_SPI_TRUSTSTORE_FILE_PASSWORD} + ############################# + KC_LOG_LEVEL: ${KC_LOG_LEVEL} + + ports: + - "8080:8080" + - "8443:8443" + - "9000:9000" + restart: always + depends_on: + - postgres + networks: + - keycloak_network + volumes: + - ../localhost.p12:/keystores/localhost.p12 + - ../localhost.crt:/cert/localhost.crt + - ../localhost.key:/key/localhost.key + - ../keystore.jks:/cert/keystore.jks + - ../truststore.jks:/cert/truststore.jks + +volumes: + postgres_data: + driver: local + +networks: + keycloak_network: + driver: bridge \ No newline at end of file diff --git a/docker/keycloak/tofu/Makefile b/docker/keycloak/tofu/Makefile new file mode 100644 index 0000000..5a72166 --- /dev/null +++ b/docker/keycloak/tofu/Makefile @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +# Default values +WORKSPACE ?= dev +DIR ?= 01-global # Change this based on the directory you want to work with + +init: + @cd $(DIR) && tofu init -backend-config=backends/$(WORKSPACE)-backend.tfvars + @cd $(DIR) && tofu workspace select $(WORKSPACE) || tofu workspace new $(WORKSPACE) + +plan: + @cd $(DIR) && tofu plan -var-file=tfvars/$(WORKSPACE).tfvars -out=tfplan + +apply: + @cd $(DIR) && tofu apply tfplan + +apply-auto-approve: + @cd $(DIR) && tofu apply -auto-approve + +destroy-plan: + @cd $(DIR) && tofu plan -destroy -var-file=tfvars/$(WORKSPACE).tfvars + +destroy: + @cd $(DIR) && tofu destroy -var-file=tfvars/$(WORKSPACE).tfvars + +format: + tofu fmt --recursive + +validate: + @cd $(DIR) && tofu validate + +pre-check: + @cd $(DIR) && tofu fmt -check + @cd $(DIR) && tofu validate + +init-upgrade: + @cd $(DIR) && rm -rf .terraform + @cd $(DIR) && tofu init -upgrade -backend-config=backends/$(WORKSPACE)-backend.tfvars + @cd $(DIR) && tofu workspace select $(WORKSPACE) || tofu workspace new $(WORKSPACE) + +pre-commit: format validate diff --git a/docker/keycloak/tofu/README.md b/docker/keycloak/tofu/README.md new file mode 100644 index 0000000..0670cd3 --- /dev/null +++ b/docker/keycloak/tofu/README.md @@ -0,0 +1,255 @@ +**Repository:** `management-node` +**Description:** `The Management Node Module is a Spring Boot application that provides APIs to be accessed by Consumer and Producer Federators. It implements a secure communication architecture using Mutual TLS (MTLS) connectivity between Federator instances and itself, as well as establishing zero trust connectivity with Keycloak for authentication and authorization.` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +# Overview + +This directory contains OpenTofu code to provision and manage Keycloak resources for the Management Node: +- Realm definition and settings +- Application clients and roles +- Client scopes and role mappings +- Optional federator clients via a module (modules/federator_client) + +The configuration uses the Keycloak provider and an S3 backend for state (configured via backend tfvars). + +Important: Backend selection +- Local runs: set a local backend. +- AWS runs: use the S3 backend as shown in backend.tf. + +Examples: + +Local backend (for running on your machine): +```hcl +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } + backend "local" {} +} +``` + +AWS S3 backend (for running in AWS): +```hcl +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } + backend "s3" {} +} +``` + +--- + +## Directory Layout + +- backend.tf - Declares the S3 backend and required providers +- providers.tf - Configures the Keycloak provider using variables +- variables.tf - Input variables used across the configuration +- realm.tf - Realm creation and base configuration +- clients.tf - Clients and role definitions +- client_scopes.tf - Client scopes and mappers +- terraform.tfvars - Default variable values for local/dev usage +- backends/ - Backend config files (e.g., dev-backend.tfvars) +- tfvars/ - Optional per-workspace tfvars files (e.g., dev.tfvars) +- modules/ - Reusable modules (e.g., federator_client) +- Makefile - Helper targets to init/plan/apply/destroy/validate + +Tip: The Makefile expects to run commands from this tofu folder and supports a WORKSPACE and DIR variable. For this repository, DIR should be set to the current directory (.). + +--- + +## Using the Makefile + +The Makefile simplifies running OpenTofu commands. + +Defaults: +- WORKSPACE=dev +- DIR=01-global (upstream default; override to . for this repo) + +Recommended to always pass DIR=. + +### 1. Setup (`make init`) +Initializes OpenTofu, selects/creates the workspace, and configures the S3 backend. + +```sh +# From docker/keycloak/tofu +make init WORKSPACE=dev DIR=. +``` + +If your backend file is named differently, adjust accordingly or rename it to match backends/-backend.tfvars. For example, ensure backends/dev-backend.tfvars exists for WORKSPACE=dev. + +### 2. Plan changes (`make plan`) +Generates an execution plan. terraform.tfvars is loaded automatically; tfvars/dev.tfvars can be used for per-workspace overrides. + +```sh +make plan WORKSPACE=dev DIR=. +``` + +### 3. Apply changes (`make apply`) +Applies the previously generated plan. + +```sh +make apply WORKSPACE=dev DIR=. +``` + +Alternatively, apply directly with auto-approve: + +```sh +make apply-auto-approve WORKSPACE=dev DIR=. +``` + +### 4. Destroy resources (`make destroy`) +Plans a destroy and destroys resources for the given workspace. + +```sh +make destroy-plan WORKSPACE=dev DIR=. +make destroy WORKSPACE=dev DIR=. +``` + +### 5. Validate and format (`make validate`, `make format`) + +```sh +make format +make validate WORKSPACE=dev DIR=. +``` + +### 6. Pre-check (`make pre-check`) +Runs fmt -check and validate. + +```sh +make pre-check WORKSPACE=dev DIR=. +``` + +--- + +## Variables +Key variables you may need to set (see variables.tf and terraform.tfvars): +- keycloak_url, keycloak_realm, keycloak_client_id, keycloak_username, keycloak_password, keycloak_client_timeout +- management_realm_name +- client_access_token_lifespan_seconds +- federator_clients (structured list for module-driven client creation and role mappings) + +These can be provided via terraform.tfvars, tfvars/.tfvars, or -var/-var-file flags. + +--- + +## Example Workflow + +```sh +cd docker/keycloak/tofu +make init WORKSPACE=dev DIR=. +make format +make validate WORKSPACE=dev DIR=. +make plan WORKSPACE=dev DIR=. +make apply WORKSPACE=dev DIR=. +``` + +--- + +## Using a local backend (no S3) on your machine +If you want to try this locally without configuring an S3 bucket, you can switch the backend from S3 to local. There are two simple approaches: + +### Option A: Use a local backend block +Edit docker/keycloak/tofu/backend.tf and change the backend block to local: + +```hcl +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } + backend "local" { + # The default path is ./terraform.tfstate + path = "terraform.tfstate" + } +} +``` + +Then use the Makefile targets (recommended): + +```sh +cd docker/keycloak/tofu +# Initialize with local backend (no -backend-config needed) +make init WORKSPACE=dev DIR=. +# Plan and apply using Makefile targets +make plan WORKSPACE=dev DIR=. +make apply WORKSPACE=dev DIR=. +``` + +### Option B: Comment out the S3 backend +Alternatively, simply comment out the S3 backend line in backend.tf so there is no backend block. OpenTofu defaults to the local backend in this case: + +Before: +```hcl +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } + backend "s3" {} +} +``` + +After (S3 backend commented out): +```hcl +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } + # backend "s3" {} +} +``` + +Then initialize and apply as in Option A using the Makefile: + +```sh +cd docker/keycloak/tofu +make init WORKSPACE=dev DIR=. +make plan WORKSPACE=dev DIR=. +make apply WORKSPACE=dev DIR=. +``` + +Notes for local usage: +- The state file terraform.tfstate will be created next to backend.tf (and is already ignored by .gitignore). +- Use the Makefile for all operations; after switching to local backend in backend.tf, simply run: + ```sh + cd docker/keycloak/tofu + make init WORKSPACE=dev DIR=. + make plan WORKSPACE=dev DIR=. + make apply WORKSPACE=dev DIR=. + ``` +- To switch back to S3 later, restore the backend "s3" {} block and run: + ```sh + cd docker/keycloak/tofu + make init WORKSPACE=dev DIR=. + ``` + +--- + +## Notes +- Backend: The backend is defined as S3 in backend.tf and is configured via backends/-backend.tfvars. Update bucket/key/region to match your environment. +- Provider auth: providers.tf uses admin credentials (admin-cli) by default for local dev. For production, configure a service account and secure secrets appropriately. +- Docker: When using docker-compose Keycloak locally (default at http://localhost:8080), the sample terraform.tfvars should work once admin credentials are set to admin/password. + +--- + +## Contributors +Thanks to all contributors of this repository: https://github.com/National-Digital-Twin/management-node/graphs/contributors diff --git a/docker/keycloak/tofu/backend.tf b/docker/keycloak/tofu/backend.tf new file mode 100644 index 0000000..7c5305f --- /dev/null +++ b/docker/keycloak/tofu/backend.tf @@ -0,0 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } + + backend "s3" {} + #backend "local" {} + +} \ No newline at end of file diff --git a/docker/keycloak/tofu/backends/dev-backend.tfvars b/docker/keycloak/tofu/backends/dev-backend.tfvars new file mode 100644 index 0000000..188b6bd --- /dev/null +++ b/docker/keycloak/tofu/backends/dev-backend.tfvars @@ -0,0 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + + +bucket = "5371-2494-4113-state" +key = "keycloak/01-base/dev/terraform.tfstate" +region = "eu-west-2" +encrypt = true diff --git a/docker/keycloak/tofu/client_scopes.tf b/docker/keycloak/tofu/client_scopes.tf new file mode 100644 index 0000000..c4ab232 --- /dev/null +++ b/docker/keycloak/tofu/client_scopes.tf @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +# Defines custom OpenID client scopes for the Management Node realm +# These scopes are referenced by modules/clients via their names + +resource "keycloak_openid_client_scope" "federator_consumer" { + realm_id = keycloak_realm.management-node.id + name = "FEDERATOR_CONSUMER" + description = "Client scope for Federator consumer" +} + +# Ensure Federator Consumer scope resolves audience dynamically (provider v5.4 compatible) +resource "keycloak_generic_protocol_mapper" "federator_consumer_audience_resolve" { + realm_id = keycloak_realm.management-node.id + client_scope_id = keycloak_openid_client_scope.federator_consumer.id + name = "audience resolve" + protocol = "openid-connect" + protocol_mapper = "oidc-audience-resolve-mapper" + + config = { + "access.token.claim" = "true" + "id.token.claim" = "false" + } +} + +resource "keycloak_openid_client_scope" "federator_producer" { + realm_id = keycloak_realm.management-node.id + name = "FEDERATOR_PRODUCER" + description = "Client scope for Federator producer" +} + +# Ensure Federator Producer scope resolves audience dynamically (provider v5.4 compatible) +resource "keycloak_generic_protocol_mapper" "federator_producer_audience_resolve" { + realm_id = keycloak_realm.management-node.id + client_scope_id = keycloak_openid_client_scope.federator_producer.id + name = "audience resolve" + protocol = "openid-connect" + protocol_mapper = "oidc-audience-resolve-mapper" + + config = { + "access.token.claim" = "true" + "id.token.claim" = "false" + } +} + +# Scope that adds management-node audience and exposes its client roles in tokens +resource "keycloak_openid_client_scope" "management_node_access" { + realm_id = keycloak_realm.management-node.id + name = "MANAGEMENT_NODE_ACCESS" + description = "Adds management-node audience and maps its client roles" +} + +# Add audience mapper to include management-node in the 'aud' claim for tokens using this scope +resource "keycloak_openid_audience_protocol_mapper" "management_node_aud" { + realm_id = keycloak_realm.management-node.id + client_scope_id = keycloak_openid_client_scope.management_node_access.id + name = "aud-management-node" + + included_client_audience = "management-node" + + add_to_access_token = true + add_to_id_token = false +} + +# Map client roles from management-node into resource_access.management-node.roles +resource "keycloak_openid_user_client_role_protocol_mapper" "management_node_roles" { + realm_id = keycloak_realm.management-node.id + client_scope_id = keycloak_openid_client_scope.management_node_access.id + name = "roles-management-node" + + # client whose roles will be added to the token + + claim_name = "resource_access.management-node.roles" + add_to_access_token = true + add_to_id_token = false + multivalued = true +} diff --git a/docker/keycloak/tofu/clients.tf b/docker/keycloak/tofu/clients.tf new file mode 100644 index 0000000..1268c6d --- /dev/null +++ b/docker/keycloak/tofu/clients.tf @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +resource "keycloak_openid_client" "management_node" { + realm_id = keycloak_realm.management-node.id + client_id = "management-node" + name = "Management Node" + description = "Management Node Client" + enabled = true + access_type = "CONFIDENTIAL" + standard_flow_enabled = false # disable browser-based auth + direct_access_grants_enabled = false + service_accounts_enabled = true # allow use of client credentials + + # Per-client JWT access token lifespan (seconds) + access_token_lifespan = var.client_access_token_lifespan_seconds +} + + +# Create custom client roles for the management-node client +resource "keycloak_role" "access_consumer_configurations" { + realm_id = keycloak_realm.management-node.id + client_id = keycloak_openid_client.management_node.id + name = "access_consumer_configurations" + description = "Allows access to consumer configuration resources" +} + +resource "keycloak_role" "access_producer_configurations" { + realm_id = keycloak_realm.management-node.id + client_id = keycloak_openid_client.management_node.id + name = "access_producer_configurations" + description = "Allows access to producer configuration resources" +} + +# Configure clients from federator_clients variable +module "federator_client" { + source = "./modules/federator_client" + + for_each = { for c in var.federator_clients : c.client => c } + + client_id = each.value.client + realm_id = keycloak_realm.management-node.id + + # Ensure base client and its roles exist before mapping + depends_on = [ + keycloak_openid_client.management_node, + keycloak_role.access_consumer_configurations, + keycloak_role.access_producer_configurations, + ] + + # Token lifespan for this client + client_access_token_lifespan_seconds = var.client_access_token_lifespan_seconds + + # Reuse the same default client scopes as the other federator clients + default_client_scopes = [ + "FEDERATOR_CONSUMER", + "FEDERATOR_PRODUCER", + "MANAGEMENT_NODE_ACCESS" + ] + + # Create roles under this client + custom_roles = [ + for r in lookup(each.value, "roles", []) : { + name = r + } + ] + + # Assign mapped roles from other clients to this client's service account + service_account_role_ids = flatten([ + for m in lookup(each.value, "mapped_client_roles", []) : [ + for role_name in m.roles : { + name = role_name + from_client = m.client + } + ] + ]) +} \ No newline at end of file diff --git a/docker/keycloak/tofu/modules/federator_client/main.tf b/docker/keycloak/tofu/modules/federator_client/main.tf new file mode 100644 index 0000000..5031469 --- /dev/null +++ b/docker/keycloak/tofu/modules/federator_client/main.tf @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +resource "keycloak_openid_client" "this" { + realm_id = var.realm_id + client_id = var.client_id + name = coalesce(var.name, var.client_id) + description = var.description + enabled = var.enabled + access_type = var.access_type + standard_flow_enabled = var.standard_flow_enabled + implicit_flow_enabled = var.implicit_flow_enabled + direct_access_grants_enabled = var.direct_access_grants_enabled + service_accounts_enabled = var.service_accounts_enabled + + # Per-client JWT access token lifespan (seconds) + access_token_lifespan = var.client_access_token_lifespan_seconds + + # Optional toggles most people like off in machine clients + consent_required = var.consent_required + backchannel_logout_session_required = var.backchannel_logout_session_required + backchannel_logout_url = var.backchannel_logout_url + + client_authenticator_type = var.client_authenticator_type + + extra_config = { + "x509.subjectdn" = var.x509_subject_dn + "x509.allow.regex.pattern.comparison" = tostring(var.x509_allow_regex_pattern_comparison) + } +} + +# Attach default client scopes if provided +resource "keycloak_openid_client_default_scopes" "this" { + count = length(var.default_client_scopes) > 0 ? 1 : 0 + realm_id = var.realm_id + client_id = keycloak_openid_client.this.id + default_scopes = var.default_client_scopes +} + +# Attach optional client scopes if provided +resource "keycloak_openid_client_optional_scopes" "this" { + count = length(var.optional_client_scopes) > 0 ? 1 : 0 + realm_id = var.realm_id + client_id = keycloak_openid_client.this.id + optional_scopes = var.optional_client_scopes +} + +# Custom client roles (scoped to this client) +resource "keycloak_role" "custom_roles" { + for_each = { for r in var.custom_roles : r.name => r } + realm_id = var.realm_id + client_id = keycloak_openid_client.this.id + name = each.value.name + description = try(each.value.description, null) +} + +# Resolve container client UUIDs for the provided roles +# Build a set of unique source client_ids (strings) we need to resolve +locals { + role_source_clients = toset([for r in var.service_account_role_ids : r.from_client]) +} + +data "keycloak_openid_client" "role_containers" { + for_each = local.role_source_clients + realm_id = var.realm_id + client_id = each.key +} + +# Assign provided roles to this client's service account (if any provided) +resource "keycloak_openid_client_service_account_role" "service_account_roles" { + # Use only input-derived, stable keys for for_each to avoid plan-time unknowns + for_each = { for r in var.service_account_role_ids : "${r.from_client}:${r.name}" => r } + + realm_id = var.realm_id + # IMPORTANT: this client_id must be the container (owner) of the role) + # Resolve the container client's UUID here (arguments may be unknown at plan time, which is OK) + client_id = data.keycloak_openid_client.role_containers[each.value.from_client].id + service_account_user_id = keycloak_openid_client.this.service_account_user_id + + # Provider expects 'role' to be the role NAME + role = each.value.name +} + +# Optionally assign custom roles defined on this client to its own service account +# This ensures roles are not only created under the client, but are also granted to the +# service account so that the corresponding audience is applied in tokens. +resource "keycloak_openid_client_service_account_role" "assign_custom_roles_to_sa" { + for_each = var.assign_roles_to_service_account && var.service_accounts_enabled ? keycloak_role.custom_roles : {} + + realm_id = var.realm_id + client_id = keycloak_openid_client.this.id + service_account_user_id = keycloak_openid_client.this.service_account_user_id + role = each.key +} + + diff --git a/docker/keycloak/tofu/modules/federator_client/outputs.tf b/docker/keycloak/tofu/modules/federator_client/outputs.tf new file mode 100644 index 0000000..9a569fe --- /dev/null +++ b/docker/keycloak/tofu/modules/federator_client/outputs.tf @@ -0,0 +1,13 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + + +output "client_uuid" { + description = "UUID of the created Keycloak client (container client_id for role assignments)" + value = keycloak_openid_client.this.id +} + +output "custom_role_names" { + description = "List of custom role names created under this client (if any)" + value = keys(keycloak_role.custom_roles) +} \ No newline at end of file diff --git a/docker/keycloak/tofu/modules/federator_client/providers.tf b/docker/keycloak/tofu/modules/federator_client/providers.tf new file mode 100644 index 0000000..72dbb98 --- /dev/null +++ b/docker/keycloak/tofu/modules/federator_client/providers.tf @@ -0,0 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +terraform { + required_version = ">= 1.6.0" + required_providers { + keycloak = { + source = "keycloak/keycloak" + version = "~> 5.4" + } + } +} diff --git a/docker/keycloak/tofu/modules/federator_client/variables.tf b/docker/keycloak/tofu/modules/federator_client/variables.tf new file mode 100644 index 0000000..4bfa477 --- /dev/null +++ b/docker/keycloak/tofu/modules/federator_client/variables.tf @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +variable "realm_id" { + description = "Target realm where the client will be created" + type = string +} + +variable "client_id" { + description = "OIDC client_id" + type = string +} + +variable "name" { + description = "Human-friendly name (defaults to client_id)" + type = string + default = null +} + +variable "description" { + type = string + default = null +} + +variable "enabled" { + type = bool + default = true +} + +variable "access_type" { + description = "CONFIDENTIAL | PUBLIC | BEARER-ONLY" + type = string + default = "CONFIDENTIAL" +} + +variable "standard_flow_enabled" { + # auth code + type = bool + default = false +} + +variable "implicit_flow_enabled" { + type = bool + default = false +} + +variable "direct_access_grants_enabled" { + # ROPC + type = bool + default = false +} + +variable "service_accounts_enabled" { + type = bool + default = true +} + +variable "consent_required" { + type = bool + default = false +} + +variable "backchannel_logout_session_required" { + type = bool + default = false +} +variable "backchannel_logout_url" { + type = string + default = null +} + +variable "custom_roles" { + description = "List of custom client roles to create on this client" + type = list(object({ + name = string + description = optional(string) + })) + default = [] +} + +variable "assign_roles_to_service_account" { + description = "If true, assign custom + extra roles to the service account" + type = bool + default = true +} + +# X.509 client authentication settings +variable "client_authenticator_type" { + description = "Client authenticator type to use for this client (e.g., client-secret, client-x509)" + type = string + default = "client-x509" +} + +variable "x509_subject_dn" { + description = "Expected Subject DN for TLS client authentication. Supports regex when x509_allow_regex_pattern_comparison is true." + type = string + default = "(.*?)(?:$)" +} + +variable "x509_allow_regex_pattern_comparison" { + description = "Whether to allow regex pattern comparison for x509.subjectdn. Keycloak attribute: x509.allow.regex.pattern.comparison" + type = bool + default = true +} + +# Client scopes to be attached to this client +variable "default_client_scopes" { + description = "List of existing client scopes to attach as default scopes to this client" + type = list(string) + default = [] +} + +variable "optional_client_scopes" { + description = "List of existing client scopes to attach as optional scopes to this client" + type = list(string) + default = [] +} + +# Roles (with container client reference) to assign to this client's service account user +variable "service_account_role_ids" { + description = "List of roles to assign to the client's service account. Each item must contain the role name and the source client identifier (client_id string) that owns the role. The module will resolve it to a UUID." + type = list(object({ + name = string # role NAME + from_client = string # source client_id (string, e.g., 'management-node' or another client_id) + })) + default = [] +} + +# Token settings +variable "client_access_token_lifespan_seconds" { + description = "Access token lifespan for this client (in seconds). Default 30 minutes (1800)." + type = number + default = 1800 +} diff --git a/docker/keycloak/tofu/providers.tf b/docker/keycloak/tofu/providers.tf new file mode 100644 index 0000000..ceeb124 --- /dev/null +++ b/docker/keycloak/tofu/providers.tf @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +provider "keycloak" { + url = var.keycloak_url + realm = var.keycloak_realm # manage realms from master + client_id = var.keycloak_client_id + username = var.keycloak_username + password = var.keycloak_password + client_timeout = var.keycloak_client_timeout +} diff --git a/docker/keycloak/tofu/realm.tf b/docker/keycloak/tofu/realm.tf new file mode 100644 index 0000000..d32f0ce --- /dev/null +++ b/docker/keycloak/tofu/realm.tf @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + + +resource "keycloak_realm" "management-node" { + realm = var.management_realm_name + display_name = "Management Node Realm" + enabled = true + + # Common login toggles + login_with_email_allowed = false + registration_allowed = false + reset_password_allowed = false + + # Optional: configure password policy + password_policy = "hashIterations(27500) and length(12) and digits(1) and specialChars(1)" +} + +# Manage realm default roles so that built-in account roles are not assigned by default +resource "keycloak_default_roles" "management_node_defaults" { + realm_id = keycloak_realm.management-node.id + + # Do not assign any realm-level default roles to new users + # This effectively prevents Keycloak from granting the composite 'default-roles-' + # which includes built-in client roles like 'manage-account' and 'view-profile'. + default_roles = [] +} diff --git a/docker/keycloak/tofu/terraform.tfvars b/docker/keycloak/tofu/terraform.tfvars new file mode 100644 index 0000000..c35b1c9 --- /dev/null +++ b/docker/keycloak/tofu/terraform.tfvars @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +keycloak_url = "http://localhost:8080" +keycloak_realm = "master" +keycloak_client_id = "admin-cli" +keycloak_username = "admin" +keycloak_password = "password" +keycloak_client_timeout = 30 + +management_realm_name = "mng-node" + +# JWT access token lifespan for clients (in seconds). Default is 1800 (30 minutes) +client_access_token_lifespan_seconds = 1800 + +# Structured federator clients configuration +federator_clients = [ + + { + client = "FEDERATOR_ENV" + roles = ["FloodRiskMapZones"] + mapped_client_roles = [ + { + client = "management-node" + roles = ["access_producer_configurations", "access_consumer_configurations"] + } + ] + }, + { + client = "FEDERATOR_BCC" + roles = ["PendingPlanningApplications"] + mapped_client_roles = [ + { + client = "management-node" + roles = ["access_producer_configurations", "access_consumer_configurations"] + } + ] + }, + { + client = "FEDERATOR_HEG" + roles = ["BrownfieldLandAvailability"] + mapped_client_roles = [ + { + client = "management-node" + roles = ["access_producer_configurations", "access_consumer_configurations"] + } + ] + }, + { + client = "MANAGEMENT_NODE_CLIENT" + roles = [] + mapped_client_roles = [ + { + client = "management-node" + roles = ["access_producer_configurations", "access_consumer_configurations"] + } + ] + } +] + diff --git a/docker/keycloak/tofu/tfvars/dev.tfvars b/docker/keycloak/tofu/tfvars/dev.tfvars new file mode 100644 index 0000000..22c4bbb --- /dev/null +++ b/docker/keycloak/tofu/tfvars/dev.tfvars @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. diff --git a/docker/keycloak/tofu/variables.tf b/docker/keycloak/tofu/variables.tf new file mode 100644 index 0000000..31e37fd --- /dev/null +++ b/docker/keycloak/tofu/variables.tf @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. + +variable "keycloak_url" { + description = "Keycloak base URL" + type = string +} + +variable "keycloak_realm" { + description = "Realm to manage (typically 'master' for administrative operations)" + type = string +} + +variable "keycloak_client_id" { + description = "Keycloak client ID used for authentication" + type = string +} + +variable "keycloak_username" { + description = "Admin username for Keycloak" + type = string +} + +variable "keycloak_password" { + description = "Admin password for Keycloak" + type = string + sensitive = true +} + +variable "keycloak_client_timeout" { + description = "Timeout in seconds for Keycloak provider client requests" + type = number +} + +variable "management_realm_name" { + description = "Name of the Keycloak realm to create/manage" + type = string +} + +variable "client_access_token_lifespan_seconds" { + description = "Access token lifespan for clients (in seconds). Default 30 minutes (1800)." + type = number + default = 1800 +} + +variable "federator_clients" { + description = "List of federator clients to create with their own roles and mapped roles from other clients" + type = list(object({ + client = string + roles = optional(list(string), []) + mapped_client_roles = optional(list(object({ + client = string + roles = list(string) + })), []) + })) + default = [] +} + diff --git a/docker/publish.sh b/docker/publish.sh new file mode 100755 index 0000000..86b063d --- /dev/null +++ b/docker/publish.sh @@ -0,0 +1,20 @@ +#!/bin/sh + +# +# SPDX-License-Identifier: Apache-2.0 +# © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally +# attributed to the Department for Business and Trade (UK) as the governing entity. +# + +AWS_REGION=eu-west-2 +AWS_ACCOUNT=$(aws sts get-caller-identity --query Account --output text) + +aws ecr get-login-password --region $AWS_REGION | sudo docker login --username AWS --password-stdin $AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com +TAG=1.0.$(date +%s) + +IMAGE=$AWS_ACCOUNT.dkr.ecr.$AWS_REGION.amazonaws.com/ndtp/management-node:$TAG + +sudo docker tag ndtp/management-node $IMAGE +sudo docker push $IMAGE + +echo "Revision tag: $TAG" diff --git a/docs/AUTHENTICATION_REQUIREMENTS.md b/docs/AUTHENTICATION_REQUIREMENTS.md new file mode 100644 index 0000000..9bef0fa --- /dev/null +++ b/docs/AUTHENTICATION_REQUIREMENTS.md @@ -0,0 +1,100 @@ +# Authentication Requirements + +**Repository:** `management-node` +**Description:** `Provides APIs to be accessed by Consumer and Producer Federators for the purpose of dynamic configuration management ` +**SPDX-License-Identifier:** `Apache-2.0 AND OGL-UK-3.0 ` + +--- +Management Node uses OAuth 2.0 with JWT bearer tokens for authentication and authorization. Tokens are typically issued by Keycloak in this project’s reference setup. + +Core requirements for every request to protected APIs: +- Bearer token: Requests must include `Authorization: Bearer `. +- Audience (aud) claim: The token MUST contain an audience that includes `"management-node"`. +- resource_access claim: The token MUST include a `resource_access` claim, which carries client-application roles used for authorization decisions. + +Notes: +- The API enforces role checks at endpoint level using Spring Security `@PreAuthorize` expressions. +- The Swagger UI documents the security scheme as HTTP bearer with JWT; you can use it to try endpoints by supplying a valid token. + +## Token structure requirements + +A compliant JWT will contain at least the following claims: +- `aud`: must include `management-node` (either as a string or within an array, depending on the issuer configuration). +- `resource_access`: an object mapping client IDs to role arrays. + +Sample JWT payload (use this structure when testing locally): +``` +{ + "exp": 1757863604, + "iat": 1757861804, + "jti": "trrtcc:a245819b-9a9f-648f-2e95-2390f6987c03", + "iss": "https://localhost:8443/realms/mng-node", + "aud": [ + "management-node", + "FEDERATOR_HEG" + ], + "sub": "ec13a601-9b02-443a-99ff-66f1eb146ae9", + "typ": "Bearer", + "azp": "FEDERATOR_BCC", + "resource_access": { + "management-node": { + "roles": [ + "access_producer_configurations", + "access_consumer_configurations", + "BrownfieldLandAvailability", + "PendingPlanningApplications" + ] + } + }, + "scope": "FEDERATOR_PRODUCER MANAGEMENT_NODE_ACCESS FEDERATOR_CONSUMER" +} +``` + +Notes: +- The aud claim may be a list (as shown) and must include "management-node". +- The resource_access.management-node.roles array must contain the role required for the API you are calling. + +## Role requirements per API + +- Producer API: Federator clients may access Producer configuration only when their token contains the role `access_producer_configurations` under the `resource_access` for the audience/client `management-node`. + - Enforcement in code: `@PreAuthorize("hasRole('ROLE_management-node:access_producer_configurations')")` on `/api/v1/configuration/producer`. + +- Consumer API: Federator clients may access Consumer configuration only when their token contains the role `access_consumer_configurations` under the `resource_access` for the audience/client `management-node`. + - Enforcement in code: `@PreAuthorize("hasRole('ROLE_management-node:access_consumer_configurations')")` on `/api/v1/configuration/consumer`. + +## How this maps to Keycloak + +- In Keycloak, roles are typically assigned to a client (here conceptually the `management-node` client) and appear in tokens under `resource_access["management-node"].roles`. +- Ensure the token’s audience includes `management-node`. This can be achieved by: + - Setting the client as an audience in the token via an Audience mapper, or + - Using the `audience resolve`/`Full Scope Allowed` as per your realm design. +- Create and assign the following client roles on the `management-node` client: + - `access_producer_configurations` + - `access_consumer_configurations` +- Assign these roles to the appropriate Producer or Consumer Federator clients or service accounts. + +## Requesting tokens (example) + +Using client credentials with mTLS (as per the project’s Keycloak setup): +``` +curl --location 'https://localhost:8443/realms/management-node/protocol/openid-connect/token' \ + --cert client.crt --key client.key \ + --header 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode 'client_id=' \ + --data-urlencode 'grant_type=client_credentials' +``` + +Supply the returned access token to the Management Node API requests: +``` +curl -k 'https://localhost:8090/api/v1/configuration/producer' \ + -H 'Authorization: Bearer ' +``` + +## Summary + +- Authentication: JWT bearer tokens. +- Mandatory claims: `aud` includes `management-node`, and `resource_access` present. +- Authorization: + - Producer API requires role: `access_producer_configurations`. + - Consumer API requires role: `access_consumer_configurations`. +- Swagger/OpenAPI: Use Swagger UI at `/swagger-ui.html` to explore and test with a valid token. \ No newline at end of file diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..19853da --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -0,0 +1,182 @@ +# Database Schema + +This document describes the relational database schema used by the Management Node. The schema is applied via Flyway migrations located at: + +- `src/main/resources/db/migration/` + +The current schema is based on the following migrations: +- `V20250728142253__intial_database_tables.sql` +- `V20250914182403__productConsumersAttributesTable.sql` + +The database is designed to model Organisations, their Producers and Consumers, the Products offered by Producers, and the access grants that allow specific Consumers to access specific Products. Additional attributes can be attached to each grant. + +## Overview of Entities and Relationships + +- Organisation has many Producers and Consumers +- Producer belongs to an Organisation +- Consumer belongs to an Organisation +- Product belongs to a Producer +- Product ↔ Consumer is a many-to-many relationship implemented via the join table `product_consumer` +- Each `product_consumer` (grant) can have many `product_consumer_attribute` rows for extensible metadata + +A simple ER diagram (Mermaid): + +```mermaid +erDiagram + ORGANISATION ||--o{ PRODUCER : has + ORGANISATION ||--o{ CONSUMER : has + PRODUCER ||--o{ PRODUCT : offers + PRODUCT ||--o{ PRODUCT_CONSUMER : grants + CONSUMER ||--o{ PRODUCT_CONSUMER : consumes + PRODUCT_CONSUMER ||--o{ PRODUCT_CONSUMER_ATTRIBUTE : has + + ORGANISATION { + BIGSERIAL id PK + VARCHAR name + } + PRODUCER { + BIGSERIAL id PK + VARCHAR name + TEXT description + BIGINT org_id FK -> ORGANISATION.id + BOOLEAN active + VARCHAR host + NUMERIC port + BOOLEAN tls + VARCHAR idp_client_id + } + CONSUMER { + BIGSERIAL id PK + VARCHAR name + BIGINT org_id FK -> ORGANISATION.id + VARCHAR idp_client_id + } + PRODUCT { + BIGSERIAL id PK + VARCHAR name + VARCHAR topic + BIGINT producer_id FK -> PRODUCER.id + } + PRODUCT_CONSUMER { + BIGSERIAL id PK + BIGINT product_id FK -> PRODUCT.id + BIGINT consumer_id FK -> CONSUMER.id + TIMESTAMP granted_ts + NUMERIC validity + UNIQUE (product_id, consumer_id) + } + PRODUCT_CONSUMER_ATTRIBUTE { + BIGSERIAL id PK + VARCHAR name + VARCHAR type + VARCHAR value + BIGINT product_consumer_id FK -> PRODUCT_CONSUMER.id + } +``` + +--- + +## Tables + +### organisation +Represents an organisation that owns Producers and Consumers. + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(150), not null + +Usage: +- Parent entity for `producer` and `consumer`. + +--- + +### producer +Represents a Producer federator/service that offers one or more Products. + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(50), not null +- `description` TEXT, not null +- `org_id` BIGINT, not null, foreign key → `organisation(id)` +- `active` BOOLEAN, not null +- `host` VARCHAR(500), not null — host or base URL where the producer can be reached +- `port` NUMERIC, not null — network port (stored as numeric) +- `tls` BOOLEAN, not null — whether TLS is required for this endpoint +- `idp_client_id` VARCHAR(50), not null — identity provider client id (e.g., Keycloak). Informational; not an FK + +Usage: +- Owns `product` records. +- Links an Organisation to concrete connection details for the Producer. + +--- + +### consumer +Represents a Consumer federator/client that requests access to Products. + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(50), not null +- `org_id` BIGINT, not null, foreign key → `organisation(id)` +- `idp_client_id` VARCHAR(50), not null — identity provider client id (e.g., Keycloak). Informational; not an FK + +Usage: +- Participates in access grants via `product_consumer`. + +--- + +### product +Represents a Product (e.g., a data stream or dataset) offered by a Producer. + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(50), not null +- `topic` VARCHAR(150), not null — logical topic or channel for the product +- `producer_id` BIGINT, not null, foreign key → `producer(id)` + +Usage: +- The resource being granted to Consumers via `product_consumer`. + +--- + +### product_consumer +Join table representing an access grant that allows a Consumer to access a Product. + +Columns (after migration `V20250914182403`): +- `id` BIGSERIAL, primary key +- `product_id` BIGINT, not null, foreign key → `product(id)` +- `consumer_id` BIGINT, not null, foreign key → `consumer(id)` +- `granted_ts` TIMESTAMP, not null — timestamp when access was granted +- `validity` NUMERIC, not null — validity period/units are application-defined +- `uq_product_consumer_pair` UNIQUE (`product_id`, `consumer_id`) — ensures one grant per pair + +Notes: +- Originally used a composite primary key (`product_id`, `consumer_id`); later replaced by surrogate `id` while preserving uniqueness via `uq_product_consumer_pair`. + +Usage: +- Central record for authorization decisions: which Consumer can access which Product and since when. + +--- + +### product_consumer_attribute +Extensible attributes attached to a specific `product_consumer` grant (key/value-like rows with a simple type field). + +Columns: +- `id` BIGSERIAL, primary key +- `name` VARCHAR(150), not null — attribute name/key +- `type` VARCHAR(50), not null — attribute type (string indicator) +- `value` VARCHAR(500), not null — attribute value +- `product_consumer_id` BIGINT, not null, foreign key → `product_consumer(id)` + +Usage: +- Store additional constraints or metadata for a grant (e.g., scopes, rate limits, contractual flags). Semantics are defined by application logic. + +--- + +## Migration Notes +- Schema is versioned and applied with Flyway on application startup. +- Foreign keys enforce referential integrity among core entities. +- Consider adding database indexes on foreign key columns (`producer.producer_id`, `consumer.org_id`, `product.producer_id`, `product_consumer.product_id`, `product_consumer.consumer_id`, `product_consumer_attribute.product_consumer_id`) to optimize query performance, if not already present in future migrations. + +## Data Protection and Security +- Identity fields like `idp_client_id` are not foreign keys; they link to external IdP configuration (e.g., Keycloak) at the application layer. +- Ensure that any PII or sensitive metadata stored in attributes follows your organization’s data handling policies. \ No newline at end of file diff --git a/docs/JACOCO_COVERAGE.md b/docs/JACOCO_COVERAGE.md new file mode 100644 index 0000000..345c33a --- /dev/null +++ b/docs/JACOCO_COVERAGE.md @@ -0,0 +1,88 @@ +# JaCoCo Code Coverage Setup + +## Overview + +This document describes the JaCoCo code coverage setup for the Management Node application. JaCoCo has been configured to measure code coverage and ensure that it meets the specified thresholds. + +## Current Configuration + +JaCoCo has been configured in the `pom.xml` file with the following settings: + +1. **Coverage Thresholds**: Currently set to 50% for: + - Instructions + - Branches + - Lines + - Methods + - Classes + +2. **Excluded Packages/Classes**: + - DTOs (`**/dto/**`) + - Entity classes (`**/entity/**`) + - Configuration classes (`**/config/**`) + - Exception classes (`**/exception/**`) + - Main application class (`**/ManagementNodeApplication.java`) + +3. **Build Configuration**: + - Tests will run even if they fail (`testFailureIgnore=true` in maven-surefire-plugin) + - Coverage checks will not fail the build if thresholds aren't met (`haltOnFailure=false`) + +## Current Coverage Levels + +As of the latest build, the coverage levels are: +- Branches: 0% +- Lines: 22% +- Methods: 33% + +These are below the current thresholds of 50%, and significantly below the target of 80%. + +## Running the Coverage Report + +To generate the JaCoCo coverage report, run: + +```bash +./mvnw clean verify +``` + +The report will be generated in the `target/site/jacoco` directory. Open `target/site/jacoco/index.html` in a web browser to view the detailed coverage report. + +## Recommendations for Improving Coverage + +To reach the target of 80% code coverage: + +1. **Fix Failing Tests**: + - Address the NullPointerException in `ConsumerAllowedDataProviderServiceImplTest` + - Ensure all existing tests pass + +2. **Add More Tests**: + - Focus on adding tests for uncovered branches + - Increase method coverage by testing all public methods + - Prioritize testing business logic and service implementations + +3. **Gradual Threshold Increase**: + - Once coverage improves, gradually increase thresholds in the JaCoCo configuration + - Aim for incremental improvements: 50% → 60% → 70% → 80% + +4. **Consider Additional Exclusions**: + - If certain classes are not practical to test, consider adding them to the exclusions + - Document the rationale for any exclusions + +## Final Goal + +The final goal is to achieve 80% code coverage across all metrics: +- 80% instruction coverage +- 80% branch coverage +- 80% line coverage +- 80% method coverage +- 80% class coverage + +Once this goal is achieved, update the JaCoCo configuration to: +1. Set all thresholds to 80% +2. Set `haltOnFailure` to `true` to enforce the coverage requirements + +## Best Practices + +1. **Write Tests First**: Follow Test-Driven Development (TDD) principles +2. **Focus on Quality**: Aim for meaningful tests that verify behavior, not just increase coverage +3. **Regular Monitoring**: Check coverage reports regularly to identify areas needing improvement +4. **Integration with CI/CD**: Include coverage checks in your CI/CD pipeline +5. **Documentation**: Keep this document updated with changes to coverage configuration \ No newline at end of file diff --git a/docs/MOCKITO_USAGE.md b/docs/MOCKITO_USAGE.md new file mode 100644 index 0000000..888864d --- /dev/null +++ b/docs/MOCKITO_USAGE.md @@ -0,0 +1,123 @@ +# Mockito Testing Tool Usage Guide + +## Overview + +Mockito is a popular mocking framework for Java that allows you to create and configure mock objects. Using Mockito, you can verify that certain methods are called with certain parameters, stub method calls to return specific values, and more. + +This guide explains how Mockito has been integrated into the project and provides examples of how to use it for testing. + +## Dependencies Added + +The following dependencies have been added to the project's `pom.xml`: + +```xml + + org.mockito + mockito-core + 5.10.0 + test + + + org.mockito + mockito-junit-jupiter + 5.10.0 + test + +``` + +## Basic Mockito Usage + +### Setting Up Mockito in a Test Class + +To use Mockito with JUnit 5, add the `@ExtendWith(MockitoExtension.class)` annotation to your test class: + +```java +@ExtendWith(MockitoExtension.class) +class MyServiceTest { + // Test methods +} +``` + +### Creating Mock Objects + +Use the `@Mock` annotation to create mock objects: + +```java +@Mock +private DependencyService dependencyService; +``` + +### Injecting Mocks + +Use the `@InjectMocks` annotation to inject mock objects into the class under test: + +```java +@InjectMocks +private MyService myService; +``` + +## Mockito Examples + +### Stubbing Method Calls + +```java +// Stub a method to return a specific value +when(dependencyService.getData()).thenReturn(expectedData); + +// Stub a method with any argument of a specific type +when(dependencyService.processData(any(Data.class))).thenReturn(processedData); + +// Stub a method with a specific argument +when(dependencyService.findById("1")).thenReturn(Optional.of(testData)); + +// Stub a method with a combination of specific and any arguments +when(dependencyService.updateData(eq("1"), any(Data.class))).thenReturn(Optional.of(updatedData)); +``` + +### Verifying Method Calls + +```java +// Verify that a method was called exactly once +verify(dependencyService, times(1)).getData(); + +// Verify that a method was called with a specific argument +verify(dependencyService, times(1)).findById("1"); + +// Verify that a method was called with a combination of specific and any arguments +verify(dependencyService, times(1)).updateData(eq("1"), any(Data.class)); +``` + +## Advanced Mockito Features + +Mockito offers many advanced features not covered in the examples: + +1. **Argument Captors**: Capture arguments passed to methods for further verification +2. **Spies**: Create partial mocks that call real methods but can still be verified and stubbed +3. **Verification Modes**: Verify method calls with different modes like `atLeastOnce()`, `atMost(n)`, etc. +4. **Answer Interfaces**: Provide custom answers for stubbed methods +5. **Verification Timeouts**: Verify method calls with timeouts for concurrent code + +For more information, refer to the [Mockito documentation](https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html). + +## Best Practices + +1. **Keep Tests Focused**: Each test should verify a single behavior +2. **Use Descriptive Test Names**: Test names should describe what they're testing +3. **Minimize Stubbing**: Only stub methods that are necessary for the test +4. **Verify Important Interactions**: Only verify method calls that are important for the test +5. **Use Argument Matchers Consistently**: If you use an argument matcher for one argument, you must use matchers for all arguments in that method call +6. **Reset Mocks When Necessary**: Use `reset(mock)` when you need to reset a mock's state between tests + +## Troubleshooting + +### Common Issues + +1. **"Invalid use of argument matchers"**: If you use an argument matcher for one argument, you must use matchers for all arguments in that method call +2. **"Wanted but not invoked"**: The method you're verifying was not called with the specified arguments +3. **"Unnecessary stubbing"**: You stubbed a method that was not called during the test + +### Solutions + +1. Use `any()`, `eq()`, or other matchers consistently for all arguments +2. Check that the method is being called with the expected arguments +3. Remove unnecessary stubbing or add `lenient()` to the stubbing \ No newline at end of file diff --git a/docs/MTLS_CONFIGURATION.md b/docs/MTLS_CONFIGURATION.md new file mode 100644 index 0000000..7cdcdb5 --- /dev/null +++ b/docs/MTLS_CONFIGURATION.md @@ -0,0 +1,160 @@ +# MTLS Configuration Guide + +This guide provides detailed instructions on how to configure Mutual TLS (MTLS) for both the Keycloak authentication server and the Management Node Spring Boot application. + +## What is MTLS and Why It's Needed + +Mutual TLS (MTLS) is a security protocol that requires both the client and server to authenticate each other using X.509 certificates. Unlike standard TLS where only the server authenticates itself to the client, MTLS ensures bidirectional authentication, providing a higher level of security. + +In the context of the Management Node Module: +- MTLS establishes a zero-trust security model where all communications must be authenticated and encrypted +- It provides service-to-service authentication without relying on passwords or API keys +- It prevents unauthorized access to sensitive APIs and data + +## Certificate Files Overview + +Before configuring MTLS, ensure you have the following certificate files: + +| File Type | Purpose | Used By | +|-----------|---------|---------| +| `keystore.jks` | Java keystore containing the server certificate and private key | Keycloak & Management Node | +| `truststore.jks` | Java truststore containing trusted client certificates | Keycloak & Management Node | +| `localhost.p12` | PKCS12 keystore for client authentication | Keycloak | +| `localhost.crt` | Certificate file | Keycloak | +| `localhost.key` | Private key file | Keycloak | + +For instructions on generating these files, refer to the [Certificate Setup](#certificate-setup) section in the main README. + +## Configuring MTLS for Keycloak + +Keycloak's MTLS configuration is defined in the `docker/docker-compose.yml` file. The following environment variables control MTLS behavior: + +```yaml +KC_HTTPS_CLIENT_AUTH: ${KC_HTTPS_CLIENT_AUTH} # Set to 'required' to enforce MTLS +KC_HTTPS_ENABLED: ${KC_HTTPS_ENABLED} # Must be 'true' for MTLS +KC_HTTPS_PORT: ${KC_HTTPS_PORT} # Default is 8443 +KC_HTTPS_KEY_STORE_FILE: /cert/keystore.jks # Server certificate +KC_HTTPS_KEY_STORE_PASSWORD: ${KC_HTTPS_KEY_STORE_PASSWORD} +KC_HTTPS_CERTIFICATE_FILE: /cert/localhost.crt +KC_HTTPS_CERTIFICATE_KEY_FILE: /key/localhost.key +KC_HTTPS_TRUST_STORE_FILE: /keystores/localhost.p12 +KC_HTTPS_TRUST_STORE_PASSWORD: ${KC_HTTPS_TRUST_STORE_PASSWORD} +KC_SPI_TRUSTSTORE_FILE_FILE: /cert/truststore.jks +KC_SPI_TRUSTSTORE_FILE_PASSWORD: ${KC_SPI_TRUSTSTORE_FILE_PASSWORD} +``` + +### Steps to Configure Keycloak MTLS: + +1. Place your certificate files in the `docker` directory: + - `keystore.jks` + - `truststore.jks` + - `localhost.p12` + - `localhost.crt` + - `localhost.key` + +2. Create or update the `.env` file in the `docker` directory with the following MTLS-related variables: + ``` + KC_HTTPS_CLIENT_AUTH=required + KC_HTTPS_ENABLED=true + KC_HTTPS_PORT=8443 + KC_HTTPS_KEY_STORE_PASSWORD=changeit + KC_HTTPS_TRUST_STORE_PASSWORD=changeit + KC_SPI_TRUSTSTORE_FILE_PASSWORD=changeit + ``` + +3. The `docker-compose.yml` file maps these certificate files into the Keycloak container: + ```yaml + volumes: + - ./localhost.p12:/keystores/localhost.p12 + - ./localhost.crt:/cert/localhost.crt + - ./localhost.key:/key/localhost.key + - ./keystore.jks:/cert/keystore.jks + - ./truststore.jks:/cert/truststore.jks + ``` + +## Configuring MTLS for the Management Node + +The Management Node's MTLS configuration is defined in the `src/main/resources/application.yml` file under the `server.ssl` section: + +```yaml +server: + port: 8090 + ssl: + key-alias: localhost + key-store: /path/to/keystore.jks + key-store-type: JKS + key-store-password: changeit + trust-store: /path/to/truststore.jks + trust-store-password: changeit + trust-store-type: JKS + client-auth: need # This enables MTLS +``` + +### Steps to Configure Management Node MTLS: + +1. Update the `application.yml` file with the correct paths to your certificate files: + ```yaml + server: + port: 8090 + ssl: + key-alias: localhost + key-store: /path/to/keystore.jks # Update this path + key-store-type: JKS + key-store-password: changeit + trust-store: /path/to/truststore.jks # Update this path + trust-store-password: changeit + trust-store-type: JKS + client-auth: need # Add this line to enable MTLS + ``` + +2. For Docker deployment, update the Dockerfile to copy the certificate files: + ```dockerfile + COPY ../docker/keystore.jks /app/docker/keystore.jks + COPY ../docker/truststore.jks /app/docker/truststore.jks + ``` + +3. When running the application, ensure the certificate files are accessible at the paths specified in the configuration. + +## Testing MTLS Configuration + +To verify that MTLS is properly configured: + +1. For Keycloak: + ```bash + # This should fail without a client certificate + curl -k https://localhost:8443/health + + # This should succeed with a client certificate + curl -k --cert client.crt --key client.key https://localhost:8443/health + ``` + +2. For Management Node: + ```bash + # This should fail without a client certificate + curl -k https://localhost:8090/actuator/health + + # This should succeed with a client certificate + curl -k --cert client.crt --key client.key https://localhost:8090/actuator/health + ``` + +## Troubleshooting MTLS Issues + +Common MTLS configuration issues: + +1. **Certificate Path Issues**: + - Ensure the paths to certificate files are correct and accessible + - For Docker deployments, verify that volumes are properly mounted + +2. **Certificate Password Issues**: + - Verify that the passwords in configuration files match the actual certificate passwords + +3. **Certificate Trust Issues**: + - Ensure the client's certificate is trusted by the server's truststore + - Ensure the server's certificate is trusted by the client's truststore + +4. **Certificate Expiration**: + - Check that certificates are not expired + +For more detailed troubleshooting, check the logs: +- Keycloak logs: `docker logs keycloak` +- Management Node logs: Check the application logs for SSL-related errors \ No newline at end of file diff --git a/docs/entity-dto-converter-pattern.md b/docs/entity-dto-converter-pattern.md new file mode 100644 index 0000000..62b4998 --- /dev/null +++ b/docs/entity-dto-converter-pattern.md @@ -0,0 +1,140 @@ +# Entity-DTO Converter Pattern + +## Overview + +This document describes the Entity-DTO converter pattern implemented in the project to handle conversions between entity objects and DTOs (Data Transfer Objects). This pattern replaces the previous approach of using ModelMapper for these conversions. + +## Benefits + +- **Separation of Concerns**: Each converter is responsible for a specific entity-DTO pair, making the code more modular and easier to maintain. +- **Type Safety**: Converters provide type-safe conversions, reducing the risk of runtime errors. +- **Explicit Mapping**: Mappings between entities and DTOs are explicitly defined, making the code more readable and easier to debug. +- **Testability**: Converters can be easily unit tested in isolation. +- **Performance**: Custom converters can be more performant than reflection-based mapping libraries like ModelMapper. + +## Implementation + +### EntityDtoConverter Interface + +The `EntityDtoConverter` interface defines the contract for all entity-DTO converters: + +```java +public interface EntityDtoConverter { + D toDto(E entity); + E toEntity(D dto); + + default List toDtoList(List entities) { + if (entities == null) { + return List.of(); + } + return entities.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + default List toEntityList(List dtos) { + if (dtos == null) { + return List.of(); + } + return dtos.stream() + .map(this::toEntity) + .collect(Collectors.toList()); + } +} +``` + +### Concrete Converter Implementations + +Concrete converter implementations are located in the `uk.gov.dbt.ndtp.ia.node.management.converter.impl` package. Each converter: + +1. Implements the `EntityDtoConverter` interface for a specific entity-DTO pair +2. Is annotated with `@Component` for Spring dependency injection +3. Handles null values safely +4. Resolves entity relationships as needed + +Example: + +```java +@Component +public class OrganisationProducerConverter implements EntityDtoConverter { + private final OrganisationRepository organisationRepository; + + public OrganisationProducerConverter(OrganisationRepository organisationRepository) { + this.organisationRepository = organisationRepository; + } + + @Override + public OrganisationProducerDTO toDto(OrganisationProducer entity) { + if (entity == null) { + return null; + } + + return OrganisationProducerDTO.builder() + .id(entity.getId()) + .name(entity.getName()) + .orgId(entity.getOrg() != null ? entity.getOrg().getId() : null) + // ... other fields + .build(); + } + + @Override + public OrganisationProducer toEntity(OrganisationProducerDTO dto) { + if (dto == null) { + return null; + } + + OrganisationProducer entity = new OrganisationProducer(); + entity.setId(dto.getId()); + entity.setName(dto.getName()); + + // Resolve relationships + if (dto.getOrgId() != null) { + Organisation organisation = organisationRepository.findById(dto.getOrgId()) + .orElse(null); + entity.setOrg(organisation); + } + + // ... other fields + + return entity; + } +} +``` + +## Usage in Services + +Service implementations use the converters for entity-DTO conversions: + +```java +@Service +public class OrganisationProducerServiceImpl implements OrganisationProducerService { + private final OrganisationProducerRepository organisationProducerRepository; + private final OrganisationProducerConverter organisationProducerConverter; + + public OrganisationProducerServiceImpl( + OrganisationProducerRepository organisationProducerRepository, + OrganisationProducerConverter organisationProducerConverter) { + this.organisationProducerRepository = organisationProducerRepository; + this.organisationProducerConverter = organisationProducerConverter; + } + + @Override + public List getProducers(List producerIds) { + List producers = organisationProducerRepository.findByIds(producerIds); + return organisationProducerConverter.toDtoList(producers); + } +} +``` + +## Best Practices + +1. **Null Safety**: Always check for null values in converter methods. +2. **Relationship Handling**: Use repository dependencies to resolve entity relationships in `toEntity` methods. +3. **Builder Pattern**: Use the builder pattern for DTO creation when available. +4. **List Conversions**: Use the default `toDtoList` and `toEntityList` methods for list conversions. +5. **Documentation**: Document any non-trivial mappings or special handling in the converter methods. +6. **Testing**: Write unit tests for converters to ensure correct mapping behavior. + +## ModelMapper + +ModelMapper is still available in the application for backward compatibility and other potential uses, but it's no longer used for entity-DTO conversions. The `ModelMapperConfig` class provides a basic ModelMapper bean with STRICT matching strategy. \ No newline at end of file diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..19529dd --- /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 +# +# http://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..249bdf3 --- /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 http://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..fd86a07 --- /dev/null +++ b/pom.xml @@ -0,0 +1,316 @@ + + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.5 + + + uk.gov.dbt.ndtp.ia.management.node + management-node + 1.0.0 + jar + management-node + Provides Management capabilities over IA Node Net + https://github.com/National-Digital-Twin/management-node + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + A business-friendly OSS license + + + + + + IANodeDevelopers + NDTP@businessandtrade.gov.uk + Department for Business and Trade + https://ndtp.co.uk + + + + scm:git:git@github.com:National-Digital-Twin/management-node.git + scm:git:git@github.com:National-Digital-Twin/management-node.git + https://github.com/National-Digital-Twin/management-node + + + 21 + UTF-8 + 2025.0.0 + 42.7.7 + 11.10.4 + 2.46.1 + 2.11.0 + 2.73.0 + 2.9.1 + 0.8.13 + 3.2.0 + 5.10.0 + 2.8.13 + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + org.modelmapper + modelmapper + ${modelmapper.version} + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc-openapi-starter-webmvc-ui.version} + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.postgresql + postgresql + ${postgresql.version} + + + org.flywaydb + flyway-core + ${flyway.version} + + + org.flywaydb + flyway-database-postgresql + ${flyway.version} + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.restdocs + spring-restdocs-mockmvc + test + + + org.springframework.security + spring-security-test + test + + + org.mockito + mockito-core + ${mockito-junit-jupiter.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito-junit-jupiter.version} + test + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + true + + + + com.diffplug.spotless + spotless-maven-plugin + ${plugin.spotless} + + + + **/*.json + + + 4 + ${plugin.spotless.gson} + + + + + ${plugin.spotless.palantir} + + + + + + 2 + true + false + recommended_2008_06 + + + + + + org.cyclonedx + cyclonedx-maven-plugin + ${plugin.cyclonedx} + + ${project.artifactId}-${project.version}-bom + + + + build-sbom-cyclonedx + + makeAggregateBom + + package + + + + + org.jacoco + jacoco-maven-plugin + ${jacoco-maven-plugin.version} + + + **/dto/** + **/entity/** + **/config/** + **/exception/** + **/ManagementNodeApplication.java + + + + + prepare-agent + + prepare-agent + + + + report + + report + + test + + + check + + check + + verify + + false + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.80 + + + BRANCH + COVEREDRATIO + 0.80 + + + LINE + COVEREDRATIO + 0.80 + + + METHOD + COVEREDRATIO + 0.80 + + + CLASS + COVEREDRATIO + 0.50 + + + + + + + + + + + + diff --git a/repository-configuration/.gitignore b/repository-configuration/.gitignore new file mode 100644 index 0000000..6ff38f4 --- /dev/null +++ b/repository-configuration/.gitignore @@ -0,0 +1,3 @@ +.terraform +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file diff --git a/repository-configuration/README.md b/repository-configuration/README.md new file mode 100644 index 0000000..4ce83af --- /dev/null +++ b/repository-configuration/README.md @@ -0,0 +1,32 @@ +# GitHub Repository Configuration + +This directory holds [OpenTofu](https://opentofu.org/) resources for managing this repository's branch protection settings. To authenticate the GitHub provider, the following environment variable must be set to a valid access token: + +`export TF_VAR_token=<>` + +These resources apply branch protection policies consistent with the use of a [GitFlow](https://nvie.com/posts/a-successful-git-branching-model/) branching strategy. Given a repository may be in varying states of maturity, branches are not in themselves created programmatically. It is assumed develop, release/* and main branches already exist. If they do not, you can still apply these resources and later can create the target branches manually in your repository. + +A second variable that must be supplied is that of your requirement tracking system. As an example, if the link to see the original issue or requirement was to be linked to https://example.com/requirement-system/DPAV-142, you would set this variable to "https://example.com/requirement-system" using the below command: + +`export TF_VAR_requirement_tracking_url_base=https://example.com/requirement-system` + +Once environment variables have been been set, you can initialise OpenTofu by running `tofu init`, followed by `tofu apply` to apply the configuration. If you are working on an existing repository, you must import the repository and any existing protection rules using the commands in the Importing existing resources section. + +## Importing existing resources + +If you are retrospectively applying these resources to manage an existing repository, the below import commands can be used. For information about the import commands themselves please see: + +* https://registry.terraform.io/providers/integrations/github/latest/docs/resources/repository +* https://registry.terraform.io/providers/integrations/github/latest/docs/resources/branch_protection. + +``` +export REPOSITORY_NAME=<> +tofu import github_repository.repository $REPOSITORY_NAME +tofu import github_branch_protection.develop_branch_protection $REPOSITORY_NAME:develop +tofu import github_branch_protection.release_branch_protection $REPOSITORY_NAME:release/* +tofu import github_branch_protection.main_branch_protection $REPOSITORY_NAME:main +``` + + +© Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally attributed to the Department for Business and Trade (UK) as the governing entity. +Licensed under the Open Government Licence v3.0. diff --git a/repository-configuration/provider.tf b/repository-configuration/provider.tf new file mode 100644 index 0000000..4a0d928 --- /dev/null +++ b/repository-configuration/provider.tf @@ -0,0 +1,4 @@ +provider "github" { + token = var.token + owner = var.organisation +} \ No newline at end of file diff --git a/repository-configuration/repository.tf b/repository-configuration/repository.tf new file mode 100644 index 0000000..7d6e41c --- /dev/null +++ b/repository-configuration/repository.tf @@ -0,0 +1,50 @@ +resource "github_repository" "repository" { + name = basename(dirname(path.cwd)) + description = var.repository_description + visibility = "public" + delete_branch_on_merge = false + has_downloads = true + has_issues = true + has_projects = true +} + +resource "github_branch_protection" "develop_branch_protection" { + repository_id = github_repository.repository.node_id + pattern = "develop" + + required_pull_request_reviews { + required_approving_review_count = 1 + } + + required_status_checks { + contexts = [] + strict = true + } +} + +resource "github_branch_protection" "release_branch_protection" { + repository_id = github_repository.repository.node_id + pattern = "release/*" +} + +resource "github_branch_protection" "main_branch_protection" { + repository_id = github_repository.repository.node_id + pattern = "main" + + required_pull_request_reviews { + required_approving_review_count = 1 + } + + required_status_checks { + contexts = [] + strict = true + } +} + +# Autolink references +resource "github_repository_autolink_reference" "autolink" { + repository = github_repository.repository.name + + key_prefix = "${var.requirement_tracking_id}-" + target_url_template = "${var.requirement_tracking_url_base}/${var.requirement_tracking_id}-" +} diff --git a/repository-configuration/terraform.tf b/repository-configuration/terraform.tf new file mode 100644 index 0000000..6c45f23 --- /dev/null +++ b/repository-configuration/terraform.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + github = { + source = "integrations/github" + version = "~> 6.0" + } + } +} \ No newline at end of file diff --git a/repository-configuration/variables.tf b/repository-configuration/variables.tf new file mode 100644 index 0000000..50fcc03 --- /dev/null +++ b/repository-configuration/variables.tf @@ -0,0 +1,28 @@ +variable "token" { + description = "GitHub personal access token." + type = string + sensitive = true +} + +variable "organisation" { + description = "The GitHub organisation name." + type = string + default = "National-Digital-Twin" +} + +variable "repository_description" { + description = "GitHub repository description." + type = string + default = "Provides Management capabilities over IA Node Net" +} + +variable "requirement_tracking_url_base" { + description = "Requirement tracking system URL base to be used for autolinking commit messages." + type = string +} + +variable "requirement_tracking_id" { + description = "Requirement identifier to be used for autolinking commit messages. This ID should match those which prefix issue identifiers, for example DPAV." + type = string + default = "DPAV" +} \ No newline at end of file diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplication.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplication.java new file mode 100644 index 0000000..8581a19 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplication.java @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ManagementNodeApplication { + + public static void main(String[] args) { + SpringApplication.run(ManagementNodeApplication.class, args); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/ClientIdMdcFilter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/ClientIdMdcFilter.java new file mode 100644 index 0000000..dab725e --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/ClientIdMdcFilter.java @@ -0,0 +1,111 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; + +/** + * Filter that adds the clientId from the Authentication object to the MDC context. + * This allows the clientId to be included in all log messages. + * This filter should be registered to run after the BearerTokenAuthenticationFilter + * to ensure that the Authentication object is already set in the SecurityContext. + * If the Authentication object is null or does not contain an EnhancedPrincipal, + * the filter will use "unknown" as the clientId. + */ +@Component +@Slf4j +public class ClientIdMdcFilter extends OncePerRequestFilter { + + public static final String CLIENT_ID_MDC_KEY = "clientId"; + private static final String UNKNOWN_CLIENT = ""; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + try { + log.trace("ClientIdMdcFilter processing request: {}", request.getRequestURI()); + + // Get the Authentication object from the SecurityContext + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null) { + log.trace("Authentication is null in SecurityContextHolder for request: {}", request.getRequestURI()); + } else { + log.trace( + "Authentication found in SecurityContextHolder: {}, Principal type: {}", + authentication.getName(), + authentication.getPrincipal() != null + ? authentication.getPrincipal().getClass().getName() + : "null"); + } + + // Extract clientId from the Authentication object if possible + String clientId = extractClientId(authentication); + + // Put the clientId in the MDC context + MDC.put(CLIENT_ID_MDC_KEY, clientId); + + log.trace("Added clientId to MDC: {}", clientId); + + // Continue with the filter chain + filterChain.doFilter(request, response); + } finally { + // Always clear the MDC context to prevent memory leaks + MDC.remove(CLIENT_ID_MDC_KEY); + log.trace("Removed clientId from MDC"); + } + } + + /** + * Extracts the clientId from the Authentication object. + *

+ * This method checks if the Authentication object is not null and if its principal + * is an instance of EnhancedPrincipal. If so, it extracts the clientId from the + * EnhancedPrincipal. Otherwise, it returns "unknown". + *

+ * Debug logging is included to help diagnose issues with the Authentication object + * and its principal. + * + * @param authentication the Authentication object + * @return the clientId, or "unknown" if it cannot be determined + */ + private String extractClientId(Authentication authentication) { + if (authentication != null) { + log.trace( + "Authentication found: {}, Principal type: {}", + authentication.getName(), + authentication.getPrincipal() != null + ? authentication.getPrincipal().getClass().getName() + : "null"); + + Object principal = authentication.getPrincipal(); + + if (principal instanceof EnhancedPrincipal enhancedPrincipal) { + String clientId = enhancedPrincipal.clientId(); + return clientId != null && !clientId.isEmpty() ? clientId : UNKNOWN_CLIENT; + } else { + log.warn( + "Principal is not an instance of EnhancedPrincipal: {}", + principal != null ? principal.getClass().getName() : "null"); + } + } else { + log.trace("Authentication is null in SecurityContextHolder"); + } + return UNKNOWN_CLIENT; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java new file mode 100644 index 0000000..ee1501c --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/CustomJwtAuthenticationToken.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import java.util.Collection; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; + +/** + * Custom JWT Authentication Token that uses CustomPrincipal as the principal object. + * This allows access to the clientId in addition to the standard JWT information. + */ +public class CustomJwtAuthenticationToken extends JwtAuthenticationToken { + + private final EnhancedPrincipal principal; + + /** + * Constructs a CustomJwtAuthenticationToken with the provided JWT, authorities, and CustomPrincipal. + * + * @param jwt the JWT + * @param authorities the collection of granted authorities + * @param principal the custom principal containing subject and clientId + */ + public CustomJwtAuthenticationToken( + Jwt jwt, Collection authorities, EnhancedPrincipal principal) { + super(jwt, authorities, principal.subject()); + this.principal = principal; + } + + @Override + public EnhancedPrincipal getPrincipal() { + return this.principal; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java new file mode 100644 index 0000000..a103a47 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverter.java @@ -0,0 +1,361 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import java.util.*; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.convert.converter.Converter; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import uk.gov.dbt.ndtp.ia.node.management.exception.ResourceAccessParsingException; +import uk.gov.dbt.ndtp.ia.node.management.exception.TokenIntrospectionException; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.JwtToken; + +/** + * The KeycloakJwtAuthenticationConverter class is responsible for converting a JWT token + * into an authentication token by integrating with Keycloak-specific token introspection + * and resource access information. This class ensures that roles, client ID, and other + * relevant token claims are parsed accurately to produce an appropriate authentication + * object. + *

+ * This class implements the {@link Converter} interface to provide conversion functionality + * between a {@link Jwt} and an instance of {@link AbstractAuthenticationToken}. + *

+ * Key responsibilities include: + * - Performing token introspection via the configured Keycloak introspection endpoint. + * - Extracting authorities, roles, and resource access data from the token. + * - Handling potential fallback scenarios when introspection fails. + * - Processing key JWT claims such as "azp", "client_id", "sub", and resource-access roles. + *

+ * Introspection is handled through HTTP calls using {@link RestTemplate} to ensure that + * the token's validity and associated claims are verified against the configured Keycloak + * server. + *

+ * Thread Safety: + * This class is designed to be thread-safe as it does not maintain mutable state at + * the instance level. Configuration properties are injected as immutable fields and are + * not modified during token conversion operations. + */ +@Component +@Slf4j +public class KeycloakJwtAuthenticationConverter implements Converter { + // Constants for claim names + private static final String CLAIM_AZP = "azp"; + private static final String CLAIM_CLIENT_ID = "client_id"; + private static final String CLAIM_RESOURCE_ACCESS = "resource_access"; + private static final String CLAIM_REALM_ACCESS = "realm_access"; + private static final String CLAIM_ROLES = "roles"; + private static final String CLAIM_SUB = "sub"; + private static final String CLAIM_ACTIVE = "active"; + + // Constants for role prefixes and default values + private static final String ROLE_PREFIX = "ROLE_"; + private static final String UNKNOWN_CLIENT = "unknown"; + private static final String RESOURCE_ROLE_SEPARATOR = ":"; + + // Form data keys + private static final String FORM_CLIENT_ID = "client_id"; + private static final String FORM_CLIENT_SECRET = "client_secret"; + private static final String FORM_TOKEN = "token"; + + private final JwtGrantedAuthoritiesConverter defaultGrantedAuthoritiesConverter = + new JwtGrantedAuthoritiesConverter(); + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${spring.security.oauth2.resourceserver.opaquetoken.introspection-uri}") + private String introspectionUri; + + @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.resourceserver.opaquetoken.client-secret}") + private String clientSecret; + + /** + * Performs token introspection by making an HTTP call to the introspection endpoint. + * + * @param tokenValue The JWT token value to introspect + * @return JwtToken containing the introspection data + * @throws TokenIntrospectionException If the introspection request fails or returns invalid data + */ + private JwtToken performTokenIntrospection(String tokenValue) throws TokenIntrospectionException { + try { + // Prepare headers for the introspection request + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + // Prepare form data for the introspection request + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add(FORM_CLIENT_ID, clientId); + formData.add(FORM_CLIENT_SECRET, clientSecret); + formData.add(FORM_TOKEN, tokenValue); + + // Create the request entity + HttpEntity> requestEntity = new HttpEntity<>(formData, headers); + + log.debug("Performing token introspection for client ID: {}", clientId); + + // Make the introspection request + ResponseEntity response = + restTemplate.postForEntity(introspectionUri, requestEntity, JwtToken.class); + + // Parse the response + JwtToken introspectionData = response.getBody(); + + // Check if token is active + if (introspectionData == null || !Boolean.TRUE.equals(introspectionData.getActive())) { + log.error("Token introspection failed: Token is not active for client ID: {}", clientId); + throw new TokenIntrospectionException("Token is not active", clientId); + } + + return introspectionData; + } catch (TokenIntrospectionException e) { + // Re-throw TokenIntrospectionException + throw e; + } catch (Exception e) { + log.error("Token introspection failed for client ID: {}", clientId, e); + throw new TokenIntrospectionException("Failed to introspect token", e, clientId); + } + } + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + try { + log.debug("Converting JWT to authentication token"); + + // Perform token introspection + JwtToken introspectionData = performTokenIntrospection(jwt.getTokenValue()); + + // Extract authorities from the introspection data + Collection authorities = extractAuthoritiesFromIntrospection(introspectionData); + + // Extract client_id from introspection data + String tokenClientId = extractClientIdFromIntrospection(introspectionData); + + // Extract subject from introspection data + String subject = introspectionData.getSub(); + if (subject == null || subject.isEmpty()) { + subject = jwt.getSubject(); // Fallback to JWT subject if not in introspection data + } + + // Create custom principal with subject and clientId + EnhancedPrincipal principal = new EnhancedPrincipal(subject, tokenClientId); + + log.debug("Successfully created authentication token for client ID: {}", tokenClientId); + + // Return custom authentication token + return new CustomJwtAuthenticationToken(jwt, authorities, principal); + } catch (TokenIntrospectionException e) { + // If introspection fails, log the error and fall back to JWT parsing + String tokenClientId = extractClientId(jwt); + log.warn( + "Token introspection failed for client ID: {}. Falling back to JWT parsing. Error: {}", + tokenClientId, + e.getMessage()); + + Collection authorities = extractAuthorities(jwt); + EnhancedPrincipal principal = new EnhancedPrincipal(jwt.getSubject(), tokenClientId); + return new CustomJwtAuthenticationToken(jwt, authorities, principal); + } catch (ResourceAccessParsingException e) { + // If resource access parsing fails, log the error and fall back to JWT parsing + String tokenClientId = extractClientId(jwt); + log.warn( + "Resource access parsing failed for client ID: {}. Falling back to JWT parsing. Error: {}", + tokenClientId, + e.getMessage()); + + Collection authorities = extractAuthorities(jwt); + EnhancedPrincipal principal = new EnhancedPrincipal(jwt.getSubject(), tokenClientId); + return new CustomJwtAuthenticationToken(jwt, authorities, principal); + } catch (Exception e) { + // For any other unexpected exceptions + String tokenClientId = extractClientId(jwt); + log.error("Unexpected error during JWT conversion for client ID: {}", tokenClientId, e); + + Collection authorities = extractAuthorities(jwt); + EnhancedPrincipal principal = new EnhancedPrincipal(jwt.getSubject(), tokenClientId); + return new CustomJwtAuthenticationToken(jwt, authorities, principal); + } + } + + /** + * Helper method to get a non-null, non-empty client ID from primary and fallback sources. + * + * @param primaryId The primary client ID to check + * @param fallbackId The fallback client ID to use if primary is null or empty + * @return A non-null client ID (either primary, fallback, or "unknown") + */ + private String getEffectiveClientId(String primaryId, String fallbackId) { + if (primaryId != null && !primaryId.isEmpty()) { + return primaryId; + } + + if (fallbackId != null && !fallbackId.isEmpty()) { + return fallbackId; + } + + return UNKNOWN_CLIENT; + } + + /** + * Extract client_id from JWT token. + * Tries to get it from "azp" claim first, then from "client_id" claim. + * If neither is present, returns "unknown". + */ + private String extractClientId(Jwt jwt) { + String azpClientId = jwt.getClaimAsString(CLAIM_AZP); + String directClientId = jwt.getClaimAsString(CLAIM_CLIENT_ID); + + return getEffectiveClientId(azpClientId, directClientId); + } + + /** + * Extract authorities from introspection data. + * + * @param jwtToken The data from the introspection endpoint + * @return Collection of GrantedAuthority objects + */ + private Collection extractAuthoritiesFromIntrospection(JwtToken jwtToken) { + Collection authorities = new ArrayList<>(); + String clientId = extractClientIdFromIntrospection(jwtToken); + + try { + log.debug("Extracting authorities from introspection data for client ID: {}", clientId); + + // Process resource_access + if (jwtToken.getResourceAccess() != null) { + jwtToken.getResourceAccess().forEach((resource, resourceAccess) -> { + if (resourceAccess != null && resourceAccess.getRoles() != null) { + resourceAccess.getRoles().forEach(role -> { + authorities.add(new SimpleGrantedAuthority( + ROLE_PREFIX + resource + RESOURCE_ROLE_SEPARATOR + role)); + }); + } + }); + } + + log.trace("Successfully extracted {} authorities for client ID: {}", authorities.size(), clientId); + } catch (Exception e) { + log.error("Error extracting authorities from introspection data for client ID: {}", clientId, e); + throw new ResourceAccessParsingException( + "Failed to parse resource access from introspection data", e, clientId); + } + + return authorities; + } + + /** + * Extract client_id from introspection data. + * Tries to get it from "azp" claim first, then from "client_id" claim. + * If neither is present, returns "unknown". + * + * @param jwtToken The data from the introspection endpoint + * @return The client ID + */ + private String extractClientIdFromIntrospection(JwtToken jwtToken) { + String azpClientId = jwtToken.getAzp(); + String directClientId = jwtToken.getClientId(); + + return getEffectiveClientId(azpClientId, directClientId); + } + + /** + * Safely extracts a Map from an Object, if the object is a Map. + * + * @param obj The object to extract a Map from + * @return An Optional containing the Map if extraction was successful, or empty Optional otherwise + */ + @SuppressWarnings("unchecked") // Safe cast with instanceof check + private Optional> extractMap(Object obj) { + if (obj instanceof Map) { + return Optional.of((Map) obj); + } + return Optional.empty(); + } + + /** + * Safely extracts a Collection of Strings from an Object, if the object is a Collection. + * + * @param obj The object to extract a Collection from + * @return An Optional containing the Collection if extraction was successful, or empty Optional otherwise + */ + @SuppressWarnings("unchecked") // Safe cast with instanceof check + private Optional> extractStringCollection(Object obj) { + if (obj instanceof Collection) { + return Optional.of((Collection) obj); + } + return Optional.empty(); + } + + /** + * Processes resource roles and creates authorities. + * + * @param resourceName The name of the resource + * @param resourceData The resource data containing roles + * @return A collection of GrantedAuthority objects + */ + private Collection processResourceRoles(String resourceName, Map resourceData) { + if (!resourceData.containsKey(CLAIM_ROLES)) { + return Collections.emptyList(); + } + + return extractStringCollection(resourceData.get(CLAIM_ROLES)) + .map(roles -> roles.stream() + .map(role -> { + if (resourceName != null) { + // Resource-specific role (format: ROLE_:) + return new SimpleGrantedAuthority( + ROLE_PREFIX + resourceName + RESOURCE_ROLE_SEPARATOR + role); + } else { + // Realm role (format: ROLE_) + return new SimpleGrantedAuthority(ROLE_PREFIX + role); + } + }) + .map(authority -> authority) + .collect(Collectors.toList())) + .orElse(Collections.emptyList()); + } + + private Collection extractAuthorities(Jwt jwt) { + // Add default authorities if any + Collection authorities = new ArrayList<>(defaultGrantedAuthoritiesConverter.convert(jwt)); + String clientId = extractClientId(jwt); + + try { + log.trace("Extracting authorities from JWT for client ID: {}", clientId); + + // Extract and process resource_access claim + extractMap(jwt.getClaim(CLAIM_RESOURCE_ACCESS)) + .ifPresent(resourceAccess -> + resourceAccess.forEach((resource, resourceDataObj) -> extractMap(resourceDataObj) + .ifPresent(resourceData -> + authorities.addAll(processResourceRoles(resource, resourceData))))); + + log.trace("Successfully extracted {} authorities from JWT for client ID: {}", authorities.size(), clientId); + } catch (Exception e) { + log.error("Error extracting authorities from JWT for client ID: {}", clientId, e); + // We're not throwing the exception here because we want to continue with default authorities + // This is a fallback method, so we want to be more lenient + } + + return authorities; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/ModelMapperConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/ModelMapperConfig.java new file mode 100644 index 0000000..136b82d --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/ModelMapperConfig.java @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import org.modelmapper.ModelMapper; +import org.modelmapper.convention.MatchingStrategies; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for ModelMapper. + * Note: Entity-DTO conversions are now handled by dedicated converter classes in the converter package. + * ModelMapper is kept for backward compatibility and other potential uses. + */ +@Configuration +public class ModelMapperConfig { + + /** + * Creates a ModelMapper bean with basic configuration. + * + * @return the configured ModelMapper + */ + @Bean + public ModelMapper modelMapper() { + ModelMapper modelMapper = new ModelMapper(); + modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT); + return modelMapper; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/OpenApiConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/OpenApiConfig.java new file mode 100644 index 0000000..f6d1d55 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/OpenApiConfig.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition( + info = + @Info( + title = "Management Node API", + version = "0.90.0", + description = + "APIs consumed by Consumer and Producer Federators to retrieve runtime configuration.", + contact = @Contact(name = "NDTP", email = "NDTP@businessandtrade.gov.uk"))) +@SecurityScheme(name = "bearerAuth", type = SecuritySchemeType.HTTP, scheme = "bearer", bearerFormat = "JWT") +public class OpenApiConfig { + // Configuration class to host OpenAPI metadata and security scheme +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/SecurityConfig.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/SecurityConfig.java new file mode 100644 index 0000000..b1e9699 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/SecurityConfig.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final KeycloakJwtAuthenticationConverter keycloakJwtAuthenticationConverter; + private final ClientIdMdcFilter clientIdMdcFilter; + + public SecurityConfig( + KeycloakJwtAuthenticationConverter keycloakJwtAuthenticationConverter, + ClientIdMdcFilter clientIdMdcFilter) { + this.keycloakJwtAuthenticationConverter = keycloakJwtAuthenticationConverter; + this.clientIdMdcFilter = clientIdMdcFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") + .permitAll() + .anyRequest() + .authenticated()) + .oauth2ResourceServer( + oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtAuthenticationConverter))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + // Add ClientIdMdcFilter after the BearerTokenAuthenticationFilter + // This ensures the Authentication object is already set in the SecurityContext + .addFilterAfter(clientIdMdcFilter, BearerTokenAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/SslPropertyInitializer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/SslPropertyInitializer.java new file mode 100644 index 0000000..6fc3147 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/config/SslPropertyInitializer.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SslPropertyInitializer { + + @Value("${application.client.key-store}") + private String keyStore; + + @Value("${application.client.key-store-password}") + private String keyStorePassword; + + @Value("${application.client.keyStoreType:JKS}") + private String keyStoreType; + + @Value("${server.ssl.trust-store}") + private String trustStore; + + @Value("${server.ssl.trust-store-password}") + private String trustStorePassword; + + @Value("${server.ssl.trust-store-type:JKS}") + private String trustStoreType; + + @PostConstruct + public void init() { + System.setProperty("javax.net.ssl.keyStore", keyStore); + System.setProperty("javax.net.ssl.keyStorePassword", keyStorePassword); + System.setProperty("javax.net.ssl.keyStoreType", keyStoreType); + + System.setProperty("javax.net.ssl.trustStore", trustStore); + System.setProperty("javax.net.ssl.trustStorePassword", trustStorePassword); + System.setProperty("javax.net.ssl.trustStoreType", trustStoreType); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java new file mode 100644 index 0000000..323fbde --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationController.java @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.controller.v1; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerConfigDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration.ConfigurationProvider; + +@RestController +@RequestMapping("/api/v1/configuration") +@Slf4j +@Tag(name = "Configuration", description = "Endpoints for retrieving Federators Producer and Consumer configuration.") +public class ConfigurationController { + + private final ConfigurationProvider configurationProvider; + + public ConfigurationController(ConfigurationProvider configurationProvider) { + this.configurationProvider = configurationProvider; + } + + @GetMapping("/producer") + @PreAuthorize("hasAuthority('ROLE_management-node:access_producer_configurations')") + @Operation( + summary = "Get Federator Producer configuration", + description = + "Returns configuration for the authenticated client, optionally scoped to a specific producer.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Federator Producer configuration returned", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ProducerConfigDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid request parameters"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public ProducerConfigDTO getProducerConfigurations( + @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, + @Parameter(name = "producer_id", description = "Optional Producer identifier to filter configuration") + @RequestParam(value = "producer_id", required = false) + Long producer_id) { + log.info("Preparing Federator Producer Config for producer {}", producer_id); + return configurationProvider.getProducerConfigByClientId( + principal.clientId(), producer_id != null ? Optional.of(producer_id) : Optional.empty()); + } + + @GetMapping("/consumer") + @PreAuthorize("hasAuthority('ROLE_management-node:access_consumer_configurations')") + @Operation( + summary = "Get Federator Consumer configuration", + description = + "Returns configuration for the authenticated client, optionally scoped to a specific consumer.", + security = {@SecurityRequirement(name = "bearerAuth")}) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "Consumer configuration returned", + content = + @Content( + mediaType = "application/json", + schema = @Schema(implementation = ConsumerConfigDTO.class))), + @ApiResponse(responseCode = "400", description = "Invalid request parameters"), + @ApiResponse(responseCode = "401", description = "Unauthorized"), + @ApiResponse(responseCode = "403", description = "Forbidden"), + @ApiResponse(responseCode = "404", description = "Not found"), + @ApiResponse(responseCode = "500", description = "Internal server error") + }) + public ConsumerConfigDTO getConsumerConfigurations( + @Parameter(hidden = true) @AuthenticationPrincipal EnhancedPrincipal principal, + @Parameter(name = "consumer_id", description = "Optional Consumer identifier to filter configuration") + @RequestParam(value = "consumer_id", required = false) + Long consumerId) { + log.info("Preparing Consumer Config for client Id {} and Consumer {}", principal.clientId(), consumerId); + + return configurationProvider.getConsumerConfigByClientId( + principal.clientId(), consumerId != null ? Optional.of(consumerId) : Optional.empty()); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java new file mode 100644 index 0000000..fa798c9 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/EntityDtoConverter.java @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * Generic interface for converting between entity and DTO objects. + * + * @param the entity type + * @param the DTO type + */ +public interface EntityDtoConverter { + + /** + * Converts an entity to a DTO. + * + * @param entity the entity to convert + * @return the converted DTO + */ + D toDto(E entity); + + /** + * Converts a DTO to an entity. + * + * @param dto the DTO to convert + * @return the converted entity + */ + E toEntity(D dto); + + /** + * Converts a list of entities to a list of DTOs. + * + * @param entities the entities to convert + * @return the converted DTOs + */ + default List toDtoList(List entities) { + if (entities == null) { + return List.of(); + } + return entities.stream().map(this::toDto).collect(Collectors.toList()); + } + + /** + * Converts a list of DTOs to a list of entities. + * + * @param dtos the DTOs to convert + * @return the converted entities + */ + default List toEntityList(List dtos) { + if (dtos == null) { + return List.of(); + } + return dtos.stream().map(this::toEntity).collect(Collectors.toList()); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java new file mode 100644 index 0000000..26d2f01 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverter.java @@ -0,0 +1,102 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.AttributesDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +/** + * Converter for ConsumerId entity and ConsumerIdDTO. + */ +@Component +public class ConsumerConverter implements EntityDtoConverter { + + private final OrganisationRepository organisationRepository; + + /** + * Constructor-based dependency injection. + * + * @param organisationRepository the organisation repository + */ + public ConsumerConverter(OrganisationRepository organisationRepository) { + this.organisationRepository = organisationRepository; + } + + /** + * Converts an ConsumerId entity to an ConsumerIdDTO. + * + * @param entity the entity to convert + * @return the converted DTO + */ + @Override + public ConsumerDTO toDto(Consumer entity) { + if (entity == null) { + return null; + } + + ConsumerDTO dto = ConsumerDTO.builder() + .id(entity.getId()) + .name(entity.getName()) + .orgId(entity.getOrg() != null ? entity.getOrg().getId() : null) + .idpClientId(entity.getIdpClientId()) + .build(); + + // Populate attributes from associated ProductConsumers + try { + if (entity.getId() != null) { + var productConsumers = entity.getProductConsumers(); + if (productConsumers != null) { + productConsumers.stream() + .filter(pc -> pc.getProductConsumerAttributes() != null) + .flatMap(pc -> pc.getProductConsumerAttributes().stream()) + .forEach(attr -> dto.getAttributes() + .add(AttributesDTO.builder() + .name(attr.getName()) + .type(attr.getType()) + .value(attr.getValue()) + .build())); + } + } + } catch (Exception ignored) { + // Keep mapping resilient + } + + return dto; + } + + /** + * Converts an ConsumerIdDTO to an ConsumerId entity. + * + * @param dto the DTO to convert + * @return the converted entity + */ + @Override + public Consumer toEntity(ConsumerDTO dto) { + if (dto == null) { + return null; + } + + Consumer entity = new Consumer(); + entity.setId(dto.getId()); + entity.setName(dto.getName()); + entity.setIdpClientId(dto.getIdpClientId()); + + // Set the organisation if orgId is provided + if (dto.getOrgId() != null) { + Organisation organisation = + organisationRepository.findById(dto.getOrgId()).orElse(null); + entity.setOrg(organisation); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java new file mode 100644 index 0000000..e0c7b30 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverter.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +/** + * Converter for OrganisationProducer entity and OrganisationProducerDTO. + */ +@Component +public class OrganisationProducerConverter implements EntityDtoConverter { + + private final OrganisationRepository organisationRepository; + private final ProductConverter productConverter; + + /** + * Constructor-based dependency injection. + * + * @param organisationRepository the organisation repository + * @param productConverter the data provider converter + */ + public OrganisationProducerConverter( + OrganisationRepository organisationRepository, ProductConverter productConverter) { + this.organisationRepository = organisationRepository; + this.productConverter = productConverter; + } + + /** + * Converts an OrganisationProducer entity to an OrganisationProducerDTO. + * + * @param entity the entity to convert + * @return the converted DTO + */ + @Override + public ProducerDTO toDto(Producer entity) { + if (entity == null) { + return null; + } + + ProducerDTO dto = ProducerDTO.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .orgId(entity.getOrg() != null ? entity.getOrg().getId() : null) + .active(entity.getActive()) + .host(entity.getHost()) + .port(entity.getPort()) + .tls(entity.getTls()) + .idpClientId(entity.getIdpClientId()) + .build(); + + // Map dataProviders if they exist + if (entity.getProducts() != null && !entity.getProducts().isEmpty()) { + entity.getProducts().forEach(dataProvider -> dto.getProducts().add(productConverter.toDto(dataProvider))); + } + + return dto; + } + + /** + * Converts an OrganisationProducerDTO to an OrganisationProducer entity. + * + * @param dto the DTO to convert + * @return the converted entity + */ + @Override + public Producer toEntity(ProducerDTO dto) { + if (dto == null) { + return null; + } + + Producer entity = new Producer(); + entity.setId(dto.getId()); + entity.setName(dto.getName()); + entity.setDescription(dto.getDescription()); + entity.setActive(dto.getActive()); + entity.setHost(dto.getHost()); + entity.setPort(dto.getPort()); + entity.setTls(dto.getTls()); + entity.setIdpClientId(dto.getIdpClientId()); + + // Set the organisation if orgId is provided + if (dto.getOrgId() != null) { + Organisation organisation = + organisationRepository.findById(dto.getOrgId()).orElse(null); + entity.setOrg(organisation); + } + + // Map dataProviders if they exist + if (dto.getProducts() != null && !dto.getProducts().isEmpty()) { + List dataProviders = new ArrayList<>(); + dto.getProducts().forEach(dataProviderDTO -> { + // Set the producerId to ensure proper mapping + if (dataProviderDTO.getProducerId() == null && dto.getId() != null) { + dataProviderDTO.setProducerId(dto.getId()); + } + Product dataProvider = productConverter.toEntity(dataProviderDTO); + if (dataProvider != null) { + dataProvider.setProducer(entity); + dataProviders.add(dataProvider); + } + }); + entity.setProducts(dataProviders); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverter.java new file mode 100644 index 0000000..ddff458 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverter.java @@ -0,0 +1,119 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import java.util.ArrayList; +import java.util.List; +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +/** + * Converter for Producer entity and ProducerDTO. + */ +@Component +public class ProducerConverter implements EntityDtoConverter { + + private final OrganisationRepository organisationRepository; + private final ProductConverter productConverter; + + /** + * Constructor-based dependency injection. + * + * @param organisationRepository the organisation repository + * @param productConverter the data provider converter + */ + public ProducerConverter(OrganisationRepository organisationRepository, ProductConverter productConverter) { + this.organisationRepository = organisationRepository; + this.productConverter = productConverter; + } + + /** + * Converts a Producer entity to a ProducerDTO. + * + * @param entity the entity to convert + * @return the converted DTO + */ + @Override + public ProducerDTO toDto(Producer entity) { + if (entity == null) { + return null; + } + + ProducerDTO dto = ProducerDTO.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .orgId(entity.getOrg() != null ? entity.getOrg().getId() : null) + .active(entity.getActive()) + .host(entity.getHost()) + .port(entity.getPort()) + .tls(entity.getTls()) + .idpClientId(entity.getIdpClientId()) + .build(); + + // Map dataProviders if they exist + if (entity.getProducts() != null && !entity.getProducts().isEmpty()) { + entity.getProducts().forEach(dataProvider -> dto.getProducts().add(productConverter.toDto(dataProvider))); + } + + return dto; + } + + /** + * Converts a ProducerDTO to a Producer entity. + * + * @param dto the DTO to convert + * @return the converted entity + */ + @Override + public Producer toEntity(ProducerDTO dto) { + if (dto == null) { + return null; + } + + Producer entity = new Producer(); + entity.setId(dto.getId()); + entity.setName(dto.getName()); + entity.setDescription(dto.getDescription()); + entity.setActive(dto.getActive()); + entity.setHost(dto.getHost()); + entity.setPort(dto.getPort()); + entity.setTls(dto.getTls()); + entity.setIdpClientId(dto.getIdpClientId()); + + // Set the organisation if orgId is provided + if (dto.getOrgId() != null) { + Organisation organisation = + organisationRepository.findById(dto.getOrgId()).orElse(null); + entity.setOrg(organisation); + } + + // Map dataProviders if they exist + if (dto.getProducts() != null && !dto.getProducts().isEmpty()) { + List dataProviders = new ArrayList<>(); + dto.getProducts().forEach(product -> { + // Set the producerId to ensure proper mapping + if (product.getProducerId() == null && dto.getId() != null) { + product.setProducerId(dto.getId()); + } + Product dataProvider = productConverter.toEntity(product); + if (dataProvider != null) { + dataProvider.setProducer(entity); + dataProviders.add(dataProvider); + } + }); + entity.setProducts(dataProviders); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java new file mode 100644 index 0000000..e59d656 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverter.java @@ -0,0 +1,90 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import java.util.List; +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.AttributesDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute; + +/** + * Converter for ConsumerAllowedDataProvider entity and ConsumerAllowedDataProviderDTO. + */ +@Component +public class ProductConsumerConverter implements EntityDtoConverter { + + /** + * Converts a ConsumerAllowedDataProvider entity to a ConsumerAllowedDataProviderDTO. + * + * @param entity the entity to convert + * @return the converted DTO + */ + @Override + public ProductConsumerDTO toDto(ProductConsumer entity) { + if (entity == null) { + return null; + } + + ProductConsumerDTO dto = ProductConsumerDTO.builder() + .productId(entity.getProduct() != null ? entity.getProduct().getId() : null) + .consumerId(entity.getConsumer() != null ? entity.getConsumer().getId() : null) + .grantedTs(entity.getGrantedTs()) + .validity(entity.getValidity()) + .build(); + + // Map attributes if available + List attrs = entity.getProductConsumerAttributes(); + if (attrs != null && !attrs.isEmpty()) { + List attributes = attrs.stream() + .map(a -> AttributesDTO.builder() + .name(a.getName()) + .type(a.getType()) + .value(a.getValue()) + .build()) + .toList(); + dto.getAttributes().addAll(attributes); + } + return dto; + } + + /** + * Converts a ConsumerAllowedDataProviderDTO to a ConsumerAllowedDataProvider entity. + * + * @param dto the DTO to convert + * @return the converted entity + */ + @Override + public ProductConsumer toEntity(ProductConsumerDTO dto) { + if (dto == null) { + return null; + } + + ProductConsumer entity = new ProductConsumer(); + + entity.setGrantedTs(dto.getGrantedTs()); + entity.setValidity(dto.getValidity()); + + if (dto.getProductId() != null) { + Product product = new Product(); + product.setId(dto.getProductId()); + entity.setProduct(product); + } + + if (dto.getConsumerId() != null) { + Consumer consumer = new Consumer(); + consumer.setId(dto.getConsumerId()); + entity.setConsumer(consumer); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java new file mode 100644 index 0000000..bc10ad4 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverter.java @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import org.springframework.stereotype.Component; +import uk.gov.dbt.ndtp.ia.node.management.converter.EntityDtoConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProducerRepository; + +/** + * Converter for OrganisationDataProvider entity and OrganisationDataProviderDTO. + */ +@Component +public class ProductConverter implements EntityDtoConverter { + + private final ProducerRepository producerRepository; + + /** + * Constructor-based dependency injection. + * + * @param producerRepository the organisation producer repository + */ + public ProductConverter(ProducerRepository producerRepository) { + this.producerRepository = producerRepository; + } + + /** + * Converts an OrganisationDataProvider entity to an OrganisationDataProviderDTO. + * + * @param entity the entity to convert + * @return the converted DTO + */ + @Override + public ProductDTO toDto(Product entity) { + if (entity == null) { + return null; + } + + return ProductDTO.builder() + .id(entity.getId()) + .name(entity.getName()) + .topic(entity.getTopic()) + .producerId(entity.getProducer() != null ? entity.getProducer().getId() : null) + .build(); + } + + /** + * Converts an OrganisationDataProviderDTO to an OrganisationDataProvider entity. + * + * @param dto the DTO to convert + * @return the converted entity + */ + @Override + public Product toEntity(ProductDTO dto) { + if (dto == null) { + return null; + } + + Product entity = new Product(); + entity.setId(dto.getId()); + entity.setName(dto.getName()); + entity.setTopic(dto.getTopic()); + + // Set the producer if producerId is provided + if (dto.getProducerId() != null) { + Producer producer = producerRepository.findById(dto.getProducerId()).orElse(null); + entity.setProducer(producer); + } + + return entity; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/AuthenticationProcessingException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/AuthenticationProcessingException.java new file mode 100644 index 0000000..cc707a2 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/AuthenticationProcessingException.java @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +import lombok.Getter; + +/** + * Base exception class for authentication processing errors. + * This exception is thrown when there's an error during the authentication process. + */ +@Getter +public class AuthenticationProcessingException extends RuntimeException { + + /** + * -- GETTER -- + * Gets the client ID associated with this exception. + * + * @return the client ID + */ + private final String clientId; + + /** + * Constructs a new AuthenticationProcessingException with the specified detail message. + * + * @param message the detail message + * @param clientId the client ID associated with the authentication + */ + public AuthenticationProcessingException(String message, String clientId) { + super(message + " [Client ID: " + clientId + "]"); + this.clientId = clientId; + } + + /** + * Constructs a new AuthenticationProcessingException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + * @param clientId the client ID associated with the authentication + */ + public AuthenticationProcessingException(String message, Throwable cause, String clientId) { + super(message + " [Client ID: " + clientId + "]", cause); + this.clientId = clientId; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/ErrorResponse.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/ErrorResponse.java new file mode 100644 index 0000000..7441884 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/ErrorResponse.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Represents an error response to be sent to clients. + * Contains a status code, an error message, and a unique error ID without exposing stack traces. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ErrorResponse { + + /** + * The HTTP status code + */ + private int status; + + /** + * A user-friendly error message + */ + private String message; + + /** + * A unique identifier for the error + */ + private String errorId; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/JwtClaimParsingException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/JwtClaimParsingException.java new file mode 100644 index 0000000..d53c1f8 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/JwtClaimParsingException.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +/** + * Exception thrown when there's an error parsing JWT claims. + * This can happen when the JWT structure is unexpected or when claims + * don't contain the expected data. + */ +public class JwtClaimParsingException extends AuthenticationProcessingException { + + /** + * Constructs a new JwtClaimParsingException with the specified detail message. + * + * @param message the detail message + * @param clientId the client ID associated with the authentication + */ + public JwtClaimParsingException(String message, String clientId) { + super(message, clientId); + } + + /** + * Constructs a new JwtClaimParsingException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + * @param clientId the client ID associated with the authentication + */ + public JwtClaimParsingException(String message, Throwable cause, String clientId) { + super(message, cause, clientId); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/ResourceAccessParsingException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/ResourceAccessParsingException.java new file mode 100644 index 0000000..856d60b --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/ResourceAccessParsingException.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +/** + * Exception thrown when there's an error parsing the resource access information + * from the authentication token or introspection data. + */ +public class ResourceAccessParsingException extends AuthenticationProcessingException { + + /** + * Constructs a new ResourceAccessParsingException with the specified detail message. + * + * @param message the detail message + * @param clientId the client ID associated with the authentication + */ + public ResourceAccessParsingException(String message, String clientId) { + super(message, clientId); + } + + /** + * Constructs a new ResourceAccessParsingException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + * @param clientId the client ID associated with the authentication + */ + public ResourceAccessParsingException(String message, Throwable cause, String clientId) { + super(message, cause, clientId); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/TokenIntrospectionException.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/TokenIntrospectionException.java new file mode 100644 index 0000000..8cf314f --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/TokenIntrospectionException.java @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +/** + * Exception thrown when there's an error during token introspection. + * This can happen when the introspection endpoint is unavailable, returns an error, + * or when the introspection data is invalid. + */ +public class TokenIntrospectionException extends AuthenticationProcessingException { + + /** + * Constructs a new TokenIntrospectionException with the specified detail message. + * + * @param message the detail message + * @param clientId the client ID associated with the authentication + */ + public TokenIntrospectionException(String message, String clientId) { + super(message, clientId); + } + + /** + * Constructs a new TokenIntrospectionException with the specified detail message and cause. + * + * @param message the detail message + * @param cause the cause of the exception + * @param clientId the client ID associated with the authentication + */ + public TokenIntrospectionException(String message, Throwable cause, String clientId) { + super(message, cause, clientId); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java new file mode 100644 index 0000000..aa311c2 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandler.java @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception.handlers; + +import java.nio.file.AccessDeniedException; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; +import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; +import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; + +/** + * Global exception handler for the application. + * Handles all exceptions thrown by controllers and provides appropriate responses + * without exposing stack traces to clients. + */ +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** + * Generates a unique error ID for tracking and correlation. + * + * @return a unique UUID string + */ + private String generateErrorId() { + return UUID.randomUUID().toString(); + } + + /** + * Handles AuthenticationProcessingException and its subclasses. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with an error message + */ + @ExceptionHandler({ + AuthenticationProcessingException.class, + AccessDeniedException.class, + AuthorizationDeniedException.class + }) + public ResponseEntity handleAuthenticationProcessingException( + AuthenticationProcessingException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug( + "Authentication processing exception occurred for client {}, error_id={} , path={}: ", + ex.getClientId(), + errorId, + request.getContextPath(), + ex); + + ErrorResponse errorResponse = + new ErrorResponse(HttpStatus.UNAUTHORIZED.value(), "Authentication error: " + ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.UNAUTHORIZED); + } + + /** + * Handles RuntimeException. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with an error message + */ + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug("Runtime exception occurred, error_id={}, path={}: ", errorId, request.getContextPath(), ex); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), "An internal server error occurred", errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Handles all other exceptions. + * + * @param ex the exception + * @param request the current request + * @return a ResponseEntity with an error message + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleAllExceptions(Exception ex, WebRequest request) { + + String errorId = generateErrorId(); + log.debug("Runtime exception occurred, error_id={}, path={}: ", errorId, request.getContextPath(), ex); + + ErrorResponse errorResponse = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), "An unexpected error occurred: " + ex.getMessage(), errorId); + + return new ResponseEntity<>(errorResponse, HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java new file mode 100644 index 0000000..77f55a1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/AttributesDTO.java @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import lombok.*; + +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AttributesDTO { + + private String name; + private String value; + private String type; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java new file mode 100644 index 0000000..93c566b --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerConfigDTO.java @@ -0,0 +1,19 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ConsumerConfigDTO { + + private final String clientId; + private final List producers; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java new file mode 100644 index 0000000..89477b6 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ConsumerDTO.java @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.ArrayList; +import java.util.List; +import lombok.*; + +/** + * DTO for consumerId entity. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ConsumerDTO { + @JsonIgnore + private Long id; + + private String name; + + @JsonIgnore + private Long orgId; + + private String idpClientId; + + private final List attributes = new ArrayList<>(); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerConfigDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerConfigDTO.java new file mode 100644 index 0000000..e2a2c57 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerConfigDTO.java @@ -0,0 +1,18 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class ProducerConfigDTO { + private String clientId; + private List producers; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java new file mode 100644 index 0000000..dcbdac7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProducerDTO.java @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import lombok.*; + +/** + * DTO for OrganisationProducer entity. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProducerDTO { + private final List products = new ArrayList<>(); + + @JsonIgnore + private Long id; + + private String name; + private String description; + + @JsonIgnore + private Long orgId; + + private Boolean active; + private String host; + private BigDecimal port; + private Boolean tls; + private String idpClientId; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java new file mode 100644 index 0000000..8ce94a7 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductConsumerDTO.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.List; +import lombok.*; + +/** + * DTO for ConsumerAllowedDataProvider entity. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductConsumerDTO { + private Long productId; + private Long consumerId; + private Timestamp grantedTs; + private BigDecimal validity; + private final List attributes = new ArrayList<>(); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java new file mode 100644 index 0000000..cdddd9a --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/dto/ProductDTO.java @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import java.util.ArrayList; +import java.util.List; +import lombok.*; + +/** + * DTO for OrganisationDataProvider entity. + */ +@Builder +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ProductDTO { + + @JsonIgnore + private Long id; + + @JsonIgnore + private Long producerId; + + private String name; + + private String topic; + + private List consumers = new ArrayList<>(); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/EnhancedPrincipal.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/EnhancedPrincipal.java new file mode 100644 index 0000000..2eaf680 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/EnhancedPrincipal.java @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.jwt; + +import java.io.Serial; +import java.io.Serializable; + +/** + * Custom Principal object that includes clientId information from the JWT. + * + * @param subject -- GETTER -- + * Get the subject (user identifier) + * @param clientId -- GETTER -- + * Get the client ID + */ +public record EnhancedPrincipal(String subject, String clientId) implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + + @Override + public String toString() { + return "CustomPrincipal{" + "subject='" + subject + '\'' + ", clientId='" + clientId + '\'' + '}'; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtToken.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtToken.java new file mode 100644 index 0000000..30dcdbd --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/model/jwt/JwtToken.java @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.model.jwt; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Represents the structure of a JWT token. + * This class is used to deserialize the JSON response from the token introspection endpoint. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JwtToken { + private Long exp; + private Long iat; + private String jti; + private String iss; + + @JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY) + private List aud; + + private String sub; + private String typ; + private String azp; + private List allowedOrigins; + + @JsonProperty("resource_access") + private Map resourceAccess; + + private String scope; + private String clientId; + private String username; + private String tokenType; + private Boolean active; + + /** + * Represents the resource access structure in the JWT token. + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ResourceAccess { + private List roles; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java new file mode 100644 index 0000000..10d7e22 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Consumer.java @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "consumer") +public class Consumer { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "org_id", nullable = false) + private Organisation org; + + @Column(name = "idp_client_id", nullable = false, length = 50) + private String idpClientId; + + @OneToMany(fetch = FetchType.LAZY) + @JoinColumn(name = "consumer_id", referencedColumnName = "id", insertable = false, updatable = false) + private List productConsumers; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java new file mode 100644 index 0000000..f2ac4dd --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Organisation.java @@ -0,0 +1,25 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "organisation") +public class Organisation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false, length = 150) + private String name; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Producer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Producer.java new file mode 100644 index 0000000..14e9fa1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Producer.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "producer") +public class Producer { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "description", nullable = false, length = Integer.MAX_VALUE) + private String description; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "org_id", nullable = false) + private Organisation org; + + @Column(name = "active", nullable = false) + private Boolean active = false; + + @Column(name = "host", nullable = false, length = 500) + private String host; + + @Column(name = "port", nullable = false) + private BigDecimal port; + + @Column(name = "tls", nullable = false) + private Boolean tls = false; + + @Column(name = "idp_client_id", nullable = false, length = 50) + private String idpClientId; + + @OneToMany(mappedBy = "producer", fetch = FetchType.LAZY) + private List products; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java new file mode 100644 index 0000000..1492166 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/Product.java @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "product") +public class Product { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "topic", nullable = false, length = 150) + private String topic; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "producer_id", nullable = false) + private Producer producer; + + @OneToMany(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", referencedColumnName = "id", insertable = false, updatable = false) + private List productConsumer; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java new file mode 100644 index 0000000..c707e8b --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumer.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.util.List; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "product_consumer") +public class ProductConsumer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "granted_ts", nullable = false) + private Timestamp grantedTs; + + @Column(name = "validity", nullable = false) + private BigDecimal validity; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "consumer_id", nullable = false) + private Consumer consumer; + + @OneToMany(fetch = FetchType.LAZY) + @JoinColumn(name = "product_consumer_id", referencedColumnName = "id", insertable = false, updatable = false) + private List productConsumerAttributes; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumerAttribute.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumerAttribute.java new file mode 100644 index 0000000..75064f2 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/entity/ProductConsumerAttribute.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.entity; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Entity +@Table(name = "product_consumer_attribute") +public class ProductConsumerAttribute { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Size(max = 150) + @NotNull + @Column(name = "name", nullable = false, length = 150) + private String name; + + @Size(max = 50) + @NotNull + @Column(name = "type", nullable = false, length = 50) + private String type; + + @Size(max = 500) + @NotNull + @Column(name = "value", nullable = false, length = 500) + private String value; + + @NotNull + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "product_consumer_id", nullable = false) + private ProductConsumer productConsumer; +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java new file mode 100644 index 0000000..c3ea1ea --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ConsumerRepository.java @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; + +@Repository +public interface ConsumerRepository extends JpaRepository { + + List findByIdpClientId(String clientId); + + /** + * Retrieves a list of {@link Consumer} entities associated with the specified provider IDs. + * The method performs a query to fetch consumers linked with products that correspond to the given provider IDs. + * + * @param providers a list of IDs of the providers whose associated consumers need to be retrieved + * @return a list of {@link Consumer} entities associated with the specified provider IDs + */ + @Query("SELECT c FROM Consumer c JOIN fetch c.productConsumers cp " + "inner join fetch cp.product p " + + "inner join fetch cp.consumer consumer " + + " WHERE p.id IN :providers") + List findConsumersByProviderIds(List providers); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationRepository.java new file mode 100644 index 0000000..4439596 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/OrganisationRepository.java @@ -0,0 +1,24 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; + +/** + * Repository interface for managing {@link Organisation} entities. + * + * This interface provides CRUD operations and query methods for interacting with + * the underlying database layer as it extends the {@link JpaRepository} interface. + * It facilitates persistence and retrieval of Organisation data from the related + * database table. + * + * Primary focus is on the {@link Organisation} entity with the identifier type {@link Long}. + */ +@Repository +public interface OrganisationRepository extends JpaRepository {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java new file mode 100644 index 0000000..a929301 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProducerRepository.java @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; + +/** + * Repository interface for managing {@link Producer} entities. + * This interface provides methods for interacting with the underlying database, + * specifically for the {@link Producer} entity. It extends {@link JpaRepository} + * to provide standard CRUD operations and supports custom query methods. + * The primary focus is on enabling operations related to the {@link Producer} + * entity with the identifier type {@link Long}. + */ +@Repository +public interface ProducerRepository extends JpaRepository { + + /** + * Retrieves a list of {@link Producer} entities, including their associated {@link Product} entities + * and linked product consumers, filtered by the provided list of consumer IDs. + * + * @param consumerIds a list of consumer IDs used to filter the {@link Producer} and associated entities + * @return a list of {@link Producer} entities along with their associated {@link Product} entities and product consumers + */ + @Query( + "SELECT o FROM Producer o JOIN FETCH o.products p JOIN p.productConsumer pc WHERE pc.consumer.id IN :consumerIds") + List findByConsumerIds(List consumerIds); + + /** + * Retrieves a list of {@link Producer} entities, along with their associated {@link Product} entities, + * based on the provided Identity Provider (IdP) client identifier. + * + * @param idpClientId the Identity Provider client identifier used to retrieve corresponding {@link Producer} entities + * @return a list of {@link Producer} entities with their associated {@link Product} entities + */ + @Query("SELECT o FROM Producer o JOIN FETCH o.products WHERE o.idpClientId IN :idpClientId") + List findByIdpClientId(String idpClientId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductConsumerAttributeRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductConsumerAttributeRepository.java new file mode 100644 index 0000000..1955b1c --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductConsumerAttributeRepository.java @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute; + +/** + * Repository interface for managing {@link ProductConsumerAttribute} entities. + * Provides persistence operations and query methods for interacting with the + * underlying database. + * + * Extends {@link JpaRepository} to inherit standard CRUD operations and adds + * query methods specific to {@link ProductConsumerAttribute}. + */ +@Repository +public interface ProductConsumerAttributeRepository extends JpaRepository {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductConsumerRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductConsumerRepository.java new file mode 100644 index 0000000..41f3257 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductConsumerRepository.java @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; + +/** + * Repository interface for managing and querying {@link ProductConsumer} entities in the database. + * Extends {@link JpaRepository} for basic CRUD operations and adds custom query methods for specific use cases. + */ +@Repository +public interface ProductConsumerRepository extends JpaRepository { + + /** + * Finds and retrieves a list of {@link ProductConsumer} entities associated with the specified consumer ID. + * This method employs a query that fetches details of product-consumer relationships, including associated + * consumer, product, and any attributes linked to the product-consumer relationship. + * + * @param consumerId the ID of the consumer whose associated product-consumer entities are to be queried + * @return a list of {@link ProductConsumer} entities associated with the given consumer ID + */ + @Query("Select dp from ProductConsumer dp " + "inner join fetch dp.consumer c " + + "inner join fetch dp.product p " + + "left join fetch dp.productConsumerAttributes pca " + + "where c.id=:consumerId") + List findByConsumerId(@Param("consumerId") Long consumerId); + + /** + * Finds and retrieves a list of {@link ProductConsumer} entities associated with the specified product ID. + * This method uses a query to fetch detailed information about product-consumer relationships, including + * the associated consumer, product, and any attributes linked to the product-consumer relationship. + * + * @param productId the ID of the product whose associated product-consumer entities are to be queried + * @return a list of {@link ProductConsumer} entities associated with the given product ID + */ + @Query("Select dp from ProductConsumer dp " + + "inner join fetch dp.consumer consumer " + + "inner join fetch dp.product p " + + "left join fetch dp.productConsumerAttributes pca " + + "where p.id=:productId") + List findByProductId(Long productId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java new file mode 100644 index 0000000..ad27315 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/persistency/repository/ProductRepository.java @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.persistency.repository; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; + +/** + * Repository interface for managing {@link Product} entities. + * + * This interface extends {@link JpaRepository} to provide CRUD operations and additional + * query methods to interact with the {@link Product} database entity. It focuses on enabling + * functionality specific to retrieving products based on identifiers or associated producers. + * + * The primary focus is on the {@link Product} entity with the identifier type {@link Long}. + */ +@Repository +public interface ProductRepository extends JpaRepository { + + /** + * Retrieves a list of {@link Product} entities based on the provided list of product IDs. + * + * @param ids a list of product IDs for which the {@link Product} entities are to be retrieved + * @return a list of {@link Product} entities matching the provided IDs + */ + @Query("SELECT o FROM Product o WHERE o.id IN :ids") + List findByIds(List ids); + + /** + * Retrieves a list of {@link Product} entities associated with the specified producer IDs. + * + * @param producers a list of producer IDs whose associated {@link Product} entities need to be retrieved + * @return a list of {@link Product} entities linked to the specified producer IDs + */ + @Query("SELECT o FROM Product o WHERE o.producer.id IN :producers") + List findByProducerIds(List producers); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ConsumerService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ConsumerService.java new file mode 100644 index 0000000..918b5e5 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ConsumerService.java @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO; + +/** + * Service interface for managing ConsumerId entities. + */ +public interface ConsumerService { + /** + * Find a ConsumerId by its ID. + * + * @param id the IDP client ID to search for + * @return a list of ConsumerIdDTO objects matching the ID + */ + Optional findById(Long id); + + /** + * Find an ConsumerId by its IDP client ID. + * + * @param idpClientId the IDP client ID to search for + * @return a list of ConsumerIdDTO objects matching the IDP client ID + */ + List findByIdpClientId(String idpClientId); + + /** + * Retrieves a map of consumers identified by their client_id + * + * @param providers a list of provider IDs for which associated consumers need to be retrieved + * @return a map where the keys are provider IDs and the values are lists of ConsumerDTO objects + * representing the consumers associated with each provider + */ + Map> getConsumersOfProviders(List providers); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationService.java new file mode 100644 index 0000000..8452bcb --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/OrganisationService.java @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +/** + * Service interface for managing Organisation entities. + */ +public interface OrganisationService {} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProducerService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProducerService.java new file mode 100644 index 0000000..b0f4972 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProducerService.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import java.util.List; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; + +/** + * Service interface for managing OrganisationProducer entities. + */ +public interface ProducerService { + + /** + * Retrieves a map of organisation IDs to lists of producer DTOs associated with those organisations. + * + * @param producerIds the list of producer IDs to retrieve + * @return a map where keys are organisation IDs and values are lists of producer DTOs associated with each organisation + */ + List getProducersByConsumerIds(List producerIds); + + List getProducersByClientId(String clientId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductConsumerService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductConsumerService.java new file mode 100644 index 0000000..8ccf6f4 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductConsumerService.java @@ -0,0 +1,26 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import java.util.List; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; + +/** + * Service interface for managing ConsumerAllowedDataProvider entities. + */ +public interface ProductConsumerService { + + /** + * Find all ConsumerAllowedDataProvider entities by consumer ID. + * + * @param consumerId the consumer ID + * @return a list of ConsumerAllowedDataProviderDTO objects + */ + List findByConsumerId(Long consumerId); + + List findByDataProviderId(Long providerId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java new file mode 100644 index 0000000..edcbfec --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/ProductService.java @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data; + +import java.util.List; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; + +/** + * Service interface for managing OrganisationDataProvider entities. + */ +public interface ProductService { + + /** + * Retrieves a list of OrganisationDataProviderDTO objects by their IDs. + * + * @param ids the list of IDs to search for + * @return a list of OrganisationDataProviderDTO objects + */ + List getProductsByIds(List ids); + + /** + * Retrieves a list of DataProviderDTO objects associated with the specified producer IDs. + * + * @param ProducerIds the list of producer IDs for which data providers need to be retrieved + * @return a list of DataProviderDTO objects corresponding to the given producer IDs + */ + List getProductsByProducerIds(List ProducerIds); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImpl.java new file mode 100644 index 0000000..bbcb8bf --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImpl.java @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ConsumerRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; + +/** + * Implementation of the consumerIdService interface. + */ +@Service +public class ConsumerServiceImpl implements ConsumerService { + + private final ConsumerRepository consumerRepository; + private final ConsumerConverter consumerIdConverter; + + /** + * Constructor-based dependency injection. + * + * @param consumerRepository the organisation consumer repository + * @param consumerIdConverter the converter for entity-to-DTO conversion + */ + public ConsumerServiceImpl(ConsumerRepository consumerRepository, ConsumerConverter consumerIdConverter) { + this.consumerRepository = consumerRepository; + this.consumerIdConverter = consumerIdConverter; + } + + @Override + public Optional findById(Long id) { + Optional consumer = consumerRepository.findById(id); + return consumer.map(consumerIdConverter::toDto); + } + + /** + * {@inheritDoc} + */ + @Override + public List findByIdpClientId(String idpClientId) { + List consumers = consumerRepository.findByIdpClientId(idpClientId); + return consumerIdConverter.toDtoList(consumers); + } + + @Override + public Map> getConsumersOfProviders(List providers) { + List consumers = consumerRepository.findConsumersByProviderIds(providers); + return consumers.stream() + .map(consumerIdConverter::toDto) + .collect(Collectors.groupingBy( + ConsumerDTO::getIdpClientId, Collectors.mapping(dto -> dto, Collectors.toList()))); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java new file mode 100644 index 0000000..4d38476 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImpl.java @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.OrganisationService; + +/** + * Implementation of the OrganisationService interface. + */ +@Service +public class OrganisationServiceImpl implements OrganisationService { + + private final OrganisationRepository organisationRepository; + + /** + * Constructor-based dependency injection. + * + * @param organisationRepository the organisation repository + */ + public OrganisationServiceImpl(OrganisationRepository organisationRepository) { + this.organisationRepository = organisationRepository; + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProducerServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProducerServiceImpl.java new file mode 100644 index 0000000..ea33fd8 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProducerServiceImpl.java @@ -0,0 +1,54 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import java.util.List; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationProducerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProducerRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; + +/** + * Implementation of the OrganisationProducerService interface. + */ +@Service +public class ProducerServiceImpl implements ProducerService { + + private final ProducerRepository producerRepository; + private final OrganisationProducerConverter organisationProducerConverter; + + /** + * Constructor-based dependency injection. + * + * @param producerRepository the organisation producer repository + * @param organisationProducerConverter the converter for entity-to-DTO conversion + */ + public ProducerServiceImpl( + ProducerRepository producerRepository, OrganisationProducerConverter organisationProducerConverter) { + this.producerRepository = producerRepository; + this.organisationProducerConverter = organisationProducerConverter; + } + + /** + * {@inheritDoc} + */ + @Override + public List getProducersByConsumerIds(List consumerIds) { + List producers = producerRepository.findByConsumerIds(consumerIds); + + // Convert entities to DTOs using the converter + return organisationProducerConverter.toDtoList(producers); + } + + @Override + public List getProducersByClientId(String clientId) { + List producers = producerRepository.findByIdpClientId(clientId); + return organisationProducerConverter.toDtoList(producers); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductConsumerServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductConsumerServiceImpl.java new file mode 100644 index 0000000..9eb43a3 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductConsumerServiceImpl.java @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import java.util.List; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductConsumerRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; + +/** + * Implementation of the ConsumerAllowedDataProviderService interface. + */ +@Service +public class ProductConsumerServiceImpl implements ProductConsumerService { + + private final ProductConsumerRepository productConsumerRepository; + private final ProductConsumerConverter consumerProviderConverter; + + /** + * Constructor-based dependency injection. + * + * @param productConsumerRepository the consumer allowed data provider repository + * @param productConsumerConverter the converter for entity-to-DTO conversion + */ + public ProductConsumerServiceImpl( + ProductConsumerRepository productConsumerRepository, ProductConsumerConverter productConsumerConverter) { + this.productConsumerRepository = productConsumerRepository; + this.consumerProviderConverter = productConsumerConverter; + } + + /** + * {@inheritDoc} + */ + @Override + public List findByConsumerId(Long consumerId) { + List entities = productConsumerRepository.findByConsumerId(consumerId); + return consumerProviderConverter.toDtoList(entities); + } + + @Override + public List findByDataProviderId(Long providerId) { + List entities = productConsumerRepository.findByProductId(providerId); + return consumerProviderConverter.toDtoList(entities); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java new file mode 100644 index 0000000..2762633 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImpl.java @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductRepository; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductService; + +/** + * Implementation of the OrganisationDataProviderService interface. + */ +@Service +public class ProductServiceImpl implements ProductService { + + private final ProductRepository productRepository; + private final ProductConverter productConverter; + + /** + * Constructor-based dependency injection. + * + * @param productRepository the organisation data provider repository + * @param productConverter the converter for entity-to-DTO conversion + */ + public ProductServiceImpl(ProductRepository productRepository, ProductConverter productConverter) { + this.productRepository = productRepository; + this.productConverter = productConverter; + } + + /** + * {@inheritDoc} + */ + @Override + public List getProductsByIds(List ids) { + if (ids == null || ids.isEmpty()) { + return List.of(); + } + List dataProviders = productRepository.findByIds(ids); + return Optional.ofNullable(dataProviders) + .map(productConverter::toDtoList) + .orElse(List.of()); + } + + /** + * {@inheritDoc} + */ + @Override + public List getProductsByProducerIds(List producerIds) { + List dataProviders = productRepository.findByProducerIds(producerIds); + return Optional.ofNullable(dataProviders) + .map(productConverter::toDtoList) + .orElse(List.of()); + } +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProvider.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProvider.java new file mode 100644 index 0000000..374fcf1 --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProvider.java @@ -0,0 +1,46 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; + +import java.util.Optional; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerConfigDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO; + +/** + * Interface for retrieving organization configuration information for both consumers and producers. + *

+ * This provider interface defines methods to access configuration settings for organizations + * based on their client identifiers. It serves as a central point for retrieving configuration + * data that may be stored in various backend systems or repositories. + *

+ * + * @since 1.0 + */ +public interface ConfigurationProvider { + + /** + * Retrieves the configuration for a consumer organization identified by the given client ID. + * + * @param clientId The unique identifier for the consumer organization. Must not be null or blank. + * @param consumerId An optional identifier for the consumer. This can provide further specificity to the request. + * @return The configuration settings for the specified consumer organization. + * @throws IllegalArgumentException if the clientId is null or empty. + * @throws RuntimeException if the configuration cannot be retrieved due to system errors. + */ + ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumerId); + + /** + * Retrieves the configuration for a producer organization identified by the given client ID. + * + * @param clientId The unique identifier for the producer organization. Must not be null or blank. + * @param producerId An optional identifier for the producer. This can provide further specificity to the request. + * @return The configuration settings for the specified producer organization. + * @throws IllegalArgumentException if the clientId is null or empty. + * @throws RuntimeException if the configuration cannot be retrieved due to system errors. + */ + ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId); +} diff --git a/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java new file mode 100644 index 0000000..74f71ba --- /dev/null +++ b/src/main/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImpl.java @@ -0,0 +1,206 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Service; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.*; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; + +@Service +public class ConfigurationProviderImpl implements ConfigurationProvider { + + private final ConsumerService consumerService; + + private final ProductConsumerService consumerAllowedDataProvidersService; + + private final ProducerService producerService; + + public ConfigurationProviderImpl( + ConsumerService consumerService, + ProductConsumerService consumerAllowedDataProviders, + ProducerService producerService) { + + this.consumerService = consumerService; + this.consumerAllowedDataProvidersService = consumerAllowedDataProviders; + this.producerService = producerService; + } + + private static boolean isValidGrantedTs(Timestamp grantedTs, BigDecimal validity) { + return grantedTs != null + && grantedTs + .toInstant() + .plus(java.time.Duration.ofDays(validity.longValue())) + .isAfter(Instant.now()); + } + + @Override + public ConsumerConfigDTO getConsumerConfigByClientId(String clientId, Optional consumerId) { + List consumers = getFilteredConsumers(clientId, consumerId); + List consumerIds = consumers.stream().map(ConsumerDTO::getId).toList(); + + List validProductIds = new ArrayList<>(); + + consumers.forEach(consumer -> + validProductIds.addAll(consumerAllowedDataProvidersService.findByConsumerId(consumer.getId()).stream() + .filter(this::isValidProvider) + .map(ProductConsumerDTO::getProductId) + .toList())); + + List producers = producerService.getProducersByConsumerIds(consumerIds).stream() + .filter(ProducerDTO::getActive) + .toList(); + + // Filter products of each producer to only those in validProductIds + if (!validProductIds.isEmpty()) { + var validIdsSet = new java.util.HashSet<>(validProductIds); + producers.forEach( + p -> p.getProducts().removeIf(prod -> prod.getId() == null || !validIdsSet.contains(prod.getId()))); + } else { + // If no valid products, clear products for all producers + producers.forEach(p -> p.getProducts().clear()); + } + + return ConsumerConfigDTO.builder() + .clientId(clientId) + .producers(producers) + .build(); + } + + @Override + public ProducerConfigDTO getProducerConfigByClientId(String clientId, Optional producerId) { + List producers = getFilteredActiveProducers(clientId, producerId); + List dataProviderIds = collectDataProviderIds(producers); + + // Get allowed consumers (not directly used but might be needed for side effects) + consumerService.getConsumersOfProviders(dataProviderIds); + + // Process consumers for each provider + processConsumersForProducers(producers); + + return ProducerConfigDTO.builder() + .clientId(clientId) + .producers(producers) + .build(); + } + + /** + * Filters consumers by client ID and optional consumer ID. + * + * @param clientId the client ID to filter by + * @param consumerId optional consumer ID for additional filtering + * @return filtered list of consumers + */ + private List getFilteredConsumers(String clientId, Optional consumerId) { + List consumers = consumerService.findByIdpClientId(clientId); + + if (consumerId.isPresent()) { + consumers = consumers.stream() + .filter(consumer -> consumer.getId().equals(consumerId.get())) + .toList(); + } + + return consumers; + } + + /** + * Filters active producers by client ID and optional producer ID. + * + * @param clientId the client ID to filter by + * @param producerId optional producer ID for additional filtering + * @return filtered list of active producers + */ + private List getFilteredActiveProducers(String clientId, Optional producerId) { + List producers = producerService.getProducersByClientId(clientId).stream() + .filter(ProducerDTO::getActive) + .toList(); + + if (producerId.isPresent()) { + producers = producers.stream() + .filter(producer -> producerId.get().equals(producer.getId())) + .toList(); + } + + return producers; + } + + /** + * Collects all data provider IDs from the given producers. + * + * @param producers list of producers + * @return list of data provider IDs + */ + private List collectDataProviderIds(List producers) { + List dataProviderIds = new ArrayList<>(); + + for (ProducerDTO producer : producers) { + List ids = + producer.getProducts().stream().map(ProductDTO::getId).toList(); + dataProviderIds.addAll(ids); + } + + return dataProviderIds; + } + + /** + * Processes consumers for each provider in the given producers. + * + * @param producers list of producers to process + */ + private void processConsumersForProducers(List producers) { + for (ProducerDTO producer : producers) { + for (ProductDTO provider : producer.getProducts()) { + processConsumersForProvider(provider); + } + } + } + + /** + * Processes consumers for a specific provider. + * + * @param provider the provider to process consumers for + */ + private void processConsumersForProvider(ProductDTO provider) { + + // Get consumer providers for this data provider + List consumerProviders = + consumerAllowedDataProvidersService.findByDataProviderId(provider.getId()); + + // Filter valid providers and add their consumers + addValidConsumersToProvider(consumerProviders, provider); + } + + /** + * Adds valid consumers to the given provider. + * + * @param consumerProviders list of consumer-provider relationships + * @param provider the provider to add consumers to + */ + private void addValidConsumersToProvider(List consumerProviders, ProductDTO provider) { + if (provider.getConsumers() == null) { + provider.setConsumers(new ArrayList<>()); + } + consumerProviders.stream().filter(this::isValidProvider).forEach(consumerProvider -> { + Optional consumer = consumerService.findById(consumerProvider.getConsumerId()); + consumer.ifPresent(provider.getConsumers()::add); + }); + } + + private boolean isValidProvider(ProductConsumerDTO provider) { + + if (provider.getValidity() == null || provider.getValidity().equals(BigDecimal.ZERO)) return true; + + return isValidGrantedTs(provider.getGrantedTs(), provider.getValidity()); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0532b6f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,72 @@ +spring: + application: + name: management-node + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://localhost:8443/realms/mng-node + jwk-set-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/certs + audiences: management-node + authorities-claim-name: resource_access + opaquetoken: + introspection-uri: https://localhost:8443/realms/mng-node/protocol/openid-connect/token/introspect + client-secret: + client-id: MANAGEMENT_NODE_CLIENT # required client id for introspect endpoint + flyway: + create-schemas: on + default-schema: mn + locations: classpath:db/migration,classpath:db/samples # samples are only for local development + enabled: true + baseline-on-migrate: true + datasource: + url: jdbc:postgresql://localhost:5433/postgres + username: # required postgress username + password: # required postgress username + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + show_sql: true + default_schema: mn + +# Server configuration +server: + port: 8090 + ssl: + key-alias: localhost + key-store: keystore.jks #path to ssl keystore + key-store-type: JKS + key-store-password: #keystore password + trust-store: #path to ssl truststore + trust-store-password: #truststore password + trust-store-type: JKS + client-auth: need + enabled: true # disable for local development Only +application: + client: + key-store: keystore.jks # path to MTLS client keystore + keyStorePassword: # MTLS client keystore password + keyStoreType: JKS + +# Actuator Configuration +management: + server: + port: 8081 + ssl: + enabled: false + endpoints: + web: + exposure: + include: health,info,metrics + endpoint: + health: + show-details: when_authorized +# Logging Configuration +logging: + level: + org.springframework.security: DEBUG + uk.gov.dbt.ndtp.ia.node.management: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{clientId}] - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{clientId}] - %msg%n" \ No newline at end of file diff --git a/src/main/resources/db/migration/V20250728142253__intial_database_tables.sql b/src/main/resources/db/migration/V20250728142253__intial_database_tables.sql new file mode 100644 index 0000000..6155acb --- /dev/null +++ b/src/main/resources/db/migration/V20250728142253__intial_database_tables.sql @@ -0,0 +1,77 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +create table organisation +( + id bigserial + constraint pk_organisation + primary key, + name varchar(150) not null +); + + + +create table producer +( + id bigserial + constraint pk_producer + primary key, + name varchar(50) not null, + description text not null, + org_id bigint not null + constraint fk___org_id + references organisation, + active boolean not null, + host varchar(500) not null, + port numeric not null, + tls boolean not null, + idp_client_id varchar(50) not null +); + + + +create table consumer +( + id bigserial + constraint pk_consumer + primary key, + name varchar(50) not null, + org_id bigint not null + constraint fk__org_id + references organisation, + idp_client_id varchar(50) not null +); + + + +create table product +( + id bigserial not null + constraint pk_3 + primary key, + name varchar(50) not null, + topic varchar(150) not null, + producer_id bigint not null + constraint fk_2 + references producer +); + + + +create table product_consumer +( + product_id bigint not null + constraint fk_organisation_data_provider__organisation_data_provider_id + references product, + consumer_id bigint not null + constraint fk_organisation_consumer__organisation_consumer_id + references consumer, + granted_ts timestamp not null, + validity numeric not null, + constraint pk_consumer_allowed_data_provider + primary key (product_id, consumer_id) +); + diff --git a/src/main/resources/db/migration/V20250914182403__productConsumersAttributesTable.sql b/src/main/resources/db/migration/V20250914182403__productConsumersAttributesTable.sql new file mode 100644 index 0000000..a5fec82 --- /dev/null +++ b/src/main/resources/db/migration/V20250914182403__productConsumersAttributesTable.sql @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + + +-- Alter table to add a surrogate primary key column as requested +alter table product_consumer add column id bigserial; +-- Switch primary key from (product_id, consumer_id) to the new id column +alter table product_consumer drop constraint pk_consumer_allowed_data_provider; +alter table product_consumer add constraint pk_consumer_allowed_data_provider primary key (id); +-- Preserve uniqueness of the original natural key +alter table product_consumer add constraint uq_product_consumer_pair unique (product_id, consumer_id); + + + + +CREATE TABLE product_consumer_attribute +( + "id" bigserial NOT NULL, + name varchar(150) NOT NULL, + type varchar(50) NOT NULL, + value varchar(500) NOT NULL, + product_consumer_id bigserial NOT NULL, + CONSTRAINT PK_product_consumer_attribute_id PRIMARY KEY ( "id" ), + CONSTRAINT FK_product_consumer_attribute__product_consumer_id FOREIGN KEY ( product_consumer_id ) REFERENCES product_consumer ( "id" ) +); \ No newline at end of file diff --git a/src/main/resources/db/samples/V20250728152300__sample_data.sql b/src/main/resources/db/samples/V20250728152300__sample_data.sql new file mode 100644 index 0000000..9111092 --- /dev/null +++ b/src/main/resources/db/samples/V20250728152300__sample_data.sql @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + + +-- Sample data for organisation table +INSERT INTO organisation (name) +VALUES ('Environment Agency (ENV)'); +INSERT INTO organisation (name) +VALUES ('Bristol City Council (BCC)'); +INSERT INTO organisation (name) +VALUES ('Homes England (HEG)'); + + +-- Sample data for producer table +INSERT INTO producer (name, description, org_id, active, host, port, tls, idp_client_id) +VALUES ('ENV-PRODUCER-1', 'ENV Producer 1', (select id from organisation where name like '%ENV%'), true, + 'https://env.gov.uk', 443, true, 'FEDERATOR_ENV'); + +INSERT INTO producer (name, description, org_id, active, host, port, tls, idp_client_id) +VALUES ('HEG-PRODUCER-1', 'HEG Producer 1', (select id from organisation where name like '%HEG%'), true, + 'https://heg.gov.uk', 443, true, 'FEDERATOR_HEG'); + + +INSERT INTO producer (name, description, org_id, active, host, port, tls, idp_client_id) +VALUES ('BCC-PRODUCER-1', 'BCC Producer 1', (select id from organisation where name like '%BCC%'), true, + 'https://heg.gov.uk', 443, true, 'FEDERATOR_BCC'); + + +-- Sample data for consumer table + + +INSERT INTO consumer (name, org_id, idp_client_id) +VALUES ('ENV-CONSUMER-1', (select id from organisation where name like '%ENV%'), 'FEDERATOR_ENV'); + + +INSERT INTO consumer (name, org_id, idp_client_id) +VALUES ('BCC-CONSUMER-1', (select id from organisation where name like '%BCC%'), 'FEDERATOR_BCC'); + + +INSERT INTO consumer (name, org_id, idp_client_id) +VALUES ('HEG-CONSUMER-1', (select id from organisation where name like '%HEG%'), 'FEDERATOR_HEG'); + +-- Sample data for data_provider table +INSERT INTO product (name, topic, producer_id) +VALUES ('BrownfieldLandAvailability', 'topic.BrownfieldLandAvailability', + (select id from producer where name like '%HEG%'));; + +INSERT INTO product (name, topic, producer_id) +VALUES ('PendingPlanningApplications', 'topic.PendingPlanningApplications', + (select id from producer where name like '%BCC%'));; + +INSERT INTO product (name, topic, producer_id) +VALUES ('FloodRiskMapZones', 'topic.FloodRiskMapZones', (select id from producer where name like '%ENV%'));; + + + +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + + + +-- Sample data for consumer_provider table +INSERT INTO product_consumer (product_id, consumer_id, granted_ts, validity) +VALUES ((select id from product where name = 'FloodRiskMapZones'), (select id from consumer where name like '%BCC%'), + '2025-07-01 00:00:00', 365); + + +INSERT INTO product_consumer (product_id, consumer_id, granted_ts, validity) +VALUES ((select id from product where name = 'PendingPlanningApplications'), + (select id from consumer where name like '%HEG%'), '2025-07-01 00:00:00', 365); + + + +INSERT INTO product_consumer (product_id, consumer_id, granted_ts, validity) +VALUES ((select id from product where name = 'BrownfieldLandAvailability'), + (select id from consumer where name like '%ENV%'), '2025-07-01 00:00:00', 365); + + diff --git a/src/main/resources/db/samples/V20250914194456__add_sample_attributes.sql b/src/main/resources/db/samples/V20250914194456__add_sample_attributes.sql new file mode 100644 index 0000000..9a931f9 --- /dev/null +++ b/src/main/resources/db/samples/V20250914194456__add_sample_attributes.sql @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + + +insert +into product_consumer_attribute (name, type, value, product_consumer_id) +values ('nationality', 'string', 'GBR', (select id as pid + from product_consumer + where product_id = + (Select id from product where name = 'BrownfieldLandAvailability') + and consumer_id = + (Select id from consumer where idp_client_id = 'FEDERATOR_ENV'))); + +insert +into product_consumer_attribute (name, type, value, product_consumer_id) +values ('clearance', 'string', '0', (select id as pid + from product_consumer + where + product_id = (Select id from product where name = 'BrownfieldLandAvailability') + and consumer_id = + (Select id from consumer where idp_client_id = 'FEDERATOR_ENV'))); + + + +insert +into product_consumer_attribute (name, type, value, product_consumer_id) +values ('organisation_type', 'string', 'NON-GOV3', (select id as pid + from product_consumer + where product_id = + (Select id from product where name = 'BrownfieldLandAvailability') + and consumer_id = + (Select id from consumer where idp_client_id = 'FEDERATOR_ENV'))); + diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java new file mode 100644 index 0000000..7e7663a --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/ManagementNodeApplicationTests.java @@ -0,0 +1,17 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ManagementNodeApplicationTests { + + @Test + void contextLoads() {} +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java new file mode 100644 index 0000000..381b682 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterExceptionTest.java @@ -0,0 +1,187 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +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; + +import java.time.Instant; +import java.util.*; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; + +/** + * Tests specifically for exception handling in KeycloakJwtAuthenticationConverter. + */ +@ExtendWith(MockitoExtension.class) +class KeycloakJwtAuthenticationConverterExceptionTest { + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private KeycloakJwtAuthenticationConverter converter; + + private Jwt mockJwt; + + @BeforeEach + void setUp() { + // Set up configuration properties + ReflectionTestUtils.setField( + converter, + "introspectionUri", + "http://localhost:8080/realms/management-node/protocol/openid-connect/token/introspect"); + ReflectionTestUtils.setField(converter, "clientId", "management-node"); + ReflectionTestUtils.setField(converter, "clientSecret", "0T5S4wNAPaaOUzFVFQyenorSEC6zxcb0"); + + // Create a mock JWT with the sample token data + Map headers = new HashMap<>(); + headers.put("alg", "RS256"); + headers.put("typ", "JWT"); + + Map claims = new HashMap<>(); + claims.put("exp", 1753576065); + claims.put("iat", 1753575765); + claims.put("jti", "trrtcc:713a03f4-55fb-4198-e8ea-a1be37d5f52f"); + claims.put("iss", "http://localhost:8080/realms/management-node"); + claims.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842"); + claims.put("typ", "Bearer"); + claims.put("azp", "management-node"); + claims.put("client_id", "management-node"); + + // Set up the aud claim as a list + List audiences = Arrays.asList("F1", "F2"); + claims.put("aud", audiences); + + // Set up resource_access claim with nested roles + Map resourceAccess = new HashMap<>(); + Map f1Resource = new HashMap<>(); + List f1Roles = Arrays.asList("TOPIC_2", "TOPIC_1"); + f1Resource.put("roles", f1Roles); + resourceAccess.put("F1", f1Resource); + claims.put("resource_access", resourceAccess); + + // Create the JWT with the headers and claims + mockJwt = new Jwt( + "token-value", Instant.ofEpochSecond(1753575765), Instant.ofEpochSecond(1753576065), headers, claims); + + // Inject mock RestTemplate + ReflectionTestUtils.setField(converter, "restTemplate", restTemplate); + } + + @Test + void convert_withRestClientException_shouldFallbackToJwtParsing() { + // Arrange + // Configure RestTemplate to throw a RestClientException + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + .thenThrow(new RestClientException("Connection refused")); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + + // Verify the token has the correct principal + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + } + + @Test + void convert_withMalformedIntrospectionResponse_shouldFallbackToJwtParsing() { + // Arrange + // Create a malformed introspection response that will cause a ResourceAccessParsingException + Map malformedResponse = new HashMap<>(); + malformedResponse.put("active", true); + malformedResponse.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842"); + malformedResponse.put("client_id", "management-node"); + + // Add malformed resource_access (not a map but a string) + malformedResponse.put("resource_access", "not-a-map"); + + // Configure RestTemplate to return the malformed response + ResponseEntity responseEntity = new ResponseEntity<>(malformedResponse, HttpStatus.OK); + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + .thenReturn(responseEntity); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + + // Verify the token has the correct principal + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + } + + @Test + void convert_withInactiveToken_shouldFallbackToJwtParsing() { + // Arrange + // Create an introspection response with inactive token + Map inactiveTokenResponse = new HashMap<>(); + inactiveTokenResponse.put("active", false); + + // Configure RestTemplate to return the inactive token response + ResponseEntity responseEntity = new ResponseEntity<>(inactiveTokenResponse, HttpStatus.OK); + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + .thenReturn(responseEntity); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + + // Verify the token has the correct principal + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + } + + @Test + void convert_withNullIntrospectionResponse_shouldFallbackToJwtParsing() { + // Arrange + // Configure RestTemplate to return null response body + ResponseEntity responseEntity = new ResponseEntity<>(null, HttpStatus.OK); + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.>any())) + .thenReturn(responseEntity); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + + // Verify the token has the correct principal + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java new file mode 100644 index 0000000..21e8c6b --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/config/KeycloakJwtAuthenticationConverterTest.java @@ -0,0 +1,398 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.config; + +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; + +import java.time.Instant; +import java.util.*; +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.client.RestTemplate; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.EnhancedPrincipal; +import uk.gov.dbt.ndtp.ia.node.management.model.jwt.JwtToken; + +@ExtendWith(MockitoExtension.class) +class KeycloakJwtAuthenticationConverterTest { + + @Mock + private RestTemplate restTemplate; + + @InjectMocks + private KeycloakJwtAuthenticationConverter converter; + + private Jwt mockJwt; + private Map mockIntrospectionResponse; + + @BeforeEach + void setUp() { + // Set up configuration properties + ReflectionTestUtils.setField( + converter, + "introspectionUri", + "http://localhost:8080/realms/management-node/protocol/openid-connect/token/introspect"); + ReflectionTestUtils.setField(converter, "clientId", "management-node"); + ReflectionTestUtils.setField(converter, "clientSecret", "0T5S4wNAPaaOUzFVFQyenorSEC6zxcb0"); + + // Create a mock JWT with the sample token data + Map headers = new HashMap<>(); + headers.put("alg", "RS256"); + headers.put("typ", "JWT"); + + Map claims = new HashMap<>(); + claims.put("exp", 1753576065); + claims.put("iat", 1753575765); + claims.put("jti", "trrtcc:713a03f4-55fb-4198-e8ea-a1be37d5f52f"); + claims.put("iss", "http://localhost:8080/realms/management-node"); + claims.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842"); + claims.put("typ", "Bearer"); + claims.put("azp", "management-node"); + claims.put("client_id", "management-node"); + + // Set up the aud claim as a list + List audiences = Arrays.asList("F1", "F2"); + claims.put("aud", audiences); + + // Set up the allowed-origins claim + List allowedOrigins = Collections.singletonList("/*"); + claims.put("allowed-origins", allowedOrigins); + + // Set up the resource_access claim with nested roles + Map resourceAccess = new HashMap<>(); + + // F1 resource + Map f1Resource = new HashMap<>(); + List f1Roles = Arrays.asList("TOPIC_2", "TOPIC_1"); + f1Resource.put("roles", f1Roles); + resourceAccess.put("F1", f1Resource); + + // management-node resource + Map managementNodeResource = new HashMap<>(); + List managementNodeRoles = Collections.singletonList("MyRole"); + managementNodeResource.put("roles", managementNodeRoles); + resourceAccess.put("management-node", managementNodeResource); + + // F2 resource + Map f2Resource = new HashMap<>(); + List f2Roles = Collections.singletonList("R1"); + f2Resource.put("roles", f2Roles); + resourceAccess.put("F2", f2Resource); + + claims.put("resource_access", resourceAccess); + + // Additional claims + claims.put("scope", "Sample_ORG management-node-client-scope"); + claims.put("clientHost", "172.20.0.1"); + claims.put("clientAddress", "172.20.0.1"); + + // Create the JWT with the headers and claims + mockJwt = new Jwt( + "token-value", Instant.ofEpochSecond(1753575765), Instant.ofEpochSecond(1753576065), headers, claims); + + // Create mock introspection response + mockIntrospectionResponse = new HashMap<>(); + mockIntrospectionResponse.put("active", true); + mockIntrospectionResponse.put("exp", 1753576065); + mockIntrospectionResponse.put("iat", 1753575765); + mockIntrospectionResponse.put("jti", "trrtcc:713a03f4-55fb-4198-e8ea-a1be37d5f52f"); + mockIntrospectionResponse.put("iss", "http://localhost:8080/realms/management-node"); + mockIntrospectionResponse.put("sub", "86a41a8a-ab2e-465e-8b48-a09d3275f842"); + mockIntrospectionResponse.put("typ", "Bearer"); + mockIntrospectionResponse.put("azp", "management-node"); + mockIntrospectionResponse.put("client_id", "management-node"); + mockIntrospectionResponse.put("aud", audiences); + mockIntrospectionResponse.put("allowed-origins", allowedOrigins); + mockIntrospectionResponse.put("resource_access", resourceAccess); + mockIntrospectionResponse.put("scope", "Sample_ORG management-node-client-scope"); + mockIntrospectionResponse.put("clientHost", "172.20.0.1"); + mockIntrospectionResponse.put("clientAddress", "172.20.0.1"); + + // Inject mock RestTemplate + ReflectionTestUtils.setField(converter, "restTemplate", restTemplate); + } + + private void setupMockIntrospectionResponse(Map response) { + // Convert Map to JwtToken + JwtToken jwtToken = createJwtTokenFromMap(response); + ResponseEntity responseEntity = new ResponseEntity<>(jwtToken, HttpStatus.OK); + Mockito.lenient() + .when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.eq(JwtToken.class))) + .thenReturn(responseEntity); + } + + private JwtToken createJwtTokenFromMap(Map map) { + if (map == null) { + return null; + } + + // Extract resource_access and convert it to the format expected by JwtToken + Map resourceAccessMap = (Map) map.get("resource_access"); + Map resourceAccess = new HashMap<>(); + + if (resourceAccessMap != null) { + resourceAccessMap.forEach((resource, resourceDataObj) -> { + if (resourceDataObj instanceof Map) { + Map resourceData = (Map) resourceDataObj; + List roles = (List) resourceData.get("roles"); + if (roles != null) { + resourceAccess.put(resource, new JwtToken.ResourceAccess(roles)); + } + } + }); + } + + // Extract other fields + // Handle numeric values that could be Integer or Long + Long exp = null; + if (map.get("exp") != null) { + exp = map.get("exp") instanceof Long ? (Long) map.get("exp") : ((Number) map.get("exp")).longValue(); + } + + Long iat = null; + if (map.get("iat") != null) { + iat = map.get("iat") instanceof Long ? (Long) map.get("iat") : ((Number) map.get("iat")).longValue(); + } + + return JwtToken.builder() + .active((Boolean) map.get("active")) + .exp(exp) + .iat(iat) + .jti((String) map.get("jti")) + .iss((String) map.get("iss")) + .sub((String) map.get("sub")) + .typ((String) map.get("typ")) + .azp((String) map.get("azp")) + .clientId((String) map.get("client_id")) + .aud((List) map.get("aud")) + .allowedOrigins((List) map.get("allowed-origins")) + .resourceAccess(resourceAccess) + .scope((String) map.get("scope")) + .username((String) map.get("username")) + .tokenType((String) map.get("token_type")) + .build(); + } + + @Test + void convert_shouldExtractCorrectAuthorities() { + // Setup mock introspection response + setupMockIntrospectionResponse(mockIntrospectionResponse); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", token.getName()); + + // Verify the CustomPrincipal has the correct clientId + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertNotNull(principal); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + + Collection authorities = token.getAuthorities(); + assertNotNull(authorities); + + // Verify the expected authorities are present + List expectedAuthorities = + Arrays.asList("ROLE_F1:TOPIC_1", "ROLE_F1:TOPIC_2", "ROLE_F2:R1", "ROLE_management-node:MyRole"); + + // Don't assert the exact count as JwtGrantedAuthoritiesConverter may add default authorities + // Just verify that all our expected authorities are present + + for (String expectedAuthority : expectedAuthorities) { + boolean found = authorities.stream() + .anyMatch(authority -> authority.getAuthority().equals(expectedAuthority)); + assertTrue(found, "Expected authority not found: " + expectedAuthority); + } + } + + @Test + void convert_withNullResourceAccess_shouldNotFail() { + // Arrange + Map claims = new HashMap<>(); + claims.put("sub", "test-subject"); + + Jwt jwtWithoutResourceAccess = new Jwt( + "token-value", + Instant.now(), + Instant.now().plusSeconds(300), + Collections.singletonMap("alg", "none"), + claims); + + // Setup mock introspection response to return null (simulate introspection failure) + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.eq(JwtToken.class))) + .thenThrow(new RuntimeException("Simulated introspection failure")); + + // Act + AbstractAuthenticationToken token = converter.convert(jwtWithoutResourceAccess); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + assertEquals("test-subject", token.getName()); + + // Verify the CustomPrincipal has the correct values + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertNotNull(principal); + assertEquals("test-subject", principal.subject()); + assertEquals("unknown", principal.clientId()); // Should default to "unknown" + + // Should not throw exception and return token with default authorities + } + + @Test + void convert_withEmptyRoles_shouldNotAddAuthorities() { + // Arrange + Map claims = new HashMap<>(); + claims.put("sub", "test-subject"); + + Map resourceAccess = new HashMap<>(); + Map resource = new HashMap<>(); + resource.put("roles", Collections.emptyList()); + resourceAccess.put("test-resource", resource); + claims.put("resource_access", resourceAccess); + + Jwt jwtWithEmptyRoles = new Jwt( + "token-value", + Instant.now(), + Instant.now().plusSeconds(300), + Collections.singletonMap("alg", "none"), + claims); + + // Setup mock introspection response to return null (simulate introspection failure) + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.eq(JwtToken.class))) + .thenThrow(new RuntimeException("Simulated introspection failure")); + + // Act + AbstractAuthenticationToken token = converter.convert(jwtWithEmptyRoles); + + // Assert + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + + // Verify the CustomPrincipal has the correct values + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertNotNull(principal); + assertEquals("test-subject", principal.subject()); + assertEquals("unknown", principal.clientId()); // Should default to "unknown" + + // Should not add any authorities for the empty roles list + assertEquals(0, token.getAuthorities().size()); + } + + @Test + void convert_withMalformedResourceAccess_shouldHandleGracefully() { + // Arrange + Map claims = new HashMap<>(); + claims.put("sub", "test-subject"); + + // Malformed resource_access (not a map) + claims.put("resource_access", "not-a-map"); + + Jwt jwtWithMalformedResourceAccess = new Jwt( + "token-value", + Instant.now(), + Instant.now().plusSeconds(300), + Collections.singletonMap("alg", "none"), + claims); + + // Setup mock introspection response to return null (simulate introspection failure) + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.eq(JwtToken.class))) + .thenThrow(new RuntimeException("Simulated introspection failure")); + + // Act & Assert + // Should not throw exception + AbstractAuthenticationToken token = converter.convert(jwtWithMalformedResourceAccess); + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + + // Verify the CustomPrincipal has the correct values + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertNotNull(principal); + assertEquals("test-subject", principal.subject()); + assertEquals("unknown", principal.clientId()); // Should default to "unknown" + } + + @Test + void convert_shouldUseIntrospectionEndpoint() { + // Setup mock introspection response + setupMockIntrospectionResponse(mockIntrospectionResponse); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + // Verify that the RestTemplate was called + Mockito.verify(restTemplate).postForEntity(anyString(), any(HttpEntity.class), Mockito.>any()); + + // Verify the token is correct + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", token.getName()); + + // Verify the CustomPrincipal has the correct clientId + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertNotNull(principal); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + + // Verify the authorities + Collection authorities = token.getAuthorities(); + assertNotNull(authorities); + + // Verify the expected authorities are present + List expectedAuthorities = + Arrays.asList("ROLE_F1:TOPIC_1", "ROLE_F1:TOPIC_2", "ROLE_F2:R1", "ROLE_management-node:MyRole"); + + for (String expectedAuthority : expectedAuthorities) { + boolean found = authorities.stream() + .anyMatch(authority -> authority.getAuthority().equals(expectedAuthority)); + assertTrue(found, "Expected authority not found: " + expectedAuthority); + } + } + + @Test + void convert_withIntrospectionFailure_shouldFallbackToJwtParsing() { + // Arrange + // Configure RestTemplate to throw an exception + Mockito.reset(restTemplate); // Reset any previous stubbing + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), Mockito.eq(JwtToken.class))) + .thenThrow(new RuntimeException("Introspection failed")); + + // Act + AbstractAuthenticationToken token = converter.convert(mockJwt); + + // Assert + // Verify that the token is still created using JWT parsing + assertNotNull(token); + assertTrue(token instanceof CustomJwtAuthenticationToken); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", token.getName()); + + // Verify the CustomPrincipal has the correct clientId + EnhancedPrincipal principal = ((CustomJwtAuthenticationToken) token).getPrincipal(); + assertNotNull(principal); + assertEquals("86a41a8a-ab2e-465e-8b48-a09d3275f842", principal.subject()); + assertEquals("management-node", principal.clientId()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java new file mode 100644 index 0000000..e75ac25 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/controller/v1/ConfigurationControllerTest.java @@ -0,0 +1,128 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.controller.v1; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.ArrayList; +import java.util.Collections; +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 org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerConfigDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerConfigDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration.ConfigurationProvider; + +@ExtendWith(MockitoExtension.class) +class ConfigurationControllerTest { + + private MockMvc mockMvc; + + @Mock + private ConfigurationProvider configurationProvider; + + @InjectMocks + private ConfigurationController configurationController; + + private final String CLIENT_ID = "test-client-id"; + private final Long PRODUCER_ID = 1L; + private final Long CONSUMER_ID = 2L; + + private ProducerConfigDTO producerConfigDTO; + private ConsumerConfigDTO consumerConfigDTO; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(configurationController).build(); + + // Set up producer config + ProducerDTO producerDTO = ProducerDTO.builder() + .id(PRODUCER_ID) + .name("Test Producer") + .active(true) + .build(); + + producerConfigDTO = ProducerConfigDTO.builder() + .clientId(CLIENT_ID) + .producers(Collections.singletonList(producerDTO)) + .build(); + + // Set up consumer config + consumerConfigDTO = ConsumerConfigDTO.builder() + .clientId(CLIENT_ID) + .producers(new ArrayList<>()) + .build(); + } + + // Note: In a real test environment with a full Spring Security context, we would use: + // 1. @WithMockUser to test authorization rules + // 2. SecurityMockMvcRequestPostProcessors.user() to provide authentication + // 3. A WebMvcTest with a proper security configuration + // + // For this example, we're using a standalone setup that bypasses Spring Security, + // so we're focusing on testing the controller functionality rather than authorization. + // The actual authorization is enforced by Spring Security through the @PreAuthorize annotations. + + @Test + void getProducerConfigurations_shouldReturnConfig() throws Exception { + // Arrange + when(configurationProvider.getProducerConfigByClientId(any(), any())).thenReturn(producerConfigDTO); + + // Act & Assert + mockMvc.perform(get("/api/v1/configuration/producer").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + } + + @Test + void getProducerConfigurations_withProducerId_shouldReturnFilteredConfig() throws Exception { + // Arrange + when(configurationProvider.getProducerConfigByClientId(any(), any())).thenReturn(producerConfigDTO); + + // Act & Assert + mockMvc.perform(get("/api/v1/configuration/producer") + .param("producer_id", PRODUCER_ID.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + } + + @Test + void getConsumerConfigurations_shouldReturnConfig() throws Exception { + // Arrange + when(configurationProvider.getConsumerConfigByClientId(any(), any())).thenReturn(consumerConfigDTO); + + // Act & Assert + mockMvc.perform(get("/api/v1/configuration/consumer").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + } + + @Test + void getConsumerConfigurations_withConsumerId_shouldReturnFilteredConfig() throws Exception { + // Arrange + when(configurationProvider.getConsumerConfigByClientId(any(), any())).thenReturn(consumerConfigDTO); + + // Act & Assert + mockMvc.perform(get("/api/v1/configuration/consumer") + .param("consumer_id", CONSUMER_ID.toString()) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.clientId").value(CLIENT_ID)); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverterTest.java new file mode 100644 index 0000000..979762e --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ConsumerConverterTest.java @@ -0,0 +1,232 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +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 uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +@ExtendWith(MockitoExtension.class) +class ConsumerConverterTest { + + @Mock + private OrganisationRepository organisationRepository; + + @InjectMocks + private ConsumerConverter converter; + + private Consumer entity; + private ConsumerDTO dto; + private Organisation organisation; + + private final Long consumerId = 1L; + private final String consumerName = "Test Consumer"; + private final String idpClientId = "test-client-id"; + private final Long orgId = 101L; + private final String orgName = "Test Organisation"; + + @BeforeEach + void setUp() { + // Create test organisation + organisation = new Organisation(); + organisation.setId(orgId); + organisation.setName(orgName); + + // Create test entity + entity = new Consumer(); + entity.setId(consumerId); + entity.setName(consumerName); + entity.setIdpClientId(idpClientId); + entity.setOrg(organisation); + + // Create test DTO + dto = new ConsumerDTO(); + dto.setId(consumerId); + dto.setName(consumerName); + dto.setIdpClientId(idpClientId); + dto.setOrgId(orgId); + } + + @Test + void toDto_withNullEntity_shouldReturnNull() { + // Act + ConsumerDTO result = converter.toDto(null); + + // Assert + assertNull(result); + } + + @Test + void toDto_withValidEntity_shouldReturnCorrectDTO() { + // Act + ConsumerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(consumerId, result.getId()); + assertEquals(consumerName, result.getName()); + assertEquals(idpClientId, result.getIdpClientId()); + assertEquals(orgId, result.getOrgId()); + } + + @Test + void toDto_withNullOrg_shouldReturnDTOWithNullOrgId() { + // Arrange + entity.setOrg(null); + + // Act + ConsumerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(consumerId, result.getId()); + assertEquals(consumerName, result.getName()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrgId()); + } + + @Test + void toEntity_withNullDTO_shouldReturnNull() { + // Act + Consumer result = converter.toEntity(null); + + // Assert + assertNull(result); + } + + @Test + void toEntity_withValidDTO_shouldReturnCorrectEntity() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Consumer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(consumerId, result.getId()); + assertEquals(consumerName, result.getName()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNotNull(result.getOrg()); + assertEquals(orgId, result.getOrg().getId()); + assertEquals(orgName, result.getOrg().getName()); + + // Verify + verify(organisationRepository, times(1)).findById(orgId); + } + + @Test + void toEntity_withNullOrgId_shouldReturnEntityWithNullOrg() { + // Arrange + dto.setOrgId(null); + + // Act + Consumer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(consumerId, result.getId()); + assertEquals(consumerName, result.getName()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrg()); + + // Verify + verify(organisationRepository, never()).findById(any()); + } + + @Test + void toEntity_withNonExistentOrgId_shouldReturnEntityWithNullOrg() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.empty()); + + // Act + Consumer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(consumerId, result.getId()); + assertEquals(consumerName, result.getName()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrg()); + + // Verify + verify(organisationRepository, times(1)).findById(orgId); + } + + @Test + void toDto_withAttributes_shouldPopulateAttributesFromEntity() { + // Arrange + uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer pc1 = + new uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer(); + uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute a1 = + new uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute(); + a1.setName("attr1"); + a1.setType("string"); + a1.setValue("v1"); + uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute a2 = + new uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute(); + a2.setName("attr2"); + a2.setType("number"); + a2.setValue("42"); + pc1.setProductConsumerAttributes(java.util.List.of(a1, a2)); + + uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer pc2 = + new uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer(); + uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute a3 = + new uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute(); + a3.setName("attr3"); + a3.setType("bool"); + a3.setValue("true"); + pc2.setProductConsumerAttributes(java.util.List.of(a3)); + + entity.setProductConsumers(java.util.List.of(pc1, pc2)); + + // Act + ConsumerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(3, result.getAttributes().size()); + assertTrue(result.getAttributes().stream() + .anyMatch(a -> a.getName().equals("attr1") + && a.getType().equals("string") + && a.getValue().equals("v1"))); + assertTrue(result.getAttributes().stream() + .anyMatch(a -> a.getName().equals("attr2") + && a.getType().equals("number") + && a.getValue().equals("42"))); + assertTrue(result.getAttributes().stream() + .anyMatch(a -> a.getName().equals("attr3") + && a.getType().equals("bool") + && a.getValue().equals("true"))); + } + + @Test + void toDto_withNoAttributes_shouldHaveEmptyAttributesList() { + // Arrange + entity.setProductConsumers(java.util.List.of()); + + // Act + ConsumerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertNotNull(result.getAttributes()); + assertEquals(0, result.getAttributes().size()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java new file mode 100644 index 0000000..2c11ac1 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/OrganisationProducerConverterTest.java @@ -0,0 +1,418 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +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 uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +@ExtendWith(MockitoExtension.class) +class OrganisationProducerConverterTest { + + @Mock + private OrganisationRepository organisationRepository; + + @Mock + private ProductConverter productConverter; + + @InjectMocks + private OrganisationProducerConverter converter; + + private Producer entity; + private ProducerDTO dto; + private Organisation organisation; + private List dataProviders; + private List dataProviderDTOs; + + private final Long producerId = 1L; + private final String producerName = "Test Producer"; + private final String description = "Test Description"; + private final Boolean active = true; + private final String host = "test-host"; + private final BigDecimal port = new BigDecimal("8080"); + private final Boolean tls = true; + private final String idpClientId = "test-client-id"; + private final Long orgId = 101L; + private final String orgName = "Test Organisation"; + + private final Long dataProviderId1 = 201L; + private final String dataProviderName1 = "Test Data Provider 1"; + private final String topic1 = "test-topic-1"; + + private final Long dataProviderId2 = 202L; + private final String dataProviderName2 = "Test Data Provider 2"; + private final String topic2 = "test-topic-2"; + + @BeforeEach + void setUp() { + // Create test organisation + organisation = new Organisation(); + organisation.setId(orgId); + organisation.setName(orgName); + + // Create test data providers + dataProviders = new ArrayList<>(); + + Product dataProvider1 = new Product(); + dataProvider1.setId(dataProviderId1); + dataProvider1.setName(dataProviderName1); + dataProvider1.setTopic(topic1); + + Product dataProvider2 = new Product(); + dataProvider2.setId(dataProviderId2); + dataProvider2.setName(dataProviderName2); + dataProvider2.setTopic(topic2); + + dataProviders.add(dataProvider1); + dataProviders.add(dataProvider2); + + // Create test entity + entity = new Producer(); + entity.setId(producerId); + entity.setName(producerName); + entity.setDescription(description); + entity.setActive(active); + entity.setHost(host); + entity.setPort(port); + entity.setTls(tls); + entity.setIdpClientId(idpClientId); + entity.setOrg(organisation); + entity.setProducts(dataProviders); + + // Set producer reference in data providers + dataProvider1.setProducer(entity); + dataProvider2.setProducer(entity); + + // Create test data provider DTOs + dataProviderDTOs = new ArrayList<>(); + + ProductDTO dataProviderDTO1 = ProductDTO.builder() + .id(dataProviderId1) + .name(dataProviderName1) + .topic(topic1) + .producerId(producerId) + .build(); + + ProductDTO dataProviderDTO2 = ProductDTO.builder() + .id(dataProviderId2) + .name(dataProviderName2) + .topic(topic2) + .producerId(producerId) + .build(); + + dataProviderDTOs.add(dataProviderDTO1); + dataProviderDTOs.add(dataProviderDTO2); + + // Create test DTO + dto = new ProducerDTO(); + dto.setId(producerId); + dto.setName(producerName); + dto.setDescription(description); + dto.setActive(active); + dto.setHost(host); + dto.setPort(port); + dto.setTls(tls); + dto.setIdpClientId(idpClientId); + dto.setOrgId(orgId); + + // Add data provider DTOs to the producer DTO + dto.getProducts().addAll(dataProviderDTOs); + + // Set up mock behavior for productConverter + lenient().when(productConverter.toDto(dataProvider1)).thenReturn(dataProviderDTO1); + lenient().when(productConverter.toDto(dataProvider2)).thenReturn(dataProviderDTO2); + lenient().when(productConverter.toEntity(dataProviderDTO1)).thenReturn(dataProvider1); + lenient().when(productConverter.toEntity(dataProviderDTO2)).thenReturn(dataProvider2); + } + + @Test + void toDto_withNullEntity_shouldReturnNull() { + // Act + ProducerDTO result = converter.toDto(null); + + // Assert + assertNull(result); + } + + @Test + void toDto_withValidEntity_shouldReturnCorrectDTO() { + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertEquals(orgId, result.getOrgId()); + + // Verify dataProviders mapping + assertNotNull(result.getProducts()); + assertEquals(2, result.getProducts().size()); + + // Verify first data provider + ProductDTO productDTO1 = result.getProducts().get(0); + assertEquals(dataProviderId1, productDTO1.getId()); + assertEquals(dataProviderName1, productDTO1.getName()); + assertEquals(topic1, productDTO1.getTopic()); + assertEquals(producerId, productDTO1.getProducerId()); + + // Verify second data provider + ProductDTO dataProviderDTO2 = result.getProducts().get(1); + assertEquals(dataProviderId2, dataProviderDTO2.getId()); + assertEquals(dataProviderName2, dataProviderDTO2.getName()); + assertEquals(topic2, dataProviderDTO2.getTopic()); + assertEquals(producerId, dataProviderDTO2.getProducerId()); + + // Verify productConverter was called for each data provider + verify(productConverter, times(1)).toDto(dataProviders.get(0)); + verify(productConverter, times(1)).toDto(dataProviders.get(1)); + } + + @Test + void toDto_withNullOrg_shouldReturnDTOWithNullOrgId() { + // Arrange + entity.setOrg(null); + + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrgId()); + } + + @Test + void toDto_withNullProducts_shouldReturnDTOWithEmptyDataProviders() { + // Arrange + entity.setProducts(null); + + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertTrue(result.getProducts().isEmpty()); + + // Verify productConverter was not called + verify(productConverter, never()).toDto(any()); + } + + @Test + void toDto_withEmptyProducts_shouldReturnDTOWithEmptyDataProviders() { + // Arrange + entity.setProducts(new ArrayList<>()); + + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertTrue(result.getProducts().isEmpty()); + + // Verify productConverter was not called + verify(productConverter, never()).toDto(any()); + } + + @Test + void toEntity_withNullDTO_shouldReturnNull() { + // Act + Producer result = converter.toEntity(null); + + // Assert + assertNull(result); + } + + @Test + void toEntity_withValidDTO_shouldReturnCorrectEntity() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNotNull(result.getOrg()); + assertEquals(orgId, result.getOrg().getId()); + assertEquals(orgName, result.getOrg().getName()); + + // Verify dataProviders mapping + assertNotNull(result.getProducts()); + assertEquals(2, result.getProducts().size()); + + // Verify first data provider + Product dataProvider1 = result.getProducts().get(0); + assertEquals(dataProviderId1, dataProvider1.getId()); + assertEquals(dataProviderName1, dataProvider1.getName()); + assertEquals(topic1, dataProvider1.getTopic()); + assertNotNull(dataProvider1.getProducer()); + assertEquals(result, dataProvider1.getProducer()); + + // Verify second data provider + Product dataProvider2 = result.getProducts().get(1); + assertEquals(dataProviderId2, dataProvider2.getId()); + assertEquals(dataProviderName2, dataProvider2.getName()); + assertEquals(topic2, dataProvider2.getTopic()); + assertNotNull(dataProvider2.getProducer()); + assertEquals(result, dataProvider2.getProducer()); + + // Verify productConverter was called for each data provider DTO + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); + + // Verify organisation repository was called + verify(organisationRepository, times(1)).findById(orgId); + } + + @Test + void toEntity_withNullOrgId_shouldReturnEntityWithNullOrg() { + // Arrange + dto.setOrgId(null); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrg()); + + // Verify + verify(organisationRepository, never()).findById(any()); + } + + @Test + void toEntity_withNonExistentOrgId_shouldReturnEntityWithNullOrg() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.empty()); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrg()); + + // Verify + verify(organisationRepository, times(1)).findById(orgId); + } + + @Test + void toEntity_withEmptyDataProviders_shouldReturnEntityWithEmptyProducts() { + // Arrange + dto.getProducts().clear(); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNull(result.getProducts()); + + // Verify productConverter was not called + verify(productConverter, never()).toEntity(any()); + } + + @Test + void toEntity_withNullProducerId_shouldSetProducerIdInDataProviderDTO() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Set producerId to null in data provider DTOs + dataProviderDTOs.get(0).setProducerId(null); + dataProviderDTOs.get(1).setProducerId(null); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertEquals(2, result.getProducts().size()); + + // Verify producerId was set in data provider DTOs + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); + + // Verify producerId was set in data provider DTOs + assertEquals(producerId, dataProviderDTOs.get(0).getProducerId()); + assertEquals(producerId, dataProviderDTOs.get(1).getProducerId()); + } + + @Test + void toEntity_withNullDataProviderFromConverter_shouldNotAddToProducts() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + when(productConverter.toEntity(dataProviderDTOs.get(1))).thenReturn(null); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertEquals(1, result.getProducts().size()); + + // Verify only one data provider was added + assertEquals(dataProviderId1, result.getProducts().get(0).getId()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java new file mode 100644 index 0000000..10b42ac --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProducerConverterTest.java @@ -0,0 +1,418 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +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 uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Organisation; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +@ExtendWith(MockitoExtension.class) +class ProducerConverterTest { + + @Mock + private OrganisationRepository organisationRepository; + + @Mock + private ProductConverter productConverter; + + @InjectMocks + private ProducerConverter converter; + + private Producer entity; + private ProducerDTO dto; + private Organisation organisation; + private List dataProviders; + private List dataProviderDTOs; + + private final Long producerId = 1L; + private final String producerName = "Test Producer"; + private final String description = "Test Description"; + private final Boolean active = true; + private final String host = "test-host"; + private final BigDecimal port = new BigDecimal("8080"); + private final Boolean tls = true; + private final String idpClientId = "test-client-id"; + private final Long orgId = 101L; + private final String orgName = "Test Organisation"; + + private final Long dataProviderId1 = 201L; + private final String dataProviderName1 = "Test Data Provider 1"; + private final String topic1 = "test-topic-1"; + + private final Long dataProviderId2 = 202L; + private final String dataProviderName2 = "Test Data Provider 2"; + private final String topic2 = "test-topic-2"; + + @BeforeEach + void setUp() { + // Create test organisation + organisation = new Organisation(); + organisation.setId(orgId); + organisation.setName(orgName); + + // Create test data providers + dataProviders = new ArrayList<>(); + + Product dataProvider1 = new Product(); + dataProvider1.setId(dataProviderId1); + dataProvider1.setName(dataProviderName1); + dataProvider1.setTopic(topic1); + + Product dataProvider2 = new Product(); + dataProvider2.setId(dataProviderId2); + dataProvider2.setName(dataProviderName2); + dataProvider2.setTopic(topic2); + + dataProviders.add(dataProvider1); + dataProviders.add(dataProvider2); + + // Create test entity + entity = new Producer(); + entity.setId(producerId); + entity.setName(producerName); + entity.setDescription(description); + entity.setActive(active); + entity.setHost(host); + entity.setPort(port); + entity.setTls(tls); + entity.setIdpClientId(idpClientId); + entity.setOrg(organisation); + entity.setProducts(dataProviders); + + // Set producer reference in data providers + dataProvider1.setProducer(entity); + dataProvider2.setProducer(entity); + + // Create test data provider DTOs + dataProviderDTOs = new ArrayList<>(); + + ProductDTO dataProviderDTO1 = ProductDTO.builder() + .id(dataProviderId1) + .name(dataProviderName1) + .topic(topic1) + .producerId(producerId) + .build(); + + ProductDTO dataProviderDTO2 = ProductDTO.builder() + .id(dataProviderId2) + .name(dataProviderName2) + .topic(topic2) + .producerId(producerId) + .build(); + + dataProviderDTOs.add(dataProviderDTO1); + dataProviderDTOs.add(dataProviderDTO2); + + // Create test DTO + dto = new ProducerDTO(); + dto.setId(producerId); + dto.setName(producerName); + dto.setDescription(description); + dto.setActive(active); + dto.setHost(host); + dto.setPort(port); + dto.setTls(tls); + dto.setIdpClientId(idpClientId); + dto.setOrgId(orgId); + + // Add data provider DTOs to the producer DTO + dto.getProducts().addAll(dataProviderDTOs); + + // Set up mock behavior for productConverter + lenient().when(productConverter.toDto(dataProvider1)).thenReturn(dataProviderDTO1); + lenient().when(productConverter.toDto(dataProvider2)).thenReturn(dataProviderDTO2); + lenient().when(productConverter.toEntity(dataProviderDTO1)).thenReturn(dataProvider1); + lenient().when(productConverter.toEntity(dataProviderDTO2)).thenReturn(dataProvider2); + } + + @Test + void toDto_withNullEntity_shouldReturnNull() { + // Act + ProducerDTO result = converter.toDto(null); + + // Assert + assertNull(result); + } + + @Test + void toDto_withValidEntity_shouldReturnCorrectDTO() { + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertEquals(orgId, result.getOrgId()); + + // Verify dataProviders mapping + assertNotNull(result.getProducts()); + assertEquals(2, result.getProducts().size()); + + // Verify first data provider + ProductDTO productDTO1 = result.getProducts().get(0); + assertEquals(dataProviderId1, productDTO1.getId()); + assertEquals(dataProviderName1, productDTO1.getName()); + assertEquals(topic1, productDTO1.getTopic()); + assertEquals(producerId, productDTO1.getProducerId()); + + // Verify second data provider + ProductDTO dataProviderDTO2 = result.getProducts().get(1); + assertEquals(dataProviderId2, dataProviderDTO2.getId()); + assertEquals(dataProviderName2, dataProviderDTO2.getName()); + assertEquals(topic2, dataProviderDTO2.getTopic()); + assertEquals(producerId, dataProviderDTO2.getProducerId()); + + // Verify productConverter was called for each data provider + verify(productConverter, times(1)).toDto(dataProviders.get(0)); + verify(productConverter, times(1)).toDto(dataProviders.get(1)); + } + + @Test + void toDto_withNullOrg_shouldReturnDTOWithNullOrgId() { + // Arrange + entity.setOrg(null); + + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrgId()); + } + + @Test + void toDto_withNullProducts_shouldReturnDTOWithEmptyDataProviders() { + // Arrange + entity.setProducts(null); + + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertTrue(result.getProducts().isEmpty()); + + // Verify productConverter was not called + verify(productConverter, never()).toDto(any()); + } + + @Test + void toDto_withEmptyProducts_shouldReturnDTOWithEmptyDataProviders() { + // Arrange + entity.setProducts(new ArrayList<>()); + + // Act + ProducerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertTrue(result.getProducts().isEmpty()); + + // Verify productConverter was not called + verify(productConverter, never()).toDto(any()); + } + + @Test + void toEntity_withNullDTO_shouldReturnNull() { + // Act + Producer result = converter.toEntity(null); + + // Assert + assertNull(result); + } + + @Test + void toEntity_withValidDTO_shouldReturnCorrectEntity() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNotNull(result.getOrg()); + assertEquals(orgId, result.getOrg().getId()); + assertEquals(orgName, result.getOrg().getName()); + + // Verify dataProviders mapping + assertNotNull(result.getProducts()); + assertEquals(2, result.getProducts().size()); + + // Verify first data provider + Product dataProvider1 = result.getProducts().get(0); + assertEquals(dataProviderId1, dataProvider1.getId()); + assertEquals(dataProviderName1, dataProvider1.getName()); + assertEquals(topic1, dataProvider1.getTopic()); + assertNotNull(dataProvider1.getProducer()); + assertEquals(result, dataProvider1.getProducer()); + + // Verify second data provider + Product dataProvider2 = result.getProducts().get(1); + assertEquals(dataProviderId2, dataProvider2.getId()); + assertEquals(dataProviderName2, dataProvider2.getName()); + assertEquals(topic2, dataProvider2.getTopic()); + assertNotNull(dataProvider2.getProducer()); + assertEquals(result, dataProvider2.getProducer()); + + // Verify productConverter was called for each data provider DTO + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); + + // Verify organisation repository was called + verify(organisationRepository, times(1)).findById(orgId); + } + + @Test + void toEntity_withNullOrgId_shouldReturnEntityWithNullOrg() { + // Arrange + dto.setOrgId(null); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrg()); + + // Verify + verify(organisationRepository, never()).findById(any()); + } + + @Test + void toEntity_withNonExistentOrgId_shouldReturnEntityWithNullOrg() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.empty()); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(producerId, result.getId()); + assertEquals(producerName, result.getName()); + assertEquals(description, result.getDescription()); + assertEquals(active, result.getActive()); + assertEquals(host, result.getHost()); + assertEquals(port, result.getPort()); + assertEquals(tls, result.getTls()); + assertEquals(idpClientId, result.getIdpClientId()); + assertNull(result.getOrg()); + + // Verify + verify(organisationRepository, times(1)).findById(orgId); + } + + @Test + void toEntity_withEmptyDataProviders_shouldReturnEntityWithEmptyProducts() { + // Arrange + dto.getProducts().clear(); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNull(result.getProducts()); + + // Verify productConverter was not called + verify(productConverter, never()).toEntity(any()); + } + + @Test + void toEntity_withNullProducerId_shouldSetProducerIdInDataProviderDTO() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + + // Set producerId to null in data provider DTOs + dataProviderDTOs.get(0).setProducerId(null); + dataProviderDTOs.get(1).setProducerId(null); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertEquals(2, result.getProducts().size()); + + // Verify producerId was set in data provider DTOs + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(0)); + verify(productConverter, times(1)).toEntity(dataProviderDTOs.get(1)); + + // Verify producerId was set in data provider DTOs + assertEquals(producerId, dataProviderDTOs.get(0).getProducerId()); + assertEquals(producerId, dataProviderDTOs.get(1).getProducerId()); + } + + @Test + void toEntity_withNullDataProviderFromConverter_shouldNotAddToProducts() { + // Arrange + when(organisationRepository.findById(orgId)).thenReturn(Optional.of(organisation)); + when(productConverter.toEntity(dataProviderDTOs.get(1))).thenReturn(null); + + // Act + Producer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNotNull(result.getProducts()); + assertEquals(1, result.getProducts().size()); + + // Verify only one data provider was added + assertEquals(dataProviderId1, result.getProducts().get(0).getId()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverterTest.java new file mode 100644 index 0000000..aa02628 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConsumerConverterTest.java @@ -0,0 +1,120 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import static org.junit.jupiter.api.Assertions.*; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +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.junit.jupiter.MockitoExtension; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumerAttribute; + +@ExtendWith(MockitoExtension.class) +class ProductConsumerConverterTest { + + @InjectMocks + private ProductConsumerConverter converter; + + private ProductConsumer entity; + private ProductConsumerDTO dto; + private final Long consumerId = 1L; + private final Long dataProviderId = 101L; + private final Timestamp grantedTs = Timestamp.from(Instant.now()); + private final BigDecimal validity = new BigDecimal("365"); + + @BeforeEach + void setUp() { + // Create test entity + entity = new ProductConsumer(); + Consumer consumer = new Consumer(); + consumer.setId(consumerId); + Product product = new Product(); + product.setId(dataProviderId); + entity.setConsumer(consumer); + entity.setProduct(product); + entity.setGrantedTs(grantedTs); + entity.setValidity(validity); + + // Create test DTO + dto = new ProductConsumerDTO(); + dto.setConsumerId(consumerId); + dto.setProductId(dataProviderId); + dto.setGrantedTs(grantedTs); + dto.setValidity(validity); + } + + @Test + void toDto_withNullEntity_shouldReturnNull() { + // Act + ProductConsumerDTO result = converter.toDto(null); + + // Assert + assertNull(result); + } + + @Test + void toDto_withValidEntity_shouldReturnCorrectDTO() { + // Add attributes to entity + ProductConsumerAttribute attr = new ProductConsumerAttribute(); + attr.setName("classification"); + attr.setType("string"); + attr.setValue("public"); + List attrs = new ArrayList<>(); + attrs.add(attr); + entity.setProductConsumerAttributes(attrs); + + // Act + ProductConsumerDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(consumerId, result.getConsumerId()); + assertEquals(dataProviderId, result.getProductId()); + assertEquals(grantedTs, result.getGrantedTs()); + assertEquals(validity, result.getValidity()); + assertNotNull(result.getAttributes()); + assertEquals(1, result.getAttributes().size()); + assertEquals("classification", result.getAttributes().get(0).getName()); + assertEquals("string", result.getAttributes().get(0).getType()); + assertEquals("public", result.getAttributes().get(0).getValue()); + } + + @Test + void toEntity_withNullDTO_shouldReturnNull() { + // Act + ProductConsumer result = converter.toEntity(null); + + // Assert + assertNull(result); + } + + @Test + void toEntity_withValidDTO_shouldReturnCorrectEntity() { + // Act + ProductConsumer result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertNotNull(result.getConsumer()); + assertNotNull(result.getProduct()); + assertEquals(consumerId, result.getConsumer().getId()); + assertEquals(dataProviderId, result.getProduct().getId()); + assertEquals(grantedTs, result.getGrantedTs()); + assertEquals(validity, result.getValidity()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverterTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverterTest.java new file mode 100644 index 0000000..6e1b9d6 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/converter/impl/ProductConverterTest.java @@ -0,0 +1,170 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.converter.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; +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 uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProducerRepository; + +@ExtendWith(MockitoExtension.class) +class ProductConverterTest { + + @Mock + private ProducerRepository producerRepository; + + @InjectMocks + private ProductConverter converter; + + private Product entity; + private ProductDTO dto; + private Producer producer; + + private final Long dataProviderId = 1L; + private final String dataProviderName = "Test Data Provider"; + private final String topic = "test-topic"; + private final Long producerId = 101L; + private final String producerName = "Test Producer"; + + @BeforeEach + void setUp() { + // Create test producer + producer = new Producer(); + producer.setId(producerId); + producer.setName(producerName); + + // Create test entity + entity = new Product(); + entity.setId(dataProviderId); + entity.setName(dataProviderName); + entity.setTopic(topic); + entity.setProducer(producer); + + // Create test DTO + dto = new ProductDTO(); + dto.setId(dataProviderId); + dto.setName(dataProviderName); + dto.setTopic(topic); + dto.setProducerId(producerId); + } + + @Test + void toDto_withNullEntity_shouldReturnNull() { + // Act + ProductDTO result = converter.toDto(null); + + // Assert + assertNull(result); + } + + @Test + void toDto_withValidEntity_shouldReturnCorrectDTO() { + // Act + ProductDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(dataProviderId, result.getId()); + assertEquals(dataProviderName, result.getName()); + assertEquals(topic, result.getTopic()); + assertEquals(producerId, result.getProducerId()); + } + + @Test + void toDto_withNullProducer_shouldReturnDTOWithNullProducerId() { + // Arrange + entity.setProducer(null); + + // Act + ProductDTO result = converter.toDto(entity); + + // Assert + assertNotNull(result); + assertEquals(dataProviderId, result.getId()); + assertEquals(dataProviderName, result.getName()); + assertEquals(topic, result.getTopic()); + assertNull(result.getProducerId()); + } + + @Test + void toEntity_withNullDTO_shouldReturnNull() { + // Act + Product result = converter.toEntity(null); + + // Assert + assertNull(result); + } + + @Test + void toEntity_withValidDTO_shouldReturnCorrectEntity() { + // Arrange + when(producerRepository.findById(producerId)).thenReturn(Optional.of(producer)); + + // Act + Product result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(dataProviderId, result.getId()); + assertEquals(dataProviderName, result.getName()); + assertEquals(topic, result.getTopic()); + assertNotNull(result.getProducer()); + assertEquals(producerId, result.getProducer().getId()); + assertEquals(producerName, result.getProducer().getName()); + + // Verify + verify(producerRepository, times(1)).findById(producerId); + } + + @Test + void toEntity_withNullProducerId_shouldReturnEntityWithNullProducer() { + // Arrange + dto.setProducerId(null); + + // Act + Product result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(dataProviderId, result.getId()); + assertEquals(dataProviderName, result.getName()); + assertEquals(topic, result.getTopic()); + assertNull(result.getProducer()); + + // Verify + verify(producerRepository, never()).findById(any()); + } + + @Test + void toEntity_withNonExistentProducerId_shouldReturnEntityWithNullProducer() { + // Arrange + when(producerRepository.findById(producerId)).thenReturn(Optional.empty()); + + // Act + Product result = converter.toEntity(dto); + + // Assert + assertNotNull(result); + assertEquals(dataProviderId, result.getId()); + assertEquals(dataProviderName, result.getName()); + assertEquals(topic, result.getTopic()); + assertNull(result.getProducer()); + + // Verify + verify(producerRepository, times(1)).findById(producerId); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/AuthenticationProcessingExceptionTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/AuthenticationProcessingExceptionTest.java new file mode 100644 index 0000000..eaf392b --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/AuthenticationProcessingExceptionTest.java @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +class AuthenticationProcessingExceptionTest { + + private static final String CLIENT_ID = "test-client"; + private static final String MESSAGE = "Test exception message"; + private static final Exception CAUSE = new RuntimeException("Test cause"); + + @Test + void constructor_withMessageAndClientId_shouldIncludeClientIdInMessage() { + // Act + AuthenticationProcessingException exception = new AuthenticationProcessingException(MESSAGE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + } + + @Test + void constructor_withMessageCauseAndClientId_shouldIncludeClientIdInMessage() { + // Act + AuthenticationProcessingException exception = new AuthenticationProcessingException(MESSAGE, CAUSE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertEquals(CAUSE, exception.getCause()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/SpecificExceptionsTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/SpecificExceptionsTest.java new file mode 100644 index 0000000..bd17993 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/SpecificExceptionsTest.java @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Tests for the specific exception types that extend AuthenticationProcessingException. + */ +class SpecificExceptionsTest { + + private static final String CLIENT_ID = "test-client"; + private static final String MESSAGE = "Test exception message"; + private static final Exception CAUSE = new RuntimeException("Test cause"); + + @Test + void resourceAccessParsingException_withMessageAndClientId_shouldIncludeClientIdInMessage() { + // Act + ResourceAccessParsingException exception = new ResourceAccessParsingException(MESSAGE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertTrue(exception instanceof AuthenticationProcessingException); + } + + @Test + void resourceAccessParsingException_withMessageCauseAndClientId_shouldIncludeClientIdInMessage() { + // Act + ResourceAccessParsingException exception = new ResourceAccessParsingException(MESSAGE, CAUSE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertEquals(CAUSE, exception.getCause()); + assertTrue(exception instanceof AuthenticationProcessingException); + } + + @Test + void jwtClaimParsingException_withMessageAndClientId_shouldIncludeClientIdInMessage() { + // Act + JwtClaimParsingException exception = new JwtClaimParsingException(MESSAGE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertTrue(exception instanceof AuthenticationProcessingException); + } + + @Test + void jwtClaimParsingException_withMessageCauseAndClientId_shouldIncludeClientIdInMessage() { + // Act + JwtClaimParsingException exception = new JwtClaimParsingException(MESSAGE, CAUSE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertEquals(CAUSE, exception.getCause()); + assertTrue(exception instanceof AuthenticationProcessingException); + } + + @Test + void tokenIntrospectionException_withMessageAndClientId_shouldIncludeClientIdInMessage() { + // Act + TokenIntrospectionException exception = new TokenIntrospectionException(MESSAGE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertTrue(exception instanceof AuthenticationProcessingException); + } + + @Test + void tokenIntrospectionException_withMessageCauseAndClientId_shouldIncludeClientIdInMessage() { + // Act + TokenIntrospectionException exception = new TokenIntrospectionException(MESSAGE, CAUSE, CLIENT_ID); + + // Assert + assertTrue(exception.getMessage().contains(MESSAGE)); + assertTrue(exception.getMessage().contains(CLIENT_ID)); + assertEquals(CLIENT_ID, exception.getClientId()); + assertEquals(CAUSE, exception.getCause()); + assertTrue(exception instanceof AuthenticationProcessingException); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java new file mode 100644 index 0000000..22e4789 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/exception/handlers/GlobalExceptionHandlerTest.java @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.exception.handlers; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.context.request.WebRequest; +import uk.gov.dbt.ndtp.ia.node.management.exception.AuthenticationProcessingException; +import uk.gov.dbt.ndtp.ia.node.management.exception.ErrorResponse; +import uk.gov.dbt.ndtp.ia.node.management.exception.JwtClaimParsingException; + +/** + * Tests for the GlobalExceptionHandler class. + * Verifies that each exception handler method returns the correct HTTP status code + * and ErrorResponse object with appropriate values. + */ +class GlobalExceptionHandlerTest { + + private GlobalExceptionHandler exceptionHandler; + + @Mock + private WebRequest webRequest; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + exceptionHandler = new GlobalExceptionHandler(); + } + + @Test + void handleAuthenticationProcessingException_shouldReturnUnauthorizedStatus() { + // Arrange + String clientId = "test-client"; + String message = "Authentication failed"; + AuthenticationProcessingException exception = new AuthenticationProcessingException(message, clientId); + + // Act + ResponseEntity response = + exceptionHandler.handleAuthenticationProcessingException(exception, webRequest); + + // Assert + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.UNAUTHORIZED.value(), errorResponse.getStatus()); + assertTrue(errorResponse.getMessage().contains(message)); + assertNotNull(errorResponse.getErrorId()); + } + + @Test + void handleAuthenticationProcessingException_withSubclass_shouldReturnUnauthorizedStatus() { + // Arrange + String clientId = "test-client"; + String message = "JWT claim parsing failed"; + JwtClaimParsingException exception = new JwtClaimParsingException(message, clientId); + + // Act + ResponseEntity response = + exceptionHandler.handleAuthenticationProcessingException(exception, webRequest); + + // Assert + assertEquals(HttpStatus.UNAUTHORIZED, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.UNAUTHORIZED.value(), errorResponse.getStatus()); + assertTrue(errorResponse.getMessage().contains(message)); + assertNotNull(errorResponse.getErrorId()); + } + + @Test + void handleRuntimeException_shouldReturnInternalServerErrorStatus() { + // Arrange + String message = "Something went wrong"; + RuntimeException exception = new RuntimeException(message); + + // Act + ResponseEntity response = exceptionHandler.handleRuntimeException(exception, webRequest); + + // Assert + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), errorResponse.getStatus()); + assertEquals("An internal server error occurred", errorResponse.getMessage()); + assertNotNull(errorResponse.getErrorId()); + } + + @Test + void handleAllExceptions_shouldReturnInternalServerErrorStatus() { + // Arrange + String message = "Generic exception"; + Exception exception = new Exception(message); + + // Act + ResponseEntity response = exceptionHandler.handleAllExceptions(exception, webRequest); + + // Assert + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + ErrorResponse errorResponse = response.getBody(); + assertNotNull(errorResponse); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR.value(), errorResponse.getStatus()); + assertEquals("An unexpected error occurred", errorResponse.getMessage()); + assertNotNull(errorResponse.getErrorId()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java new file mode 100644 index 0000000..9f2c238 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerProviderOrganisationServiceImplTest.java @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +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 uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductConsumerRepository; + +@ExtendWith(MockitoExtension.class) +public class ConsumerProviderOrganisationServiceImplTest { + + @Mock + private ProductConsumerRepository productConsumerRepository; + + @Mock + private ProductConsumerConverter productConsumerConverter; + + @InjectMocks + private ProductConsumerServiceImpl consumerAllowedDataProviderService; + + private ProductConsumer entity1; + private ProductConsumer entity2; + private ProductConsumerDTO dto1; + private ProductConsumerDTO dto2; + private final Long consumerId = 1L; + + @BeforeEach + void setUp() { + // Create test entities + entity1 = new ProductConsumer(); + Consumer consumer = new Consumer(); + consumer.setId(consumerId); + Product product1 = new Product(); + product1.setId(101L); + entity1.setConsumer(consumer); + entity1.setProduct(product1); + entity1.setGrantedTs(Timestamp.from(Instant.now())); + entity1.setValidity(new BigDecimal("365")); + + entity2 = new ProductConsumer(); + Product product2 = new Product(); + product2.setId(102L); + entity2.setConsumer(consumer); + entity2.setProduct(product2); + entity2.setGrantedTs(Timestamp.from(Instant.now())); + entity2.setValidity(new BigDecimal("180")); + + // Create test DTOs + dto1 = new ProductConsumerDTO(); + dto1.setConsumerId(consumerId); + dto1.setProductId(101L); + dto1.setGrantedTs(entity1.getGrantedTs()); + dto1.setValidity(entity1.getValidity()); + + dto2 = new ProductConsumerDTO(); + dto2.setConsumerId(consumerId); + dto2.setProductId(102L); + dto2.setGrantedTs(entity2.getGrantedTs()); + dto2.setValidity(entity2.getValidity()); + } + + @Test + void findByConsumerId_shouldReturnDTOList() { + // Arrange + List entities = Arrays.asList(entity1, entity2); + List dtos = Arrays.asList(dto1, dto2); + when(productConsumerRepository.findByConsumerId(consumerId)).thenReturn(entities); + when(productConsumerConverter.toDtoList(entities)).thenReturn(dtos); + + // Act + List result = consumerAllowedDataProviderService.findByConsumerId(consumerId); + + // Assert + assertNotNull(result); + assertEquals(2, result.size()); + assertEquals(dto1.getConsumerId(), result.get(0).getConsumerId()); + assertEquals(dto1.getProductId(), result.get(0).getProductId()); + assertEquals(dto1.getGrantedTs(), result.get(0).getGrantedTs()); + assertEquals(dto1.getValidity(), result.get(0).getValidity()); + + assertEquals(dto2.getConsumerId(), result.get(1).getConsumerId()); + assertEquals(dto2.getProductId(), result.get(1).getProductId()); + assertEquals(dto2.getGrantedTs(), result.get(1).getGrantedTs()); + assertEquals(dto2.getValidity(), result.get(1).getValidity()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java new file mode 100644 index 0000000..ad89403 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ConsumerServiceImplTest.java @@ -0,0 +1,177 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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 uk.gov.dbt.ndtp.ia.node.management.converter.impl.ConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ConsumerRepository; + +@ExtendWith(MockitoExtension.class) +class ConsumerServiceImplTest { + + @Mock + private ConsumerRepository consumerRepository; + + @Mock + private ConsumerConverter consumerConverter; + + @InjectMocks + private ConsumerServiceImpl consumerService; + + private Consumer consumer; + private ConsumerDTO consumerDTO; + private final Long consumerId = 1L; + private final String idpClientId = "test-client-id"; + + @BeforeEach + void setUp() { + // Set up test data + consumer = new Consumer(); + consumer.setId(consumerId); + consumer.setIdpClientId(idpClientId); + consumer.setName("Test Consumer"); + + consumerDTO = ConsumerDTO.builder() + .id(consumerId) + .idpClientId(idpClientId) + .name("Test Consumer") + .build(); + } + + @Test + void findById_withExistingId_shouldReturnConsumerDTO() { + // Arrange + when(consumerRepository.findById(consumerId)).thenReturn(Optional.of(consumer)); + when(consumerConverter.toDto(consumer)).thenReturn(consumerDTO); + + // Act + Optional result = consumerService.findById(consumerId); + + // Assert + assertTrue(result.isPresent()); + assertEquals(consumerId, result.get().getId()); + assertEquals(idpClientId, result.get().getIdpClientId()); + assertEquals("Test Consumer", result.get().getName()); + + // Verify + verify(consumerRepository).findById(consumerId); + verify(consumerConverter).toDto(consumer); + } + + @Test + void findById_withNonExistingId_shouldReturnEmptyOptional() { + // Arrange + when(consumerRepository.findById(consumerId)).thenReturn(Optional.empty()); + + // Act + Optional result = consumerService.findById(consumerId); + + // Assert + assertFalse(result.isPresent()); + + // Verify + verify(consumerRepository).findById(consumerId); + verify(consumerConverter, never()).toDto(any()); + } + + @Test + void findByIdpClientId_withExistingClientId_shouldReturnListOfConsumerDTOs() { + // Arrange + List consumers = List.of(consumer); + List consumerDTOs = List.of(consumerDTO); + + when(consumerRepository.findByIdpClientId(idpClientId)).thenReturn(consumers); + when(consumerConverter.toDtoList(consumers)).thenReturn(consumerDTOs); + + // Act + List result = consumerService.findByIdpClientId(idpClientId); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(consumerId, result.get(0).getId()); + assertEquals(idpClientId, result.get(0).getIdpClientId()); + + // Verify + verify(consumerRepository).findByIdpClientId(idpClientId); + verify(consumerConverter).toDtoList(consumers); + } + + @Test + void findByIdpClientId_withNonExistingClientId_shouldReturnEmptyList() { + // Arrange + when(consumerRepository.findByIdpClientId(idpClientId)).thenReturn(Collections.emptyList()); + when(consumerConverter.toDtoList(Collections.emptyList())).thenReturn(Collections.emptyList()); + + // Act + List result = consumerService.findByIdpClientId(idpClientId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(consumerRepository).findByIdpClientId(idpClientId); + verify(consumerConverter).toDtoList(Collections.emptyList()); + } + + @Test + void getConsumersOfProviders_withValidProviderIds_shouldReturnMappedConsumers() { + // Arrange + List providerIds = List.of(1L, 2L); + List consumers = List.of(consumer); + List consumerDTOs = List.of(consumerDTO); + + when(consumerRepository.findConsumersByProviderIds(providerIds)).thenReturn(consumers); + when(consumerConverter.toDto(consumer)).thenReturn(consumerDTO); + + // Act + Map> result = consumerService.getConsumersOfProviders(providerIds); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertTrue(result.containsKey(idpClientId)); + assertEquals(1, result.get(idpClientId).size()); + assertEquals(consumerId, result.get(idpClientId).get(0).getId()); + + // Verify + verify(consumerRepository).findConsumersByProviderIds(providerIds); + verify(consumerConverter).toDto(consumer); + } + + @Test + void getConsumersOfProviders_withEmptyProviderIds_shouldReturnEmptyMap() { + // Arrange + List emptyProviderIds = Collections.emptyList(); + when(consumerRepository.findConsumersByProviderIds(emptyProviderIds)).thenReturn(Collections.emptyList()); + + // Act + Map> result = consumerService.getConsumersOfProviders(emptyProviderIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(consumerRepository).findConsumersByProviderIds(emptyProviderIds); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java new file mode 100644 index 0000000..99ffb52 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/OrganisationServiceImplTest.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +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 uk.gov.dbt.ndtp.ia.node.management.persistency.repository.OrganisationRepository; + +@ExtendWith(MockitoExtension.class) +class OrganisationServiceImplTest { + + @Mock + private OrganisationRepository organisationRepository; + + @InjectMocks + private OrganisationServiceImpl organisationService; + + @BeforeEach + void setUp() { + // No setup needed as the service has no methods to test yet + } + + @Test + void organisationService_shouldBeInitialized() { + // This test verifies that the service is properly initialized with its dependencies + assertNotNull(organisationService); + assertNotNull(organisationRepository); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProducerServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProducerServiceImplTest.java new file mode 100644 index 0000000..5470f34 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProducerServiceImplTest.java @@ -0,0 +1,153 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +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 uk.gov.dbt.ndtp.ia.node.management.converter.impl.OrganisationProducerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProducerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProducerRepository; + +@ExtendWith(MockitoExtension.class) +class ProducerServiceImplTest { + + @Mock + private ProducerRepository producerRepository; + + @Mock + private OrganisationProducerConverter organisationProducerConverter; + + @InjectMocks + private ProducerServiceImpl producerService; + + private Producer producer; + private ProducerDTO producerDTO; + private final Long producerId = 1L; + private final String clientId = "test-client-id"; + + @BeforeEach + void setUp() { + // Set up test data + producer = new Producer(); + producer.setId(producerId); + producer.setIdpClientId(clientId); + producer.setName("Test Producer"); + producer.setActive(true); + + producerDTO = ProducerDTO.builder() + .id(producerId) + .idpClientId(clientId) + .name("Test Producer") + .active(true) + .build(); + } + + @Test + void getProducersByConsumerIds_withValidIds_shouldReturnProducerDTOs() { + // Arrange + List consumerIds = List.of(producerId); + List producers = List.of(producer); + List producerDTOs = List.of(producerDTO); + + when(producerRepository.findByConsumerIds(consumerIds)).thenReturn(producers); + when(organisationProducerConverter.toDtoList(producers)).thenReturn(producerDTOs); + + // Act + List result = producerService.getProducersByConsumerIds(consumerIds); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(producerId, result.getFirst().getId()); + assertEquals(clientId, result.getFirst().getIdpClientId()); + assertEquals("Test Producer", result.getFirst().getName()); + assertEquals(true, result.getFirst().getActive()); + + // Verify + verify(producerRepository).findByConsumerIds(consumerIds); + verify(organisationProducerConverter).toDtoList(producers); + } + + @Test + void getProducersByConsumerIds_withEmptyIds_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + List emptyProducers = Collections.emptyList(); + List emptyDTOs = Collections.emptyList(); + + when(producerRepository.findByConsumerIds(emptyIds)).thenReturn(emptyProducers); + when(organisationProducerConverter.toDtoList(emptyProducers)).thenReturn(emptyDTOs); + + // Act + List result = producerService.getProducersByConsumerIds(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(producerRepository).findByConsumerIds(emptyIds); + verify(organisationProducerConverter).toDtoList(emptyProducers); + } + + @Test + void getProducersByClientId_withValidClientId_shouldReturnProducerDTOs() { + // Arrange + List producers = List.of(producer); + List producerDTOs = List.of(producerDTO); + + when(producerRepository.findByIdpClientId(clientId)).thenReturn(producers); + when(organisationProducerConverter.toDtoList(producers)).thenReturn(producerDTOs); + + // Act + List result = producerService.getProducersByClientId(clientId); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(producerId, result.getFirst().getId()); + assertEquals(clientId, result.getFirst().getIdpClientId()); + assertEquals("Test Producer", result.getFirst().getName()); + assertEquals(true, result.getFirst().getActive()); + + // Verify + verify(producerRepository).findByIdpClientId(clientId); + verify(organisationProducerConverter).toDtoList(producers); + } + + @Test + void getProducersByClientId_withNonExistingClientId_shouldReturnEmptyList() { + // Arrange + String nonExistingClientId = "non-existing-client-id"; + List emptyProducers = Collections.emptyList(); + List emptyDTOs = Collections.emptyList(); + + when(producerRepository.findByIdpClientId(nonExistingClientId)).thenReturn(emptyProducers); + when(organisationProducerConverter.toDtoList(emptyProducers)).thenReturn(emptyDTOs); + + // Act + List result = producerService.getProducersByClientId(nonExistingClientId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(producerRepository).findByIdpClientId(nonExistingClientId); + verify(organisationProducerConverter).toDtoList(emptyProducers); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductConsumerServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductConsumerServiceImplTest.java new file mode 100644 index 0000000..648b6f9 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductConsumerServiceImplTest.java @@ -0,0 +1,157 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.Collections; +import java.util.List; +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 uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConsumerConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductConsumerDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Consumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.ProductConsumer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductConsumerRepository; + +@ExtendWith(MockitoExtension.class) +class ProductConsumerServiceImplTest { + + @Mock + private ProductConsumerRepository productConsumerRepository; + + @Mock + private ProductConsumerConverter productConsumerConverter; + + @InjectMocks + private ProductConsumerServiceImpl productConsumerService; + + private ProductConsumer productConsumer; + private ProductConsumerDTO productConsumerDTO; + private final Long consumerId = 1L; + private final Long productId = 2L; + + @BeforeEach + void setUp() { + // Set up test data + productConsumer = new ProductConsumer(); + Consumer consumer = new Consumer(); + consumer.setId(consumerId); + Product product = new Product(); + product.setId(productId); + productConsumer.setConsumer(consumer); + productConsumer.setProduct(product); + productConsumer.setGrantedTs(Timestamp.from(Instant.now())); + productConsumer.setValidity(BigDecimal.ZERO); + + productConsumerDTO = ProductConsumerDTO.builder() + .consumerId(consumerId) + .productId(productId) + .grantedTs(Timestamp.from(Instant.now())) + .validity(BigDecimal.ZERO) + .build(); + } + + @Test + void findByConsumerId_withValidId_shouldReturnProductConsumerDTOs() { + // Arrange + List productConsumers = List.of(productConsumer); + List productConsumerDTOs = List.of(productConsumerDTO); + + when(productConsumerRepository.findByConsumerId(consumerId)).thenReturn(productConsumers); + when(productConsumerConverter.toDtoList(productConsumers)).thenReturn(productConsumerDTOs); + + // Act + List result = productConsumerService.findByConsumerId(consumerId); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(consumerId, result.get(0).getConsumerId()); + assertEquals(productId, result.get(0).getProductId()); + + // Verify + verify(productConsumerRepository).findByConsumerId(consumerId); + verify(productConsumerConverter).toDtoList(productConsumers); + } + + @Test + void findByConsumerId_withNonExistingId_shouldReturnEmptyList() { + // Arrange + Long nonExistingId = 999L; + List emptyList = Collections.emptyList(); + List emptyDTOList = Collections.emptyList(); + + when(productConsumerRepository.findByConsumerId(nonExistingId)).thenReturn(emptyList); + when(productConsumerConverter.toDtoList(emptyList)).thenReturn(emptyDTOList); + + // Act + List result = productConsumerService.findByConsumerId(nonExistingId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productConsumerRepository).findByConsumerId(nonExistingId); + verify(productConsumerConverter).toDtoList(emptyList); + } + + @Test + void findByDataProviderId_withValidId_shouldReturnProductConsumerDTOs() { + // Arrange + List productConsumers = List.of(productConsumer); + List productConsumerDTOs = List.of(productConsumerDTO); + + when(productConsumerRepository.findByProductId(productId)).thenReturn(productConsumers); + when(productConsumerConverter.toDtoList(productConsumers)).thenReturn(productConsumerDTOs); + + // Act + List result = productConsumerService.findByDataProviderId(productId); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(consumerId, result.get(0).getConsumerId()); + assertEquals(productId, result.get(0).getProductId()); + + // Verify + verify(productConsumerRepository).findByProductId(productId); + verify(productConsumerConverter).toDtoList(productConsumers); + } + + @Test + void findByDataProviderId_withNonExistingId_shouldReturnEmptyList() { + // Arrange + Long nonExistingId = 999L; + List emptyList = Collections.emptyList(); + List emptyDTOList = Collections.emptyList(); + + when(productConsumerRepository.findByProductId(nonExistingId)).thenReturn(emptyList); + when(productConsumerConverter.toDtoList(emptyList)).thenReturn(emptyDTOList); + + // Act + List result = productConsumerService.findByDataProviderId(nonExistingId); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productConsumerRepository).findByProductId(nonExistingId); + verify(productConsumerConverter).toDtoList(emptyList); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java new file mode 100644 index 0000000..3917ed2 --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/data/impl/ProductServiceImplTest.java @@ -0,0 +1,200 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.data.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +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 uk.gov.dbt.ndtp.ia.node.management.converter.impl.ProductConverter; +import uk.gov.dbt.ndtp.ia.node.management.model.dto.ProductDTO; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Producer; +import uk.gov.dbt.ndtp.ia.node.management.persistency.entity.Product; +import uk.gov.dbt.ndtp.ia.node.management.persistency.repository.ProductRepository; + +@ExtendWith(MockitoExtension.class) +class ProductServiceImplTest { + + @Mock + private ProductRepository productRepository; + + @Mock + private ProductConverter productConverter; + + @InjectMocks + private ProductServiceImpl productService; + + private Product product; + private ProductDTO productDTO; + private final Long productId = 1L; + private final Long producerId = 2L; + private final String productName = "Test Product"; + + @BeforeEach + void setUp() { + // Set up test data + Producer producer = new Producer(); + producer.setId(producerId); + producer.setName("Test Producer"); + + product = new Product(); + product.setId(productId); + product.setName(productName); + product.setTopic("test-topic"); + product.setProducer(producer); + + productDTO = ProductDTO.builder() + .id(productId) + .name(productName) + .producerId(producerId) + .build(); + } + + @Test + void getProductsByIds_withValidIds_shouldReturnProductDTOs() { + // Arrange + List productIds = List.of(productId); + List products = List.of(product); + List productDTOs = List.of(productDTO); + + when(productRepository.findByIds(productIds)).thenReturn(products); + when(productConverter.toDtoList(products)).thenReturn(productDTOs); + + // Act + List result = productService.getProductsByIds(productIds); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(productId, result.get(0).getId()); + assertEquals(productName, result.get(0).getName()); + assertEquals(producerId, result.get(0).getProducerId()); + + // Verify + verify(productRepository).findByIds(productIds); + verify(productConverter).toDtoList(products); + } + + @Test + void getProductsByIds_withEmptyIds_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + + // Act + List result = productService.getProductsByIds(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productRepository, never()).findByIds(any()); + } + + @Test + void getProductsByIds_withNullIds_shouldReturnEmptyList() { + // Act + List result = productService.getProductsByIds(null); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productRepository, never()).findByIds(any()); + } + + @Test + void getProductsByIds_withNullRepositoryResult_shouldReturnEmptyList() { + // Arrange + List productIds = List.of(productId); + when(productRepository.findByIds(productIds)).thenReturn(null); + + // Act + List result = productService.getProductsByIds(productIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productRepository).findByIds(productIds); + verify(productConverter, never()).toDtoList(any()); + } + + @Test + void getProductsByProducerIds_withValidIds_shouldReturnProductDTOs() { + // Arrange + List producerIds = List.of(producerId); + List products = List.of(product); + List productDTOs = List.of(productDTO); + + when(productRepository.findByProducerIds(producerIds)).thenReturn(products); + when(productConverter.toDtoList(products)).thenReturn(productDTOs); + + // Act + List result = productService.getProductsByProducerIds(producerIds); + + // Assert + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(productId, result.get(0).getId()); + assertEquals(productName, result.get(0).getName()); + assertEquals(producerId, result.get(0).getProducerId()); + + // Verify + verify(productRepository).findByProducerIds(producerIds); + verify(productConverter).toDtoList(products); + } + + @Test + void getProductsByProducerIds_withEmptyIds_shouldReturnEmptyList() { + // Arrange + List emptyIds = Collections.emptyList(); + List emptyProducts = Collections.emptyList(); + List emptyDTOs = Collections.emptyList(); + + when(productRepository.findByProducerIds(emptyIds)).thenReturn(emptyProducts); + when(productConverter.toDtoList(emptyProducts)).thenReturn(emptyDTOs); + + // Act + List result = productService.getProductsByProducerIds(emptyIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productRepository).findByProducerIds(emptyIds); + verify(productConverter).toDtoList(emptyProducts); + } + + @Test + void getProductsByProducerIds_withNullRepositoryResult_shouldReturnEmptyList() { + // Arrange + List producerIds = List.of(producerId); + when(productRepository.findByProducerIds(producerIds)).thenReturn(null); + + // Act + List result = productService.getProductsByProducerIds(producerIds); + + // Assert + assertNotNull(result); + assertTrue(result.isEmpty()); + + // Verify + verify(productRepository).findByProducerIds(producerIds); + verify(productConverter, never()).toDtoList(any()); + } +} diff --git a/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java new file mode 100644 index 0000000..1f72d7c --- /dev/null +++ b/src/test/java/uk/gov/dbt/ndtp/ia/node/management/service/providers/configuration/ConfigurationProviderImplTest.java @@ -0,0 +1,506 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * © Crown Copyright 2025. This work has been developed by the National Digital Twin Programme and is legally + * attributed to the Department for Business and Trade (UK) as the governing entity. + */ + +package uk.gov.dbt.ndtp.ia.node.management.service.providers.configuration; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.math.BigDecimal; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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 uk.gov.dbt.ndtp.ia.node.management.model.dto.*; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ConsumerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProducerService; +import uk.gov.dbt.ndtp.ia.node.management.service.data.ProductConsumerService; + +@ExtendWith(MockitoExtension.class) +class ConfigurationProviderImplTest { + + @Mock + private ConsumerService consumerService; + + @Mock + private ProductConsumerService consumerAllowedDataProvidersService; + + @Mock + private ProducerService producerService; + + @InjectMocks + private ConfigurationProviderImpl configurationProvider; + + private final String clientId = "test-client-id"; + private final Long consumerId = 1L; + private final Long producerId = 2L; + private final Long productId = 3L; + + private ConsumerDTO consumerDTO; + private ProducerDTO producerDTO; + private ProductDTO productDTO; + private ProductConsumerDTO productConsumerDTO; + + @BeforeEach + void setUp() { + // Set up consumer + consumerDTO = ConsumerDTO.builder() + .id(consumerId) + .name("Test Consumer") + .idpClientId(clientId) + .build(); + + // Set up producer + producerDTO = ProducerDTO.builder() + .id(producerId) + .name("Test Producer") + .idpClientId(clientId) + .active(true) + .build(); + + // Set up product + productDTO = ProductDTO.builder() + .id(productId) + .name("Test Product") + .producerId(producerId) + .consumers(new ArrayList<>()) + .build(); + + // Set up product consumer relationship + productConsumerDTO = ProductConsumerDTO.builder() + .consumerId(consumerId) + .productId(productId) + .validity(null) // No validity constraint + .build(); + } + + // Tests for getConsumerConfigByClientId + + @Test + void getConsumerConfigByClientId_withValidClientIdAndNoConsumerId_shouldReturnConfig() { + // Arrange + List consumers = List.of(consumerDTO); + List producers = List.of(producerDTO); + + when(consumerService.findByIdpClientId(clientId)).thenReturn(consumers); + when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(producers); + + // Act + ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertEquals(producerId, result.getProducers().getFirst().getId()); + + // Verify + verify(consumerService).findByIdpClientId(clientId); + verify(producerService).getProducersByConsumerIds(List.of(consumerId)); + verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); + } + + @Test + void getConsumerConfigByClientId_withValidClientIdAndConsumerId_shouldReturnFilteredConfig() { + // Arrange + List allConsumers = List.of(consumerDTO); + List producers = List.of(producerDTO); + + when(consumerService.findByIdpClientId(clientId)).thenReturn(allConsumers); + when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(producers); + + // Act + ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(consumerId)); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertEquals(producerId, result.getProducers().getFirst().getId()); + + // Verify + verify(consumerService).findByIdpClientId(clientId); + verify(producerService).getProducersByConsumerIds(List.of(consumerId)); + verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); + } + + @Test + void getConsumerConfigByClientId_withNoMatchingConsumers_shouldReturnEmptyConfig() { + // Arrange + when(consumerService.findByIdpClientId(clientId)).thenReturn(Collections.emptyList()); + when(producerService.getProducersByConsumerIds(Collections.emptyList())).thenReturn(Collections.emptyList()); + + // Act + ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertTrue(result.getProducers().isEmpty()); + + // Verify + verify(consumerService).findByIdpClientId(clientId); + verify(producerService).getProducersByConsumerIds(Collections.emptyList()); + verifyNoInteractions(consumerAllowedDataProvidersService); + } + + @Test + void getConsumerConfigByClientId_withNoMatchingConsumerForSpecificId_shouldReturnEmptyConfig() { + // Arrange + ConsumerDTO differentConsumer = + ConsumerDTO.builder().id(999L).idpClientId(clientId).build(); + + when(consumerService.findByIdpClientId(clientId)).thenReturn(List.of(differentConsumer)); + when(producerService.getProducersByConsumerIds(Collections.emptyList())).thenReturn(Collections.emptyList()); + + // Act + ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.of(consumerId)); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertTrue(result.getProducers().isEmpty()); + + // Verify + verify(consumerService).findByIdpClientId(clientId); + verify(producerService).getProducersByConsumerIds(Collections.emptyList()); + verifyNoInteractions(consumerAllowedDataProvidersService); + } + + @Test + void getConsumerConfigByClientId_withNoActiveProducers_shouldReturnEmptyConfig() { + // Arrange + List consumers = List.of(consumerDTO); + + // Create inactive producer + ProducerDTO inactiveProducer = + ProducerDTO.builder().id(producerId).active(false).build(); + + when(consumerService.findByIdpClientId(clientId)).thenReturn(consumers); + when(producerService.getProducersByConsumerIds(List.of(consumerId))).thenReturn(List.of(inactiveProducer)); + + // Act + ConsumerConfigDTO result = configurationProvider.getConsumerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertTrue(result.getProducers().isEmpty()); + + // Verify + verify(consumerService).findByIdpClientId(clientId); + verify(producerService).getProducersByConsumerIds(List.of(consumerId)); + verify(consumerAllowedDataProvidersService).findByConsumerId(consumerId); + } + + // Tests for getProducerConfigByClientId + + @Test + void getProducerConfigByClientId_withValidClientIdAndNoProducerId_shouldReturnConfig() { + // Arrange + List producers = List.of(producerDTO); + // Add product to producer's dataProviders list + producerDTO.getProducts().add(productDTO); + + Map> consumersMap = new HashMap<>(); + consumersMap.put(productId.toString(), List.of(consumerDTO)); + + when(producerService.getProducersByClientId(clientId)).thenReturn(producers); + when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); + when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) + .thenReturn(List.of(productConsumerDTO)); + when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertEquals(producerId, result.getProducers().getFirst().getId()); + assertEquals(1, result.getProducers().getFirst().getProducts().size()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(List.of(productId)); + verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); + verify(consumerService).findById(consumerId); + } + + @Test + void getProducerConfigByClientId_withValidClientIdAndProducerId_shouldReturnFilteredConfig() { + // Arrange + List allProducers = List.of(producerDTO); + // Add product to producer's dataProviders list + producerDTO.getProducts().add(productDTO); + + Map> consumersMap = new HashMap<>(); + consumersMap.put(productId.toString(), List.of(consumerDTO)); + + when(producerService.getProducersByClientId(clientId)).thenReturn(allProducers); + when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); + when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) + .thenReturn(List.of(productConsumerDTO)); + when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(producerId)); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertEquals(producerId, result.getProducers().getFirst().getId()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(List.of(productId)); + verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); + verify(consumerService).findById(consumerId); + } + + @Test + void getProducerConfigByClientId_withNoMatchingProducers_shouldReturnEmptyConfig() { + // Arrange + when(producerService.getProducersByClientId(clientId)).thenReturn(Collections.emptyList()); + when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertTrue(result.getProducers().isEmpty()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + } + + @Test + void getProducerConfigByClientId_withNoMatchingProducerForSpecificId_shouldReturnEmptyConfig() { + // Arrange + ProducerDTO differentProducer = ProducerDTO.builder() + .id(999L) + .idpClientId(clientId) + .active(true) + .build(); + + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(differentProducer)); + when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.of(producerId)); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertTrue(result.getProducers().isEmpty()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + } + + @Test + void getProducerConfigByClientId_withNoActiveProducers_shouldReturnEmptyConfig() { + // Arrange + ProducerDTO inactiveProducer = ProducerDTO.builder() + .id(producerId) + .idpClientId(clientId) + .active(false) + .build(); + + when(producerService.getProducersByClientId(clientId)).thenReturn(List.of(inactiveProducer)); + when(consumerService.getConsumersOfProviders(Collections.emptyList())).thenReturn(Collections.emptyMap()); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertTrue(result.getProducers().isEmpty()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(Collections.emptyList()); + } + + @Test + void getProducerConfigByClientId_withNullConsumers_shouldInitializeConsumersList() { + // Arrange + List producers = List.of(producerDTO); + // Add product to producer's dataProviders list with null consumers + productDTO.setConsumers(null); // Null consumers list + producerDTO.getProducts().add(productDTO); + + Map> consumersMap = new HashMap<>(); + consumersMap.put(productId.toString(), List.of(consumerDTO)); + + when(producerService.getProducersByClientId(clientId)).thenReturn(producers); + when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); + when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) + .thenReturn(List.of(productConsumerDTO)); + when(consumerService.findById(consumerId)).thenReturn(Optional.of(consumerDTO)); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertNotNull(result.getProducers().getFirst().getProducts().getFirst().getConsumers()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(List.of(productId)); + verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); + verify(consumerService).findById(consumerId); + } + + @Test + void getProducerConfigByClientId_withExpiredValidity_shouldNotAddConsumer() { + // Arrange + List producers = List.of(producerDTO); + // Add product to producer's dataProviders list + producerDTO.getProducts().add(productDTO); + + // Create expired product consumer relationship + ProductConsumerDTO expiredProductConsumer = ProductConsumerDTO.builder() + .consumerId(consumerId) + .productId(productId) + .validity(BigDecimal.valueOf(30)) // 30 days validity + .grantedTs(Timestamp.from(Instant.now().minus(60, ChronoUnit.DAYS))) // 60 days ago + .build(); + + Map> consumersMap = new HashMap<>(); + consumersMap.put(productId.toString(), List.of(consumerDTO)); + + when(producerService.getProducersByClientId(clientId)).thenReturn(producers); + when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); + when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) + .thenReturn(List.of(expiredProductConsumer)); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertTrue(result.getProducers() + .getFirst() + .getProducts() + .getFirst() + .getConsumers() + .isEmpty()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(List.of(productId)); + verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); + verify(consumerService, never()).findById(any()); + } + + @Test + void getProducerConfigByClientId_withValidityButNoGrantedTs_shouldNotAddConsumer() { + // Arrange + List producers = List.of(producerDTO); + // Add product to producer's dataProviders list + producerDTO.getProducts().add(productDTO); + + // Create product consumer relationship with validity but no grantedTs + ProductConsumerDTO invalidProductConsumer = ProductConsumerDTO.builder() + .consumerId(consumerId) + .productId(productId) + .validity(BigDecimal.valueOf(30)) // 30 days validity + .grantedTs(null) // No granted timestamp + .build(); + + Map> consumersMap = new HashMap<>(); + consumersMap.put(productId.toString(), List.of(consumerDTO)); + + when(producerService.getProducersByClientId(clientId)).thenReturn(producers); + when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); + when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) + .thenReturn(List.of(invalidProductConsumer)); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertTrue(result.getProducers() + .getFirst() + .getProducts() + .getFirst() + .getConsumers() + .isEmpty()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(List.of(productId)); + verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); + verify(consumerService, never()).findById(any()); + } + + @Test + void getProducerConfigByClientId_withConsumerNotFound_shouldNotAddConsumer() { + // Arrange + List producers = List.of(producerDTO); + // Add product to producer's dataProviders list + producerDTO.getProducts().add(productDTO); + + Map> consumersMap = new HashMap<>(); + consumersMap.put(productId.toString(), List.of(consumerDTO)); + + when(producerService.getProducersByClientId(clientId)).thenReturn(producers); + when(consumerService.getConsumersOfProviders(List.of(productId))).thenReturn(consumersMap); + when(consumerAllowedDataProvidersService.findByDataProviderId(productId)) + .thenReturn(List.of(productConsumerDTO)); + when(consumerService.findById(consumerId)).thenReturn(Optional.empty()); + + // Act + ProducerConfigDTO result = configurationProvider.getProducerConfigByClientId(clientId, Optional.empty()); + + // Assert + assertNotNull(result); + assertEquals(clientId, result.getClientId()); + assertEquals(1, result.getProducers().size()); + assertTrue(result.getProducers() + .getFirst() + .getProducts() + .getFirst() + .getConsumers() + .isEmpty()); + + // Verify + verify(producerService).getProducersByClientId(clientId); + verify(consumerService).getConsumersOfProviders(List.of(productId)); + verify(consumerAllowedDataProvidersService).findByDataProviderId(productId); + verify(consumerService).findById(consumerId); + } + + // Tests for isValidProvider method through public methods + +}