From 5bfaa696066d351217b3ce5e683359ed4173cb46 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Wed, 11 Mar 2026 12:40:08 +0100 Subject: [PATCH 01/27] Feature/database (#8) --- .gitignore | 8 + CHANGELOG.md | 4 +- Makefile | 145 ++++++++- README.md | 4 +- api-project-platform/pom.xml | 2 +- api-project-users/pom.xml | 2 +- application.yaml | 42 ++- core/pom.xml | 16 +- .../DevstackApiServiceApplicationTest.java | 20 -- external-service-aap/pom.xml | 2 +- external-service-api/pom.xml | 2 +- external-service-bitbucket/pom.xml | 2 +- external-service-jira/pom.xml | 2 +- external-service-ocp/pom.xml | 2 +- .../pom.xml | 2 +- external-service-uipath/pom.xml | 2 +- external-service-webhookproxy/pom.xml | 2 +- persistence/liquibase.properties.example | 28 ++ persistence/pom.xml | 112 +++++++ .../persistence/entity/ProjectEntity.java | 126 ++++++++ .../repository/ProjectRepository.java | 45 +++ .../db/changelog/db.changelog-master.xml | 25 ++ .../migrations/001_create_projects_table.sql | 46 +++ .../002_create_client_apps_table.sql | 69 +++++ .../PersistenceTestApplication.java | 17 ++ .../PersistenceIntegrationTestConfig.java | 19 ++ .../ProjectRepositoryIntegrationTest.java | 279 ++++++++++++++++++ .../repository/ProjectRepositoryTest.java | 165 +++++++++++ .../resources/application-local.properties | 10 + pom.xml | 7 +- 30 files changed, 1162 insertions(+), 45 deletions(-) delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java create mode 100644 persistence/liquibase.properties.example create mode 100644 persistence/pom.xml create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java create mode 100644 persistence/src/main/resources/db/changelog/db.changelog-master.xml create mode 100644 persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql create mode 100644 persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java create mode 100644 persistence/src/test/resources/application-local.properties diff --git a/.gitignore b/.gitignore index b1179828..f766354f 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,14 @@ dependency-reduced-pom.xml *.swp *.bak *.tmp + +# Persistence module — local database credentials (never commit real passwords) +persistence/liquibase.properties +persistence/.env + +# Legacy: Database module references (migrated to persistence/) +database/liquibase.properties +database/.env *.orig *.rej diff --git a/CHANGELOG.md b/CHANGELOG.md index a01dc467..6c7df06d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Health Monitoring & Observability - **Spring Boot Actuator** health indicators for all external services: +#### Persistance to keep project lifecycle + ### Changed - Updated all external service implementations to extend `ExternalService` interface - Use of lombok.extern.slf4j.Slf4j to remove boilerplate code. @@ -32,7 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Updated Spring Boot version - Added health indicator dependencies across external service modules -## [0.0.2] - 2026-03-03 +## [0.0.3] - 2026-03-03 ### Changed ##### Projects Info Service diff --git a/Makefile b/Makefile index 069095bc..79b44381 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ # Supports building Spring Boot JAR and Native applications # Project variables -PROJECT_NAME := devstack-api-service -VERSION := 0.0.2 +PROJECT_NAME := opendevstack-api-service +VERSION := 0.0.3 JAVA_VERSION := 21 MAIN_CLASS := org.opendevstack.apiservice.core.DevstackApiServiceApplication @@ -44,7 +44,8 @@ YELLOW := \033[1;33m BLUE := \033[0;34m NC := \033[0m # No Color -.PHONY: help clean compile test package jar native docker docker-native run-jar run-native run-docker run-docker-native install verify lint format check-java check-maven check-config +.PHONY: help clean compile test package jar native docker docker-native run-jar run-native run-docker run-docker-native install verify lint format check-java check-maven check-config \ + db-check-env db-validate db-status db-migrate db-rollback db-tag db-port-forward db-docker-build-db # Default target .DEFAULT_GOAL := help @@ -270,6 +271,144 @@ all: jar docker ## Quick start for development quick-start: jar run-jar +# ============================================================================= +# Database targets (Liquibase / PostgreSQL) +# +# Required environment variables (export them or source persistence/.env): +# ODS_API_SERVICE_DB_HOST PostgreSQL hostname or IP +# ODS_API_SERVICE_DB_PORT PostgreSQL port (default: 5432) +# ODS_API_SERVICE_DB_NAME Database name (ods_api_service) +# ODS_API_SERVICE_DB_USER Application user (ods_api_service) +# ODS_API_SERVICE_DB_PASSWORD Application user password +# +# Quick start: +# cp persistence/liquibase.properties.example persistence/.env +# # edit persistence/.env with real values +# source persistence/.env && make db-migrate +# +# Port-forward variables (can be overridden on the command line): +# NAMESPACE Kubernetes/OpenShift namespace where PostgreSQL runs (REQUIRED) +# DB_K8S_SERVICE Name of the PostgreSQL Service in the cluster (default: ods-api-service-postgresql) +# DB_PF_LOCAL_PORT Local port to bind on the developer machine (default: 5432) +# DB_PF_REMOTE_PORT PostgreSQL port exposed by the Service in-cluster (default: 5432) +# +# Example: +# make db-port-forward NAMESPACE=ods-dev +# make db-port-forward NAMESPACE=ods-prod DB_K8S_SERVICE=postgresql DB_PF_LOCAL_PORT=15432 +# ============================================================================= + +# Build the JDBC URL from individual host/port/name parts. +# ODS_API_SERVICE_DB_PORT defaults to 5432 when not set. +DB_PORT ?= $(or $(ODS_API_SERVICE_DB_PORT),5432) +DB_JDBC_URL = jdbc:postgresql://$(ODS_API_SERVICE_DB_HOST):$(DB_PORT)/$(ODS_API_SERVICE_DB_NAME) + +# Kubernetes port-forward defaults (all overridable on the command line) +DB_K8S_SERVICE ?= ods-api-service-postgresql +DB_PF_LOCAL_PORT ?= 5432 +DB_PF_REMOTE_PORT ?= 5432 + +DB_MAVEN_ARGS = -pl persistence \ + -Dliquibase.url=$(DB_JDBC_URL) \ + -Dliquibase.username=$(ODS_API_SERVICE_DB_USER) \ + -Dliquibase.password=$(ODS_API_SERVICE_DB_PASSWORD) + +## [DB] Verify required DB environment variables are set +db-check-env: + @missing=; \ + for var in ODS_API_SERVICE_DB_HOST ODS_API_SERVICE_DB_NAME ODS_API_SERVICE_DB_USER ODS_API_SERVICE_DB_PASSWORD; do \ + if [ -z "$$(eval echo \$$$$var)" ]; then missing="$$missing $$var"; fi; \ + done; \ + if [ -n "$$missing" ]; then \ + echo "$(RED)Error: the following required environment variables are not set:$(NC)"; \ + for v in $$missing; do echo " $$v"; done; \ + echo ""; \ + echo "$(YELLOW)Hint: copy persistence/liquibase.properties.example to persistence/.env,"; \ + echo " fill in the values, then run: source persistence/.env$(NC)"; \ + exit 1; \ + fi + @echo "$(GREEN)✓ DB environment variables OK$(NC)" + +## [DB] Validate the Liquibase changelog syntax (no DB connection required) +db-validate: check-maven + @echo "$(BLUE)Validating Liquibase changelog...$(NC)" + $(MAVEN_WRAPPER) -pl persistence liquibase:validate + @echo "$(GREEN)✓ Changelog validation complete$(NC)" + +## [DB] Show pending changesets that have not yet been applied +db-status: db-check-env check-maven + @echo "$(BLUE)Checking pending Liquibase changesets...$(NC)" + @echo "$(YELLOW)Target: $(DB_JDBC_URL)$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:status + +## [DB] Apply all pending changesets to the database +db-migrate: db-check-env check-maven + @echo "$(BLUE)Running Liquibase migrations...$(NC)" + @echo "$(YELLOW)Target: $(DB_JDBC_URL)$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:update + @echo "$(GREEN)✓ Migrations applied$(NC)" + +## [DB] Roll back the last applied changeset (usage: make db-rollback) +db-rollback: db-check-env check-maven + @echo "$(YELLOW)Rolling back last changeset on: $(DB_JDBC_URL)$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:rollback -Dliquibase.rollbackCount=1 + @echo "$(GREEN)✓ Rollback complete$(NC)" + +## [DB] Tag the current database state (usage: make db-tag TAG=v1.0.0) +db-tag: db-check-env check-maven + @if [ -z "$(TAG)" ]; then \ + echo "$(RED)Error: TAG is required. Usage: make db-tag TAG=v1.0.0$(NC)"; \ + exit 1; \ + fi + @echo "$(BLUE)Tagging database state as '$(TAG)'...$(NC)" + $(MAVEN_WRAPPER) $(DB_MAVEN_ARGS) liquibase:tag -Dliquibase.tag=$(TAG) + @echo "$(GREEN)✓ Database tagged as '$(TAG)'$(NC)" + +## [DB] Port-forward the cluster PostgreSQL Service to localhost (usage: make db-port-forward NAMESPACE=) +db-port-forward: + @if [ -z "$(NAMESPACE)" ]; then \ + echo "$(RED)Error: NAMESPACE is required.$(NC)"; \ + echo "$(YELLOW)Usage: make db-port-forward NAMESPACE=$(NC)"; \ + echo "$(YELLOW)Optionally override DB_K8S_SERVICE (default: $(DB_K8S_SERVICE)),$(NC)"; \ + echo "$(YELLOW) DB_PF_LOCAL_PORT (default: $(DB_PF_LOCAL_PORT)) or DB_PF_REMOTE_PORT (default: $(DB_PF_REMOTE_PORT)).$(NC)"; \ + exit 1; \ + fi + @if ! command -v kubectl >/dev/null 2>&1; then \ + echo "$(RED)Error: kubectl not found in PATH$(NC)"; \ + exit 1; \ + fi + @echo "$(BLUE)Port-forwarding PostgreSQL service...$(NC)" + @echo "$(YELLOW) Namespace : $(NAMESPACE)$(NC)" + @echo "$(YELLOW) Service : $(DB_K8S_SERVICE)$(NC)" + @echo "$(YELLOW) Mapping : localhost:$(DB_PF_LOCAL_PORT) → $(DB_K8S_SERVICE):$(DB_PF_REMOTE_PORT)$(NC)" + @echo "" + @echo "$(YELLOW)Once forwarding is active, connect with:$(NC)" + @echo " psql -h localhost -p $(DB_PF_LOCAL_PORT) -U \$$ODS_API_SERVICE_DB_USER \$$ODS_API_SERVICE_DB_NAME" + @echo " # or run migrations against the forwarded port:" + @echo " ODS_API_SERVICE_DB_HOST=localhost ODS_API_SERVICE_DB_PORT=$(DB_PF_LOCAL_PORT) make db-migrate" + @echo "" + @echo "$(YELLOW)Press Ctrl+C to stop the tunnel$(NC)" + kubectl port-forward \ + --namespace $(NAMESPACE) \ + service/$(DB_K8S_SERVICE) \ + $(DB_PF_LOCAL_PORT):$(DB_PF_REMOTE_PORT) + +db-docker-build-db: + @echo "$(BLUE)Building Docker image for database...$(NC)" + @docker build --file ../ods-core/ods-api-service/docker/Dockerfile.database --tag $(PROJECT_NAME)-db:18 ../ods-core/ods-api-service/docker/ + @echo "$(GREEN)✓ Database Docker image built: $(PROJECT_NAME)-db:$(DOCKER_TAG)$(NC)" + +db-docker-run-db: db-docker-build-db + @echo "$(BLUE)Running PostgreSQL database in Docker...$(NC)" + @echo "$(YELLOW)Access the database at: localhost:5432$(NC)" + @echo "$(YELLOW)PostgreSQL credentials: user=ods_api_service, db=ods_api_service$(NC)" + @echo "$(YELLOW)Press Ctrl+C to stop$(NC)" + docker run -p 5432:5432 \ + -e POSTGRES_USER=ods_api_service \ + -e POSTGRES_PASSWORD=ods_api_service \ + -e POSTGRES_DB=ods_api_service \ + $(PROJECT_NAME)-db:18 + + ## Clean everything including Docker images clean-all: clean @echo "$(BLUE)Cleaning Docker images...$(NC)" diff --git a/README.md b/README.md index c3e6c89f..195ffcd3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ The system will expose RESTful APIs for third-party client applications and futu Before using the Makefile, ensure you have the following installed: ### Required -- **Java 17+** - The project uses Java 21 +- **Java 21+** - The project uses Java 21 - **Maven** - Maven wrapper (`mvnw`) is included in the project - **Make** - For running Makefile commands @@ -54,7 +54,7 @@ Build a traditional Spring Boot JAR file: ```bash make jar ``` -- Output: `core/target/core-0.0.2-SNAPSHOT.jar` +- Output: `core/target/core-0.0.3.jar` - Includes all dependencies - Standard Spring Boot startup time diff --git a/api-project-platform/pom.xml b/api-project-platform/pom.xml index 52d31863..abf2834d 100644 --- a/api-project-platform/pom.xml +++ b/api-project-platform/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 api-project-platform diff --git a/api-project-users/pom.xml b/api-project-users/pom.xml index fbd989c8..c75bf700 100644 --- a/api-project-users/pom.xml +++ b/api-project-users/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 api-project-users diff --git a/application.yaml b/application.yaml index e1d762a4..dc471553 100644 --- a/application.yaml +++ b/application.yaml @@ -3,7 +3,43 @@ logging: org.springframework: INFO org.springframework.security: TRACE org.opendevstack.apiservice.externalservice: DEBUG - + +# ────────────────────────────────────────────────────────────────────────────── +# Persistence — PostgreSQL datasource + JPA / Hibernate +# +# Schema is managed externally via Liquibase (database module / Makefile). +# Hibernate is set to `validate` so it only checks that entities match the +# existing schema at boot — it never creates or alters tables. +# +# Required env vars (no defaults — must be explicitly set per environment): +# DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD +# ────────────────────────────────────────────────────────────────────────────── +spring: + datasource: + url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:devstack} + username: ${DB_USERNAME:devstack} + password: ${DB_PASSWORD:devstack} + driver-class-name: org.postgresql.Driver + hikari: + # Pool sizing — tune per environment + maximum-pool-size: ${DB_POOL_MAX_SIZE:10} + minimum-idle: ${DB_POOL_MIN_IDLE:2} + connection-timeout: 30000 + idle-timeout: 600000 + max-lifetime: 1800000 + jpa: + hibernate: + # NEVER auto-create/alter — Liquibase owns the schema + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + # Log slow queries (> 500 ms) via Hibernate statistics + generate_statistics: false + # Avoid lazy-loading pitfalls: keep Session scoped to Service, not Request + open-in-view: false + show-sql: false + management: endpoints: web: @@ -44,7 +80,7 @@ openapi: otel: service: name: devstack-api-service-dev - version: 0.0.2 + version: 0.0.3 exporter: otlp: endpoint: http://opentelemetry.example.com @@ -55,7 +91,7 @@ otel: metrics: exporter: none resource: - attributes: service.name=devstack-api-service,service.version=0.0.2,deployment.environment=development + attributes: service.name=devstack-api-service,service.version=0.0.3,deployment.environment=development instrumentation: jdbc: enabled: false diff --git a/core/pom.xml b/core/pom.xml index 76bb484f..09a9ad9e 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 core @@ -113,6 +113,20 @@ ${project.version} + + + org.opendevstack.apiservice + persistence + ${project.version} + + + + + org.postgresql + postgresql + runtime + + io.opentelemetry.instrumentation diff --git a/core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java b/core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java deleted file mode 100644 index d8ada753..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/DevstackApiServiceApplicationTest.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.opendevstack.apiservice.core; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class DevstackApiServiceApplicationTest { - - @Test - void contextLoads() { - // This test ensures that the Spring context loads successfully - } - - @Test - void applicationStartsSuccessfully() { - // This test verifies that the application class can be instantiated - DevstackApiServiceApplication app = new DevstackApiServiceApplication(); - org.junit.jupiter.api.Assertions.assertNotNull(app, "Application instance should not be null"); - } -} \ No newline at end of file diff --git a/external-service-aap/pom.xml b/external-service-aap/pom.xml index 8250a957..0c80ffe9 100644 --- a/external-service-aap/pom.xml +++ b/external-service-aap/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-aap diff --git a/external-service-api/pom.xml b/external-service-api/pom.xml index af29c092..b6957371 100644 --- a/external-service-api/pom.xml +++ b/external-service-api/pom.xml @@ -7,7 +7,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2-SNAPSHOT + 0.0.3 external-service-api diff --git a/external-service-bitbucket/pom.xml b/external-service-bitbucket/pom.xml index efa5d4bf..39467b78 100644 --- a/external-service-bitbucket/pom.xml +++ b/external-service-bitbucket/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-bitbucket diff --git a/external-service-jira/pom.xml b/external-service-jira/pom.xml index c1ef1bd3..f89d4567 100644 --- a/external-service-jira/pom.xml +++ b/external-service-jira/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2-SNAPSHOT + 0.0.3 external-service-jira diff --git a/external-service-ocp/pom.xml b/external-service-ocp/pom.xml index a2f7df9b..befcf6f6 100644 --- a/external-service-ocp/pom.xml +++ b/external-service-ocp/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-ocp diff --git a/external-service-projects-info-service/pom.xml b/external-service-projects-info-service/pom.xml index d4447102..76a7779b 100644 --- a/external-service-projects-info-service/pom.xml +++ b/external-service-projects-info-service/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-projects-info-service diff --git a/external-service-uipath/pom.xml b/external-service-uipath/pom.xml index aa7962ca..b2a99254 100644 --- a/external-service-uipath/pom.xml +++ b/external-service-uipath/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-uipath diff --git a/external-service-webhookproxy/pom.xml b/external-service-webhookproxy/pom.xml index d4ed9a8d..94c8ec1e 100644 --- a/external-service-webhookproxy/pom.xml +++ b/external-service-webhookproxy/pom.xml @@ -6,7 +6,7 @@ org.opendevstack.apiservice devstack-api-service - 0.0.2 + 0.0.3 external-service-webhookproxy diff --git a/persistence/liquibase.properties.example b/persistence/liquibase.properties.example new file mode 100644 index 00000000..59629bef --- /dev/null +++ b/persistence/liquibase.properties.example @@ -0,0 +1,28 @@ +# Liquibase connection properties — LOCAL DEVELOPMENT TEMPLATE +# +# Copy this file to liquibase.properties and fill in real values. +# cp liquibase.properties.example liquibase.properties +# +# The file liquibase.properties is git-ignored and MUST NOT be committed. +# In CI/CD pipelines the connection is passed via environment variables +# that the Makefile forwards as -D flags to Maven (see 'make help' for details). +# +# Required environment variables used by the Makefile db-* targets: +# ODS_API_SERVICE_DB_HOST PostgreSQL hostname or IP +# ODS_API_SERVICE_DB_PORT PostgreSQL port (default: 5432) +# ODS_API_SERVICE_DB_NAME Database name → ods_api_service +# ODS_API_SERVICE_DB_USER Application user → ods_api_service +# ODS_API_SERVICE_DB_PASSWORD Application user password (plain text) +# +# The Kubernetes Secret holds the Base64-encoded equivalents: +# ODS_API_SERVICE_DB_NAME_B64 +# ODS_API_SERVICE_DB_USER_B64 +# ODS_API_SERVICE_DB_PASSWORD_B64 +# ODS_API_SERVICE_DB_SUPER_PASSWORD_B64 +# --------------------------------------------------------------------------- + +export ODS_API_SERVICE_DB_HOST=localhost +export ODS_API_SERVICE_DB_PORT=5432 +export ODS_API_SERVICE_DB_NAME=ods_api_service +export ODS_API_SERVICE_DB_USER=ods_api_service +export ODS_API_SERVICE_DB_PASSWORD=changeme diff --git a/persistence/pom.xml b/persistence/pom.xml new file mode 100644 index 00000000..a5fd5f9f --- /dev/null +++ b/persistence/pom.xml @@ -0,0 +1,112 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + persistence + Persistence + JPA entities, Spring Data repositories, database schema migrations and persistence utilities for the Devstack API Service + + + 5.0.1 + 42.7.10 + + jdbc:postgresql://localhost:5432/ods_api_service + ods_api_service + + org.postgresql.Driver + src/main/resources/db/changelog/db.changelog-master.xml + src/main/resources/db/changelog/db.changelog-generated.xml + true + + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.testcontainers + postgresql + test + + + + + org.testcontainers + junit-jupiter + test + + + + + org.postgresql + postgresql + test + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + org.liquibase + liquibase-maven-plugin + ${liquibase.version} + + ${liquibase.driver} + ${liquibase.url} + ${liquibase.username} + ${liquibase.password} + ${liquibase.changeLogFile} + ${liquibase.outputChangeLogFile} + ${liquibase.verbose} + + + + + org.postgresql + postgresql + ${postgresql.version} + + + + + + + diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java new file mode 100644 index 00000000..635a1e7e --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java @@ -0,0 +1,126 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import java.time.OffsetDateTime; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +/** + * JPA entity mapping the {@code projects} table. + * + *

+ * Schema is managed externally via Liquibase (see {@code database} module). Hibernate is + * configured with {@code ddl-auto=validate} so it only validates alignment at boot — it never + * modifies the schema. + *

+ * + *

+ * Soft-deletes are supported via the {@link #deleted} flag. Use + * {@code findByDeletedFalse()} to retrieve only active projects. + *

+ */ +@Entity +@Table(name = "projects") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = { "description", "ldapGroupManager", "ldapGroupTeam", "ldapGroupStakeholder" }) +public class ProjectEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** + * Human-readable unique identifier (e.g. {@code MY-PROJECT}). Maps to the + * {@code uq_projects_project_key} unique index. + */ + @Column(name = "project_key", nullable = false, unique = true, length = 100) + private String projectKey; + + /** Optional display name. */ + @Column(name = "project_name", length = 255) + private String projectName; + + /** Optional free-text description. */ + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + /** Configuration item identifier. */ + @Column(name = "configuration_item", nullable = false, length = 255) + private String configurationItem; + + /** + * Deployment region. + */ + @Column(name = "location", nullable = false, length = 50) + private String location; + + /** + * Project type classifier. + */ + @Column(name = "project_flavor", length = 50) + private String projectFlavor; + + /** + * Provisioning status. Known values: {@code Failed}, {@code Pending} (null = completed). + */ + @Column(name = "status", length = 50) + private String status; + + /** + * Soft-delete flag. Active projects have {@code deleted = false}. + */ + @Column(name = "deleted", nullable = false) + @Builder.Default + private boolean deleted = false; + + /** Full LDAP DN for the PROJECT_MANAGER group. */ + @Column(name = "ldap_group_manager", columnDefinition = "TEXT") + private String ldapGroupManager; + + /** Full LDAP DN for the PROJECT_TEAM group. */ + @Column(name = "ldap_group_team", columnDefinition = "TEXT") + private String ldapGroupTeam; + + /** Full LDAP DN for the PROJECT_STAKEHOLDER group. */ + @Column(name = "ldap_group_stakeholder", columnDefinition = "TEXT") + private String ldapGroupStakeholder; + + /** Original creation timestamp (UTC). Set automatically on first persist. */ + @Column(name = "created_at", nullable = false, updatable = false, + columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime createdAt; + + /** Timestamp of last update (UTC). Updated automatically on every merge. */ + @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime updatedAt; + + @PrePersist + void onPrePersist() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + void onPreUpdate() { + this.updatedAt = OffsetDateTime.now(); + } + +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java new file mode 100644 index 00000000..68f344cc --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java @@ -0,0 +1,45 @@ +package org.opendevstack.apiservice.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link ProjectEntity}. + * + *

+ * Provides standard CRUD operations inherited from {@link JpaRepository} plus + * domain-specific derived query methods. All query methods that return project + * collections exclude soft-deleted records (i.e. {@code deleted = false}) by + * convention — use {@code findAllIncludingDeleted()} variants explicitly when + * deleted records are needed. + *

+ */ +@Repository +public interface ProjectRepository extends JpaRepository { + + /** + * Finds a project by its unique business key regardless of soft-delete status. Use + * this when you explicitly need to handle deleted projects (e.g. restore flows). + * @param projectKey the Atlassian-style project key + * @return the matching project, or {@link Optional#empty()} if it has never existed + */ + Optional findByProjectKey(String projectKey); + + /** + * Returns all active (non-deleted) projects. + * @return list of active projects; empty list if none exist + */ + List findByDeletedFalse(); + + /** + * Checks whether an active project with the given key already exists. + * @param projectKey the project key to look up + * @return {@code true} if an active project with this key exists + */ + boolean existsByProjectKey(String projectKey); + +} diff --git a/persistence/src/main/resources/db/changelog/db.changelog-master.xml b/persistence/src/main/resources/db/changelog/db.changelog-master.xml new file mode 100644 index 00000000..1c6499ad --- /dev/null +++ b/persistence/src/main/resources/db/changelog/db.changelog-master.xml @@ -0,0 +1,25 @@ + + + + + + + + diff --git a/persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql b/persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql new file mode 100644 index 00000000..382c40ab --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/001_create_projects_table.sql @@ -0,0 +1,46 @@ +--liquibase formatted sql + +--changeset ods:001-create-projects +-- Description: Creates the `projects` table, which is the central entity of the +-- ods-api-service. Each row represents a managed project identified by +-- a unique, Atlassian project key (e.g. "MY-PROJECT"). +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS projects ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + project_key VARCHAR(100) NOT NULL, + project_name VARCHAR(255), + description VARCHAR(255), + configuration_item VARCHAR(255) NOT NULL, + location VARCHAR(50) NOT NULL, + project_flavor VARCHAR(50), + status VARCHAR(50), + deleted BOOLEAN NOT NULL DEFAULT FALSE, + ldap_group_manager VARCHAR(255), + ldap_group_team VARCHAR(255), + ldap_group_stakeholder VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Unique index on project_key — used as a natural FK target from other tables +CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_project_key + ON projects (project_key); + +COMMENT ON TABLE projects IS 'Managed projects maintained by ods-api-service'; +COMMENT ON COLUMN projects.project_key IS 'Human-readable unique identifier (e.g. MY-PROJECT)'; +COMMENT ON COLUMN projects.project_name IS 'Optional display name'; +COMMENT ON COLUMN projects.description IS 'Optional free-text description'; +COMMENT ON COLUMN projects.configuration_item IS 'CMDB configuration item identifier'; +COMMENT ON COLUMN projects.location IS 'Deployment region; known values: cn, eu, nah, us, us-test'; +COMMENT ON COLUMN projects.project_flavor IS 'Project type classifier; known values: AMP, DLSS'; +COMMENT ON COLUMN projects.status IS 'Provisioning status; known values: Failed, Pending (null = completed)'; +COMMENT ON COLUMN projects.deleted IS 'Soft-delete flag'; +COMMENT ON COLUMN projects.ldap_group_manager IS 'Full LDAP CN for the PROJECT_MANAGER group'; +COMMENT ON COLUMN projects.ldap_group_team IS 'Full LDAP CN for the PROJECT_TEAM group'; +COMMENT ON COLUMN projects.ldap_group_stakeholder IS 'Full LDAP CN for the PROJECT_STAKEHOLDER group'; +COMMENT ON COLUMN projects.created_at IS 'Original creation timestamp (UTC)'; +COMMENT ON COLUMN projects.updated_at IS 'Timestamp of last update (UTC)'; + +--rollback DROP INDEX IF EXISTS uq_projects_project_key; +--rollback DROP TABLE IF EXISTS projects; diff --git a/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql b/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql new file mode 100644 index 00000000..4c2b0779 --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql @@ -0,0 +1,69 @@ +--liquibase formatted sql + +--changeset ods:002-create-client-apps +-- Description: Creates the `client_apps` table, representing registered Azure AD +-- clients that are authorised to call the service APIs. +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS client_apps ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + client_id VARCHAR(36) NOT NULL, + client_name VARCHAR(255), + permissions TEXT[] NOT NULL DEFAULT '{}', + role_scope TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_client_apps_client_id + ON client_apps (client_id); + +COMMENT ON TABLE client_apps IS 'Azure AD clients authorised to call the service APIs'; +COMMENT ON COLUMN client_apps.client_id IS 'Azure AD Application (client) UUID'; +COMMENT ON COLUMN client_apps.client_name IS 'Azure AD application display name'; +COMMENT ON COLUMN client_apps.permissions IS 'Granted permissions; known values: project:add, project:detail, project:list'; +COMMENT ON COLUMN client_apps.role_scope IS 'OAuth2 scope or role granted to this client (e.g. api.read, api.write)'; +COMMENT ON COLUMN client_apps.enabled IS 'When FALSE the client is denied access without removing the row'; + +--rollback DROP INDEX IF EXISTS uq_client_apps_client_id; +--rollback DROP TABLE IF EXISTS client_apps; + + +--changeset ods:002-create-client-app-project-flavors +-- Description: Each API client can be configured with one or more project flavors +-- that control how projects are created (key pattern, template, owner, etc.). +CREATE TABLE IF NOT EXISTS client_app_project_flavors ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + client_app_id UUID NOT NULL, + name VARCHAR(50) NOT NULL, + project_key_pattern VARCHAR(100) NOT NULL, + template_id INT, + project_owner VARCHAR(255), + service_account VARCHAR(255), + config_item VARCHAR(255), + allowed_config_items TEXT[] NOT NULL DEFAULT '{}', + location VARCHAR(50), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_project_flavors_client_app + FOREIGN KEY (client_app_id) REFERENCES client_apps (id) + ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_client_app_project_flavors_client_app_id + ON client_app_project_flavors (client_app_id); + +COMMENT ON TABLE client_app_project_flavors IS 'Project flavor configurations per API client'; +COMMENT ON COLUMN client_app_project_flavors.client_app_id IS 'FK to client_apps.id (UUID)'; +COMMENT ON COLUMN client_app_project_flavors.name IS 'Flavor name (e.g. DLSS, AMP)'; +COMMENT ON COLUMN client_app_project_flavors.project_key_pattern IS 'printf-style pattern used to generate the project key (e.g. DLSS%06d)'; +COMMENT ON COLUMN client_app_project_flavors.template_id IS 'ODS/Jira template identifier'; +COMMENT ON COLUMN client_app_project_flavors.project_owner IS 'Default project owner username'; +COMMENT ON COLUMN client_app_project_flavors.service_account IS 'Service account associated with the flavor'; +COMMENT ON COLUMN client_app_project_flavors.config_item IS 'Default CMDB configuration item for projects created under this flavor'; +COMMENT ON COLUMN client_app_project_flavors.allowed_config_items IS 'Allowed CMDB configuration item overrides (empty = no overrides permitted)'; +COMMENT ON COLUMN client_app_project_flavors.location IS 'Deployment region (e.g. eu, us)'; + +--rollback DROP INDEX IF EXISTS idx_client_app_project_flavors_client_app_id; +--rollback DROP TABLE IF EXISTS client_app_project_flavors; diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java new file mode 100644 index 00000000..164c08ef --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.persistence; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Shared Spring Boot test bootstrap for the persistence module. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") +@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") +public class PersistenceTestApplication { + +} \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java new file mode 100644 index 00000000..a83c8048 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/PersistenceIntegrationTestConfig.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.persistence.integration; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Test configuration for persistence integration tests. + * Enables Spring Boot auto-configuration, entity scanning, and JPA repository + * scanning for the persistence module. + */ +@SpringBootConfiguration +@EnableAutoConfiguration +@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") +@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") +public class PersistenceIntegrationTestConfig { + +} diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java new file mode 100644 index 00000000..be517a67 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java @@ -0,0 +1,279 @@ +package org.opendevstack.apiservice.persistence.integration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.TestPropertySource; + +import lombok.extern.slf4j.Slf4j; + +/** + * Full-context integration tests for {@link ProjectRepository} against a real + * local PostgreSQL database. + + *

+ * Requires a running PostgreSQL instance reachable via the environment + * variables consumed by these tests. If no environment variables are provided, + * the defaults point to the local development database. + * + *

+ * Test data is inserted under well-known keys ({@code IT-ACTIVE-01}, + * {@code IT-DELETED-01}, + * {@code IT-PENDING-01}) and cleaned up in {@code @AfterEach}, so the tests do + * not interfere with existing data in the database. + *

+ */ +@SpringBootTest(classes = PersistenceIntegrationTestConfig.class) +@ActiveProfiles("local") +@TestPropertySource(properties = { "spring.config.activate.on-profile=local" }) +@EnabledIfEnvironmentVariable(named = "PROJECT_REPOSITORY_INTEGRATION_TEST_ENABLED", matches = "true") +@Slf4j +class ProjectRepositoryIntegrationTest { + + private static final String DEFAULT_TEST_DATASOURCE_URL = "jdbc:postgresql://localhost:5432/ods_api_service"; + private static final String DEFAULT_TEST_DATASOURCE_USERNAME = "ods_api_service"; + private static final String DEFAULT_TEST_DATASOURCE_PASSWORD = "ods_api_service"; + + @DynamicPropertySource + static void datasourceProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", + () -> System.getenv().getOrDefault("IT_SPRING_DATASOURCE_URL", DEFAULT_TEST_DATASOURCE_URL)); + registry.add("spring.datasource.username", + () -> System.getenv().getOrDefault("IT_SPRING_DATASOURCE_USERNAME", DEFAULT_TEST_DATASOURCE_USERNAME)); + registry.add("spring.datasource.password", + () -> System.getenv().getOrDefault("IT_SPRING_DATASOURCE_PASSWORD", DEFAULT_TEST_DATASOURCE_PASSWORD)); + } + + /** Keys reserved exclusively for these integration tests. */ + private static final Set TEST_KEYS = Set.of("IT-ACTIVE-01", "IT-DELETED-01", "IT-PENDING-01", + "IT-DUPLICATE-01"); + + @Autowired + private ProjectRepository projectRepository; + + private ProjectEntity activeProject; + private ProjectEntity deletedProject; + + @BeforeEach + void setUp() { + cleanupTestData(); + + activeProject = projectRepository.save(ProjectEntity.builder() + .projectKey("IT-ACTIVE-01") + .projectName("IT Active Project") + .configurationItem("CI-IT-001") + .location("eu") + .projectFlavor("AMP") + .deleted(false) + .build()); + + deletedProject = projectRepository.save(ProjectEntity.builder() + .projectKey("IT-DELETED-01") + .projectName("IT Deleted Project") + .configurationItem("CI-IT-002") + .location("us") + .projectFlavor("DLSS") + .status("Failed") + .deleted(true) + .build()); + + // Pending project — active but not yet provisioned + projectRepository.save(ProjectEntity.builder() + .projectKey("IT-PENDING-01") + .projectName("IT Pending Project") + .configurationItem("CI-IT-003") + .location("eu") + .projectFlavor("AMP") + .status("Pending") + .deleted(false) + .build()); + } + + @AfterEach + void tearDown() { + cleanupTestData(); + } + + private void cleanupTestData() { + TEST_KEYS.forEach(key -> projectRepository.findByProjectKey(key).ifPresent(projectRepository::delete)); + } + + // ── findByProjectKey ────────────────────────────────────────────────────── + + @Nested + @DisplayName("findByProjectKey") + class FindByProjectKey { + + @Test + @DisplayName("returns active project by its key") + void returnsActiveProjectByKey() { + Optional result = projectRepository.findByProjectKey("IT-ACTIVE-01"); + + assertThat(result).isPresent(); + assertThat(result.get().getProjectKey()).isEqualTo("IT-ACTIVE-01"); + assertThat(result.get().isDeleted()).isFalse(); + } + + @Test + @DisplayName("returns soft-deleted project by its key") + void returnsDeletedProjectByKey() { + Optional result = projectRepository.findByProjectKey("IT-DELETED-01"); + + assertThat(result).isPresent(); + assertThat(result.get().getProjectKey()).isEqualTo("IT-DELETED-01"); + assertThat(result.get().isDeleted()).isTrue(); + } + + @Test + @DisplayName("returns empty Optional for a key that has never existed") + void returnsEmptyForUnknownKey() { + Optional result = projectRepository.findByProjectKey("NONEXISTENTKEY"); + + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("returned entity contains all persisted fields") + void returnedEntityContainsAllFields() { + Optional result = projectRepository.findByProjectKey("IT-DELETED-01"); + + assertThat(result).isPresent(); + ProjectEntity entity = result.get(); + assertThat(entity.getProjectName()).isEqualTo("IT Deleted Project"); + assertThat(entity.getConfigurationItem()).isEqualTo("CI-IT-002"); + assertThat(entity.getLocation()).isEqualTo("us"); + assertThat(entity.getStatus()).isEqualTo("Failed"); + } + } + + // ── findByDeletedFalse ──────────────────────────────────────────────────── + + @Nested + @DisplayName("findByDeletedFalse") + class FindByDeletedFalse { + + @Test + @DisplayName("returns only non-deleted projects") + void returnsOnlyNonDeletedProjects() { + List active = projectRepository.findByDeletedFalse(); + + assertThat(active) + .isNotEmpty() + .allMatch(p -> !p.isDeleted()); + } + + @Test + @DisplayName("excludes soft-deleted projects from results") + void excludesSoftDeletedProjects() { + List active = projectRepository.findByDeletedFalse(); + + assertThat(active) + .isNotEmpty() + .extracting(ProjectEntity::getProjectKey) + .doesNotContain("IT-DELETED-01"); + } + + @Test + @DisplayName("includes test active projects regardless of their status field") + void includesAllActiveProjectsRegardlessOfStatus() { + List active = projectRepository.findByDeletedFalse(); + + // The real DB may contain other projects; assert our test keys are present + assertThat(active) + .extracting(ProjectEntity::getProjectKey) + .contains("IT-ACTIVE-01", "IT-PENDING-01"); + } + } + + // ── existsByProjectKey ──────────────────────────────────────────────────── + + @Nested + @DisplayName("existsByProjectKey") + class ExistsByProjectKey { + + @Test + @DisplayName("returns true for an existing active project key") + void returnsTrueForActiveKey() { + assertThat(projectRepository.existsByProjectKey("IT-ACTIVE-01")).isTrue(); + } + + @Test + @DisplayName("returns true for a soft-deleted project key") + void returnsTrueForDeletedKey() { + assertThat(projectRepository.existsByProjectKey("IT-DELETED-01")).isTrue(); + } + + @Test + @DisplayName("returns false for a key that has never existed") + void returnsFalseForNonExistentKey() { + assertThat(projectRepository.existsByProjectKey("NONEXISTENTKEY")).isFalse(); + } + } + + // ── Persistence lifecycle ───────────────────────────────────────────────── + + @Nested + @DisplayName("Persistence lifecycle") + class PersistenceLifecycle { + + @Test + @DisplayName("save() auto-populates createdAt and updatedAt timestamps") + void saveAutoPopulatesTimestamps() { + assertThat(activeProject.getCreatedAt()).isNotNull(); + assertThat(activeProject.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("save() with duplicate project_key throws DataIntegrityViolationException") + void saveDuplicateProjectKeyThrowsException() { + ProjectEntity duplicate = ProjectEntity.builder() + .projectKey("IT-ACTIVE-01") // already exists + .configurationItem("CI-IT-999") + .location("eu") + .build(); + + assertThatThrownBy(() -> projectRepository.saveAndFlush(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + @DisplayName("soft-delete by setting deleted=true is reflected in findByDeletedFalse") + void softDeleteIsReflectedInActiveList() { + activeProject.setDeleted(true); + projectRepository.save(activeProject); + + assertThat(projectRepository.findByDeletedFalse()) + .extracting(ProjectEntity::getProjectKey) + .doesNotContain("IT-ACTIVE-01"); + } + + @Test + @DisplayName("restoring a soft-deleted project makes it reappear in findByDeletedFalse") + void restoredProjectReappearsInActiveList() { + deletedProject.setDeleted(false); + projectRepository.save(deletedProject); + + assertThat(projectRepository.findByDeletedFalse()) + .extracting(ProjectEntity::getProjectKey) + .contains("IT-DELETED-01"); + } + } +} \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java new file mode 100644 index 00000000..e067117d --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java @@ -0,0 +1,165 @@ +package org.opendevstack.apiservice.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration tests for {@link ProjectRepository}. + * + *

+ * Uses a real PostgreSQL instance via Testcontainers with Hibernate + * {@code create-drop} so the tests are fully schema-autonomous — no dependency + * on the Liquibase migration files at test time. This keeps the persistence + * module independently testable. + *

+ */ +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = Replace.NONE) +class ProjectRepositoryTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine") + .withDatabaseName("devstack_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2"); + registry.add("spring.datasource.hikari.minimum-idle", () -> "0"); + // Let Hibernate create the schema once for the container lifecycle and avoid + // delayed drop-on-close work during JVM shutdown. + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + } + + @Autowired + private ProjectRepository repository; + + private ProjectEntity activeProject; + + private ProjectEntity deletedProject; + + @BeforeEach + void setUp() { + repository.deleteAll(); + + activeProject = repository.save(ProjectEntity.builder() + .projectKey("ACTIVE-01") + .projectName("Active Project One") + .configurationItem("CI-001") + .location("eu") + .projectFlavor("AMP") + .status(null) // null = completed in this domain + .deleted(false) + .build()); + + deletedProject = repository.save(ProjectEntity.builder() + .projectKey("DELETED-01") + .projectName("Deleted Project One") + .configurationItem("CI-002") + .location("us") + .projectFlavor("DLSS") + .status("Failed") + .deleted(true) + .build()); + } + + // ── CRUD ────────────────────────────────────────────────────────────────── + + @Nested + @DisplayName("Basic CRUD") + class BasicCrud { + + @Test + @DisplayName("save() persists entity and auto-sets timestamps") + void save_persistsEntityWithTimestamps() { + assertThat(activeProject.getId()).isNotNull(); + assertThat(activeProject.getCreatedAt()).isNotNull(); + assertThat(activeProject.getUpdatedAt()).isNotNull(); + } + + @Test + @DisplayName("findById() returns the saved entity") + void findById_returnsEntity() { + Optional found = repository.findById(activeProject.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getProjectKey()).isEqualTo("ACTIVE-01"); + } + + @Test + @DisplayName("save() with duplicate project_key throws DataIntegrityViolationException") + void save_duplicateProjectKey_throwsException() { + ProjectEntity duplicate = ProjectEntity.builder() + .projectKey("ACTIVE-01") // same key + .configurationItem("CI-999") + .location("eu") + .build(); + + assertThatThrownBy(() -> { + repository.saveAndFlush(duplicate); + }).isInstanceOf(DataIntegrityViolationException.class); + } + + } + + // ── findByProjectKey ────────────────────────────────────────────────────── + + @Nested + @DisplayName("findByProjectKey") + class FindByProjectKey { + + @Test + @DisplayName("returns project regardless of deleted flag") + void returnsProjectRegardlessOfDeletedFlag() { + assertThat(repository.findByProjectKey("ACTIVE-01")).isPresent(); + assertThat(repository.findByProjectKey("DELETED-01")).isPresent(); + } + + @Test + @DisplayName("returns empty for unknown key") + void returnsEmptyForUnknownKey() { + assertThat(repository.findByProjectKey("UNKNOWN")).isEmpty(); + } + + } + + + // ── findByDeletedFalse ──────────────────────────────────────────────────── + + @Nested + @DisplayName("findByDeletedFalse") + class FindByDeletedFalse { + + @Test + @DisplayName("returns only active projects") + void returnsOnlyActiveProjects() { + List active = repository.findByDeletedFalse(); + assertThat(active).hasSize(1); + assertThat(active.get(0).getProjectKey()).isEqualTo("ACTIVE-01"); + } + + } + +} diff --git a/persistence/src/test/resources/application-local.properties b/persistence/src/test/resources/application-local.properties new file mode 100644 index 00000000..af3a390f --- /dev/null +++ b/persistence/src/test/resources/application-local.properties @@ -0,0 +1,10 @@ +logging.level.org.springframework.boot.autoconfigure.jdbc=DEBUG +spring.datasource.url=jdbc\:postgresql\://localhost\:5432/ods_api_service +spring.datasource.username=ods_api_service +spring.datasource.password=ods_api_service +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.maximum-pool-size=2 +spring.datasource.hikari.minimum-idle=0 +spring.datasource.hikari.idle-timeout=600000 +spring.datasource.hikari.max-lifetime=1800000 diff --git a/pom.xml b/pom.xml index 605d1efc..4248e132 100644 --- a/pom.xml +++ b/pom.xml @@ -14,7 +14,7 @@ org.opendevstack.apiservice devstack-api-service Devstack API Service - 0.0.2 + 0.0.3 21 @@ -44,6 +44,7 @@ external-service-api + persistence core external-service-aap external-service-projects-info-service @@ -172,10 +173,6 @@ **/*Test.java - - - ../application.yaml - From b94316786f073a6ec1c9728e383255539df8274d Mon Sep 17 00:00:00 2001 From: Angel MP Date: Thu, 12 Mar 2026 13:37:29 +0100 Subject: [PATCH 02/27] Add project service module with key generation and existence checks (#7) --- .gitignore | 5 +- CHANGELOG.md | 3 + .../openapi/api-project-users.yaml | 4 +- api-project/openapi/api-project.yaml | 152 ++++++++++++++++ api-project/pom.xml | 163 ++++++++++++++++++ .../project/controller/ProjectController.java | 63 +++++++ .../controller/ProjectResponseFactory.java | 38 ++++ .../exception/ProjectCreationException.java | 12 ++ .../project/exception/ProjectException.java | 12 ++ .../ProjectKeyGenerationException.java | 12 ++ .../project/facade/ProjectsFacade.java | 14 ++ .../facade/impl/ProjectsFacadeImpl.java | 35 ++++ .../project/mapper/ProjectMapper.java | 15 ++ .../controller/ProjectControllerTest.java | 132 ++++++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 92 ++++++++++ core/pom.xml | 7 + pom.xml | 5 + service-projects/pom.xml | 75 ++++++++ .../ProjectKeyGenerationException.java | 13 ++ .../model/CreateProjectRequest.java | 19 ++ .../model/CreateProjectResponse.java | 25 +++ .../service/GenerateProjectKeyService.java | 11 ++ .../service/ProjectService.java | 12 ++ .../impl/GenerateProjectKeyServiceImpl.java | 141 +++++++++++++++ .../service/impl/ProjectServiceImpl.java | 37 ++++ .../GenerateProjectKeyServiceImplTest.java | 98 +++++++++++ 26 files changed, 1191 insertions(+), 4 deletions(-) create mode 100644 api-project/openapi/api-project.yaml create mode 100644 api-project/pom.xml create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java create mode 100644 service-projects/pom.xml create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java diff --git a/.gitignore b/.gitignore index f766354f..5183737d 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/api api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/model api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/api api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model -api-project-users/.openapi-generator -/api-project-platform/.openapi-generator/ +api-project/src/main/java/org/opendevstack/apiservice/project/api +api-project/src/main/java/org/opendevstack/apiservice/project/model +**/.openapi-generator diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c7df06d..df729a5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Created **New API module** for managing EDP Projects with create and retrieve endpoints. +- Created **New module** external service projects to manage EDP Projects. + ### External Service Jira (`external-service-jira`) - **New module** for checking project existance in Jira (Server) - Caching for the client diff --git a/api-project-users/openapi/api-project-users.yaml b/api-project-users/openapi/api-project-users.yaml index 2d0ba51b..a78d1efc 100644 --- a/api-project-users/openapi/api-project-users.yaml +++ b/api-project-users/openapi/api-project-users.yaml @@ -16,7 +16,7 @@ tags: - name: Project Users description: API for managing project users and their roles paths: - /project/{projectKey}/users: + /projects/{projectKey}/users: post: tags: - Project Users @@ -89,7 +89,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiResponseMembershipRequestResponse" - /project/{projectKey}/users/{userid}/status: + /projects/{projectKey}/users/{userid}/status: get: tags: - Project Users diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml new file mode 100644 index 00000000..8fbd57b2 --- /dev/null +++ b/api-project/openapi/api-project.yaml @@ -0,0 +1,152 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Project + description: API for manage EDP projects. +paths: + /projects: + post: + tags: + - Projects + summary: Create a new project. + description: Creates a new project with the provided configuration. Generates a unique project key if not provided. + operationId: createProject + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectRequest' + responses: + '200': + description: Project creation initiated successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '400': + description: Invalid request body or validation error. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '409': + description: A project with the specified key already exists. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '500': + description: Internal server error during project creation. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + /projects/{projectKey}: + get: + tags: + - Projects + summary: Get project status by project key. + description: Returns the current status and details of the project identified by the given project key. + operationId: getProject + parameters: + - name: projectKey + in: path + required: true + schema: + type: string + description: Project key to retrieve information. + responses: + '200': + description: Project information retrieved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + '404': + description: Project not found. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProjectResponse' +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + CreateProjectRequest: + type: object + properties: + projectKey: + type: string + description: Optional project key. If not provided, a unique key will be generated. + projectKeyPattern: + type: string + description: Optional pattern for generating the project key (e.g. 'SS%06d'). + projectName: + type: string + description: Name of the project. + projectDescription: + type: string + description: Description of the project. + required: + - projectName + CreateProjectResponse: + type: object + properties: + projectKey: + type: string + status: + type: string + message: + type: string + error: + type: string + errorKey: + type: string + errorDescription: + type: string diff --git a/api-project/pom.xml b/api-project/pom.xml new file mode 100644 index 00000000..8daceb0d --- /dev/null +++ b/api-project/pom.xml @@ -0,0 +1,163 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project + API Projects + API module for managing EDP projects + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + org.mapstruct + mapstruct + 1.6.3 + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-core + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java new file mode 100644 index 00000000..a17d338b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -0,0 +1,63 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.api.ProjectsApi; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(ProjectController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectController implements ProjectsApi { + + public static final String API_BASE_PATH = "/api/pub/v0/projects"; + + private final ProjectsFacade projectsFacade; + + @PostMapping + @Override + public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { + try { + return ResponseEntity.ok(projectsFacade.createProject(createProjectRequest)); + } catch (ProjectCreationException e) { + log.error("Project creation conflict: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ProjectResponseFactory.conflict(e.getMessage())); + } catch (ProjectKeyGenerationException e) { + log.error("Failed to generate project key: {}", e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ProjectResponseFactory.projectKeyGenerationFailed()); + } + } + + @GetMapping("/{projectKey}") + @Override + public ResponseEntity getProject(@PathVariable String projectKey) { + try { + CreateProjectResponse response = projectsFacade.getProject(projectKey); + if (response == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ProjectResponseFactory.notFound(projectKey)); + } + return ResponseEntity.ok(response); + } catch (ProjectCreationException e) { + log.error("Error retrieving project '{}': {}", projectKey, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ProjectResponseFactory.internalError()); + } + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java new file mode 100644 index 00000000..6a6faae3 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -0,0 +1,38 @@ +package org.opendevstack.apiservice.project.controller; + +import org.opendevstack.apiservice.project.model.CreateProjectResponse; + +public final class ProjectResponseFactory { + + private static final String INTERNAL_ERROR = "INTERNAL_ERROR"; + + private ProjectResponseFactory() { + } + + public static CreateProjectResponse conflict(String message) { + return error("CONFLICT", "PROJECT_ALREADY_EXISTS", message); + } + + public static CreateProjectResponse projectKeyGenerationFailed() { + return error(INTERNAL_ERROR, "PROJECT_KEY_GENERATION_FAILED", + "Failed to generate a unique project key."); + } + + public static CreateProjectResponse notFound(String projectKey) { + return error("NOT_FOUND", "PROJECT_NOT_FOUND", + String.format("Project with key '%s' not found", projectKey)); + } + + public static CreateProjectResponse internalError() { + return error(INTERNAL_ERROR, INTERNAL_ERROR, + "An error occurred while processing the request."); + } + + private static CreateProjectResponse error(String error, String errorKey, String message) { + CreateProjectResponse response = new CreateProjectResponse(); + response.setError(error); + response.setErrorKey(errorKey); + response.setMessage(message); + return response; + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java new file mode 100644 index 00000000..87e12af6 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectCreationException extends Exception { + + public ProjectCreationException(String message) { + super(message); + } + + public ProjectCreationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java new file mode 100644 index 00000000..e9d8e8b0 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectException extends Exception { + + public ProjectException(String message) { + super(message); + } + + public ProjectException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java new file mode 100644 index 00000000..be9b66a0 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ProjectKeyGenerationException extends Exception { + + public ProjectKeyGenerationException(String message) { + super(message); + } + + public ProjectKeyGenerationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java new file mode 100644 index 00000000..9d29f13a --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.facade; + +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; + +public interface ProjectsFacade { + + CreateProjectResponse createProject(CreateProjectRequest request) + throws ProjectCreationException, ProjectKeyGenerationException; + + CreateProjectResponse getProject(String projectKey) throws ProjectCreationException; +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java new file mode 100644 index 00000000..274ad1db --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -0,0 +1,35 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Component; + +@Component("apiProjectFacadeImpl") +public class ProjectsFacadeImpl implements ProjectsFacade { + + private final ProjectService projectService; + private final ProjectMapper projectMapper; + + public ProjectsFacadeImpl( + ProjectService projectService, + ProjectMapper projectMapper) { + this.projectService = projectService; + this.projectMapper = projectMapper; + } + + @Override + public CreateProjectResponse createProject(CreateProjectRequest request) + throws ProjectCreationException, ProjectKeyGenerationException { + return projectMapper.toApiResponse(projectService.createProject(projectMapper.toServiceRequest(request))); + } + + @Override + public CreateProjectResponse getProject(String projectKey) throws ProjectCreationException { + return projectMapper.toApiResponse(projectService.getProject(projectKey)); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java new file mode 100644 index 00000000..f9b24c8b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; + +@Mapper(componentModel = "spring") +public interface ProjectMapper { + + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest toServiceRequest( + CreateProjectRequest apiRequest); + + CreateProjectResponse toApiResponse( + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse); +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java new file mode 100644 index 00000000..39f5fd92 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -0,0 +1,132 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProjectControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + private ProjectController sut; + + @BeforeEach + void setup() { + sut = new ProjectController(projectsFacade); + } + + @Test + void createProject_whenSuccess_thenReturnOk() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + request.setProjectKey("PROJ01"); + + CreateProjectResponse serviceResponse = new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + serviceResponse.setMessage("The project creation process has been successfully initiated."); + + when(projectsFacade.createProject(any(CreateProjectRequest.class))) + .thenReturn(serviceResponse); + + ResponseEntity result = sut.createProject(request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); + } + + @Test + void createProject_whenProjectCreationException_thenReturnConflict() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + request.setProjectKey("EXISTING"); + + when(projectsFacade.createProject(any(CreateProjectRequest.class))) + .thenThrow(new ProjectCreationException("Project with key 'EXISTING' already exists")); + + ResponseEntity result = sut.createProject(request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo("CONFLICT"); + assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_ALREADY_EXISTS"); + assertThat(result.getBody().getMessage()).contains("already exists"); + } + + @Test + void createProject_whenProjectKeyGenerationException_thenReturnInternalServerError() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + + when(projectsFacade.createProject(any(CreateProjectRequest.class))) + .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); + + ResponseEntity result = sut.createProject(request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR"); + assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_KEY_GENERATION_FAILED"); + assertThat(result.getBody().getMessage()).isEqualTo("Failed to generate a unique project key."); + } + + @Test + void getProject_whenFound_thenReturnOk() throws Exception { + CreateProjectResponse serviceResponse = new CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + + when(projectsFacade.getProject("PROJ01")).thenReturn(serviceResponse); + + ResponseEntity result = sut.getProject("PROJ01"); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + verify(projectsFacade).getProject("PROJ01"); + } + + @Test + void getProject_whenNotFound_thenReturnNotFound() throws Exception { + when(projectsFacade.getProject("UNKNOWN")).thenReturn(null); + + ResponseEntity result = sut.getProject("UNKNOWN"); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo("NOT_FOUND"); + assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_NOT_FOUND"); + assertThat(result.getBody().getMessage()).contains("UNKNOWN"); + } + + @Test + void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception { + when(projectsFacade.getProject(anyString())) + .thenThrow(new ProjectCreationException("Database error")); + + ResponseEntity result = sut.getProject("PROJ01"); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(result.getBody()).isNotNull(); + assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR"); + assertThat(result.getBody().getErrorKey()).isEqualTo("INTERNAL_ERROR"); + assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + } + +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java new file mode 100644 index 00000000..dc0cf009 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -0,0 +1,92 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.project.mapper.ProjectMapper; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ProjectsFacadeImplTest { + + @Mock + private ProjectService projectService; + + private final ProjectMapper projectMapper = Mappers.getMapper(ProjectMapper.class); + + private ProjectsFacadeImpl sut; + + @BeforeEach + void setup() { + sut = new ProjectsFacadeImpl(projectService, projectMapper); + } + + @Test + void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + request.setProjectKey("PROJ01"); + + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = + new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Initiated"); + + when(projectService.createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + .thenReturn(serviceResponse); + + CreateProjectResponse response = sut.createProject(request); + + assertThat(response).isNotNull(); + assertThat(response.getProjectKey()).isEqualTo("PROJ01"); + assertThat(response.getStatus()).isEqualTo("Initiated"); + verify(projectService).createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class)); + } + + @Test + void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + CreateProjectRequest request = new CreateProjectRequest("My Project"); + when(projectService.createProject(org.mockito.ArgumentMatchers.any( + org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + .thenReturn(null); + + CreateProjectResponse response = sut.createProject(request); + + assertThat(response).isNull(); + } + + @Test + void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { + org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = + new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + serviceResponse.setProjectKey("PROJ01"); + serviceResponse.setStatus("Found"); + + when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + + CreateProjectResponse response = sut.getProject("PROJ01"); + + assertThat(response).isNotNull(); + assertThat(response.getProjectKey()).isEqualTo("PROJ01"); + assertThat(response.getStatus()).isEqualTo("Found"); + } + + @Test + void getProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + when(projectService.getProject("UNKNOWN")).thenReturn(null); + + CreateProjectResponse response = sut.getProject("UNKNOWN"); + + assertThat(response).isNull(); + } +} diff --git a/core/pom.xml b/core/pom.xml index 09a9ad9e..6705b443 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -107,6 +107,12 @@ ${project.version}
+ + org.opendevstack.apiservice + api-project + ${project.version} + + org.opendevstack.apiservice api-project-platform @@ -159,6 +165,7 @@ org.opendevstack.apiservice.core.DevstackApiServiceApplication ../docker + ${project.parent.basedir} diff --git a/pom.xml b/pom.xml index 4248e132..c825aa00 100644 --- a/pom.xml +++ b/pom.xml @@ -53,8 +53,10 @@ external-service-bitbucket external-service-jira external-service-webhookproxy + service-projects api-project-users api-project-platform + api-project @@ -173,6 +175,9 @@ **/*Test.java + + ${SPRING_CONFIG_DIR} + diff --git a/service-projects/pom.xml b/service-projects/pom.xml new file mode 100644 index 00000000..cd1a9313 --- /dev/null +++ b/service-projects/pom.xml @@ -0,0 +1,75 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + service-projects + Service Projects + Service module for project operations: key generation and project existence checks + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + org.opendevstack.apiservice + external-service-api + ${project.version} + + + + org.opendevstack.apiservice + external-service-bitbucket + ${project.version} + + + + org.opendevstack.apiservice + external-service-jira + ${project.version} + + + + org.opendevstack.apiservice + external-service-ocp + ${project.version} + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java new file mode 100644 index 00000000..0d1fa5f3 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectKeyGenerationException.java @@ -0,0 +1,13 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectKeyGenerationException extends Exception { + + public ProjectKeyGenerationException(String message) { + super(message); + } + + public ProjectKeyGenerationException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java new file mode 100644 index 00000000..22122b6e --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateProjectRequest { + + private String projectKey; + + private String projectKeyPattern; + + private String projectName; + + private String projectDescription; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java new file mode 100644 index 00000000..1dcd6077 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreateProjectResponse { + + private String projectKey; + + private String status; + + private String message; + + private String error; + + private String errorKey; + + private String errorDescription; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java new file mode 100644 index 00000000..289b9776 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java @@ -0,0 +1,11 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; + +public interface GenerateProjectKeyService { + + String DEFAULT_PROJECT_KEY_PATTERN = "SS%06d"; + + String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException; +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java new file mode 100644 index 00000000..63f2d668 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; + +public interface ProjectService { + + CreateProjectResponse createProject(CreateProjectRequest request); + + CreateProjectResponse getProject(String projectKey); +} + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java new file mode 100644 index 00000000..2e53781e --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java @@ -0,0 +1,141 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.security.SecureRandom; +import java.util.Comparator; +import java.util.Random; +import java.util.Set; + +@Service +@Slf4j +public class GenerateProjectKeyServiceImpl implements GenerateProjectKeyService { + + private static final int MAX_RETRIES = 10; + + private final OpenshiftService openshiftService; + + private final BitbucketService bitbucketService; + + private final JiraService jiraService; + + private final Random random; + + @Autowired + public GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService) { + this(bitbucketService, jiraService, openshiftService, new SecureRandom()); + } + + GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, Random random) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.random = random; + } + + @Override + public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException { + String pattern = resolveProjectKeyPattern(projectKeyPattern); + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + int randomNumber = random.nextInt(1_000_000); + String projectKey = String.format(pattern, randomNumber); + + if (!isProjectFound(projectKey)) { + log.debug("Generated unique project key '{}' on attempt {}", projectKey, attempt); + return projectKey; + } + + log.debug("Project key '{}' already exists (attempt {}/{})", projectKey, attempt, MAX_RETRIES); + } + + throw new ProjectKeyGenerationException( + String.format("Failed to generate unique project key after %d retries", MAX_RETRIES)); + } + + private String resolveProjectKeyPattern(String projectKeyPattern) { + if (projectKeyPattern == null || projectKeyPattern.isBlank()) { + return DEFAULT_PROJECT_KEY_PATTERN; + } + return projectKeyPattern; + } + + private boolean isProjectFound(String projectKey) throws ProjectKeyGenerationException { + try { + if (existsInAnyBitbucketInstance(projectKey)) { + return true; + } + + if (existsInAnyJiraInstance(projectKey)) { + return true; + } + + return existsInAnyOpenshift(projectKey); + } catch (BitbucketException e) { + throw new ProjectKeyGenerationException( + String.format("Failed to check project '%s' in Bitbucket", projectKey), e); + } catch (JiraException e) { + throw new ProjectKeyGenerationException( + String.format("Failed to check project '%s' in Jira", projectKey), e); + } catch (OpenshiftException e) { + throw new ProjectKeyGenerationException( + String.format("Failed to check project '%s' in Openshift", projectKey), e); + } + } + + private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { + Set instances = bitbucketService.getAvailableInstances(); + + for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (bitbucketService.projectExists(instanceName, projectKey)) { + return true; + } + } + + return false; + } + + private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { + Set instances = jiraService.getAvailableInstances(); + + if (instances == null || instances.isEmpty()) { + return jiraService.projectExists(projectKey); + } + + for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (jiraService.projectExists(instanceName, projectKey)) { + return true; + } + } + + return false; + } + + private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { + Set instances = openshiftService.getAvailableInstances(); + + if (instances == null || instances.isEmpty()) { + return false; + } + + for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (openshiftService.projectExists(instanceName, projectKey)) { + return true; + } + } + + return false; + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java new file mode 100644 index 00000000..3e8caabb --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -0,0 +1,37 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@AllArgsConstructor +public class ProjectServiceImpl implements ProjectService { + + private final OpenshiftService openshiftService; + + private final BitbucketService bitbucketService; + + private final JiraService jiraService; + + private final GenerateProjectKeyService generateProjectKeyService; + + @Override + public CreateProjectResponse createProject(CreateProjectRequest request) { + return CreateProjectResponse.builder().build(); + } + + @Override + public CreateProjectResponse getProject(String projectKey) { + return CreateProjectResponse.builder().build(); + } +} + diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java new file mode 100644 index 00000000..ebba5236 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java @@ -0,0 +1,98 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; + +import java.util.Random; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class GenerateProjectKeyServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + + @Mock + private JiraService jiraService; + + @Mock + private OpenshiftService openshiftService; + + @Mock + private Random random; + + private GenerateProjectKeyServiceImpl tested; + + @BeforeEach + void setup() { + tested = new GenerateProjectKeyServiceImpl(bitbucketService, jiraService, openshiftService, random); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("dev")); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("default")); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of()); + } + + @Test + void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(7); + when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); + when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + + String result = tested.generateProjectKey(null); + + assertThat(result).isEqualTo("SS000007"); + } + + @Test + void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(1, 2); + + when(bitbucketService.projectExists("dev", "SS000001")).thenReturn(true); + when(jiraService.projectExists("default", "SS000001")).thenReturn(false); + + when(bitbucketService.projectExists("dev", "SS000002")).thenReturn(false); + when(jiraService.projectExists("default", "SS000002")).thenReturn(false); + + String result = tested.generateProjectKey("SS%06d"); + + assertThat(result).isEqualTo("SS000002"); + } + + @Test + void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); + + when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); + + when(jiraService.projectExists(anyString(), anyString())).thenReturn(true); + + assertThatThrownBy(() -> tested.generateProjectKey("SS%06d")) + .isInstanceOf(ProjectKeyGenerationException.class) + .hasMessageContaining("Failed to generate unique project key after 10 retries"); + } + + @Test + void generateProjectKey_whenCustomPatternProvided_thenUseIt() throws Exception { + when(random.nextInt(1_000_000)).thenReturn(42); + when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); + when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + + String result = tested.generateProjectKey("AB%04d"); + + assertThat(result).isEqualTo("AB0042"); + } +} From 8f7c7fab20012345d381615b287dc582c4eb6a3d Mon Sep 17 00:00:00 2001 From: Angel MP Date: Wed, 18 Mar 2026 15:51:26 +0100 Subject: [PATCH 03/27] Chore/project service (#9) Co-authored-by: Jorge Romero --- api-project/openapi/api-project.yaml | 4 + api-project/pom.xml | 4 +- .../project/controller/ProjectController.java | 29 ++- .../controller/ProjectResponseFactory.java | 40 ++-- .../project/exception/ErrorKey.java | 48 ++++ .../project/exception/ErrorMessage.java | 12 + .../project/facade/ProjectsFacade.java | 2 +- .../facade/impl/ProjectsFacadeImpl.java | 2 +- .../project/mapper/ProjectMapper.java | 46 +++- .../controller/ProjectControllerTest.java | 35 ++- .../facade/impl/ProjectsFacadeImplTest.java | 25 ++- .../project/mapper/ProjectMapperTest.java | 207 ++++++++++++++++++ core/pom.xml | 15 ++ .../core/DevstackApiServiceApplication.java | 4 + .../apiservice/core/config/JacksonConfig.java | 21 ++ .../persistence/entity/ProjectEntity.java | 3 +- .../repository/ProjectRepository.java | 25 +-- .../PersistenceTestApplication.java | 2 - .../ProjectRepositoryIntegrationTest.java | 28 +-- .../repository/ProjectRepositoryTest.java | 14 +- .../src/test/resources/application.properties | 18 ++ pom.xml | 1 + service-projects/pom.xml | 35 +++ .../mapper/ProjectResponseMapper.java | 29 +++ ...rojectRequest.java => ProjectRequest.java} | 2 +- ...jectResponse.java => ProjectResponse.java} | 12 +- .../serviceproject/model/Status.java | 15 ++ .../service/ProjectService.java | 8 +- .../service/impl/ProjectServiceImpl.java | 27 ++- .../service/impl/ProjectServiceImplTest.java | 192 ++++++++++++++++ 30 files changed, 785 insertions(+), 120 deletions(-) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java create mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java create mode 100644 persistence/src/test/resources/application.properties create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java rename service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/{CreateProjectRequest.java => ProjectRequest.java} (90%) rename service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/{CreateProjectResponse.java => ProjectResponse.java} (57%) create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 8fbd57b2..9c83d307 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -142,6 +142,8 @@ components: type: string status: type: string + projectFlavor: + type: string message: type: string error: @@ -150,3 +152,5 @@ components: type: string errorDescription: type: string + location: + type: string diff --git a/api-project/pom.xml b/api-project/pom.xml index 8daceb0d..e77eda9f 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -74,7 +74,7 @@ org.mapstruct mapstruct - 1.6.3 + ${mapstruct.version} @@ -111,7 +111,7 @@ org.mapstruct mapstruct-processor - 1.6.3 + ${mapstruct.version} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index a17d338b..fe8012dd 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -26,38 +26,51 @@ public class ProjectController implements ProjectsApi { public static final String API_BASE_PATH = "/api/pub/v0/projects"; + private static final String HTTP_HEADER_LOCATION = "Location"; + private final ProjectsFacade projectsFacade; @PostMapping @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { try { - return ResponseEntity.ok(projectsFacade.createProject(createProjectRequest)); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(projectsFacade.createProject(createProjectRequest)); } catch (ProjectCreationException e) { log.error("Project creation conflict: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.CONFLICT) - .body(ProjectResponseFactory.conflict(e.getMessage())); + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(ProjectResponseFactory.conflict(e.getMessage(), API_BASE_PATH)); } catch (ProjectKeyGenerationException e) { log.error("Failed to generate project key: {}", e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ProjectResponseFactory.projectKeyGenerationFailed()); + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH)); } } @GetMapping("/{projectKey}") @Override public ResponseEntity getProject(@PathVariable String projectKey) { + String location = API_BASE_PATH + "/" + projectKey; try { CreateProjectResponse response = projectsFacade.getProject(projectKey); if (response == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(ProjectResponseFactory.notFound(projectKey)); + .header(HTTP_HEADER_LOCATION, location) + .body(ProjectResponseFactory.notFound(projectKey, location)); } - return ResponseEntity.ok(response); - } catch (ProjectCreationException e) { - log.error("Error retrieving project '{}': {}", projectKey, e.getMessage(), e); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, location) + .body(response); + } catch (Exception e) { + log.error("Unexpected error retrieving project '{}': {}", projectKey, e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ProjectResponseFactory.internalError()); + .header(HTTP_HEADER_LOCATION, location) + .body(ProjectResponseFactory.internalError(location)); } } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java index 6a6faae3..b1ed5d2a 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -1,38 +1,50 @@ package org.opendevstack.apiservice.project.controller; +import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.model.CreateProjectResponse; public final class ProjectResponseFactory { - private static final String INTERNAL_ERROR = "INTERNAL_ERROR"; - private ProjectResponseFactory() { } - public static CreateProjectResponse conflict(String message) { - return error("CONFLICT", "PROJECT_ALREADY_EXISTS", message); + public static CreateProjectResponse conflict(String message, String location) { + return error( + ErrorKey.PROJECT_ALREADY_EXISTS.getMessage(), + ErrorKey.PROJECT_ALREADY_EXISTS.getKey(), + message, location); } - public static CreateProjectResponse projectKeyGenerationFailed() { - return error(INTERNAL_ERROR, "PROJECT_KEY_GENERATION_FAILED", - "Failed to generate a unique project key."); + public static CreateProjectResponse projectKeyGenerationFailed(String location) { + return error(ErrorKey.INTERNAL_ERROR.getMessage(), + "PROJECT_KEY_GENERATION_FAILED", + "Failed to generate a unique project key.", + location); } - public static CreateProjectResponse notFound(String projectKey) { - return error("NOT_FOUND", "PROJECT_NOT_FOUND", - String.format("Project with key '%s' not found", projectKey)); + public static CreateProjectResponse notFound(String projectKey, String location) { + return error( + ErrorKey.PROJECT_NOT_FOUND.getMessage(), + ErrorKey.PROJECT_NOT_FOUND.getKey(), + String.format("Project with key '%s' not found", projectKey), + location + ); } - public static CreateProjectResponse internalError() { - return error(INTERNAL_ERROR, INTERNAL_ERROR, - "An error occurred while processing the request."); + public static CreateProjectResponse internalError(String location) { + return error( + ErrorKey.INTERNAL_ERROR.getMessage(), + ErrorKey.INTERNAL_ERROR.getKey(), + "An error occurred while processing the request.", + location); } - private static CreateProjectResponse error(String error, String errorKey, String message) { + private static CreateProjectResponse error(String error, String errorKey, String message, String location) { CreateProjectResponse response = new CreateProjectResponse(); response.setError(error); response.setErrorKey(errorKey); response.setMessage(message); + response.setLocation(location); return response; } } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java new file mode 100644 index 00000000..4ee644d5 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -0,0 +1,48 @@ +package org.opendevstack.apiservice.project.exception; + +public enum ErrorKey { + + OK("000", "Success"), + PRODUCT_NOT_FOUND("001", ErrorMessage.NOT_FOUND), + ACCESS_DENIED("002", ErrorMessage.FORBIDDEN), + INTERNAL_ERROR("003", "Internal error"), + INVALID_AUTH_HEADER("004", ErrorMessage.BAD_REQUEST), + MISSING_AUTH_HEADER("005", ErrorMessage.BAD_REQUEST), + INVALID_PARAMETERS("006", ErrorMessage.BAD_REQUEST), + X2_ACCOUNT_MISSING_GROUPS("007", ErrorMessage.NOT_FOUND), + ONLY_INVITED_PROJECT("008", ErrorMessage.FORBIDDEN), + ONCE_PER_PROJECT("009", ErrorMessage.FORBIDDEN), + COMPONENT_PARAM_NOT_MEET_REGEX("010", ErrorMessage.BAD_REQUEST), + INVALID_LOCATION("011", ErrorMessage.BAD_REQUEST), + PROJECT_NOT_FOUND("012", ErrorMessage.NOT_FOUND), + COMPONENT_NOT_FOUND("013", ErrorMessage.NOT_FOUND), + BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), + FORBIDDEN("015", ErrorMessage.FORBIDDEN), + DUPLICATE_RECORD("016", "Record already exists"), + COMPONENT_PARAM_INVALID_FORMAT("017", ErrorMessage.BAD_REQUEST), + PROJECT_KEY_INVALID_FORMAT("018", "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"), + PROJECT_NAME_INVALID_FORMAT("019", "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"), + PROJECT_DESCRIPTION_INVALID_FORMAT("020", "projectDescription not met the pattern ^.{0,255}$"), + PROJECT_OWNER_INVALID_FORMAT("021", "projectOwner not met the pattern ^[a-z]{1,10}$"), + PROJECT_X2ACCOUNT_INVALID_FORMAT("022", "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"), + BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"), + MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"), + PROJECT_ALREADY_EXISTS("025", "Project already exists"), + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"); + + private String key; + private String message; + + ErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return this.key; + } + + public String getMessage() { + return this.message; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java new file mode 100644 index 00000000..767ab2e2 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ErrorMessage { + + public static final String NOT_FOUND = "Not Found"; + public static final String FORBIDDEN = "Forbidden"; + public static final String BAD_REQUEST = "Bad Request"; + + private ErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index 9d29f13a..b30ad4f4 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -10,5 +10,5 @@ public interface ProjectsFacade { CreateProjectResponse createProject(CreateProjectRequest request) throws ProjectCreationException, ProjectKeyGenerationException; - CreateProjectResponse getProject(String projectKey) throws ProjectCreationException; + CreateProjectResponse getProject(String projectKey); } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 274ad1db..ea66ee83 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -29,7 +29,7 @@ public CreateProjectResponse createProject(CreateProjectRequest request) } @Override - public CreateProjectResponse getProject(String projectKey) throws ProjectCreationException { + public CreateProjectResponse getProject(String projectKey) { return projectMapper.toApiResponse(projectService.getProject(projectKey)); } } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java index f9b24c8b..181b571c 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -1,15 +1,49 @@ package org.opendevstack.apiservice.project.mapper; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import java.text.MessageFormat; @Mapper(componentModel = "spring") public interface ProjectMapper { - - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest toServiceRequest( - CreateProjectRequest apiRequest); - - CreateProjectResponse toApiResponse( - org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse); + + ProjectRequest toServiceRequest(CreateProjectRequest apiRequest); + + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation") + @Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription") + CreateProjectResponse toApiResponse(ProjectResponse serviceResponse); + + @Named("mapStatus") + default String mapStatus(Status status) { + if (status == null) { + return null; + } + return status.getDbValue(); + } + + @Named("mapErrorDescription") + default String mapErrorDescription(ProjectResponse serviceResponse) { + return (serviceResponse.getStatus() == Status.FAILED) + ? MessageFormat.format( + "There was an error when creating the project {0}.\n\n " + + "The error has been reported to our Support team as an incident. " + + "You will be informed about the incident via email.", serviceResponse.getProjectKey()) + : null; + } + + @Named("mapLocation") + default String mapLocation(String projectKey) { + if (projectKey == null || projectKey.isEmpty()) { + return null; + } + return "/api/pub/v0/projects/" + projectKey; + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index 39f5fd92..5e57b723 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -51,6 +51,9 @@ void createProject_whenSuccess_thenReturnOk() throws Exception { assertThat(result.getBody()).isNotNull(); assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); + assertThat(result.getBody().getError()).isNull(); + assertThat(result.getBody().getErrorKey()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test @@ -65,9 +68,12 @@ void createProject_whenProjectCreationException_thenReturnConflict() throws Exce assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("CONFLICT"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_ALREADY_EXISTS"); - assertThat(result.getBody().getMessage()).contains("already exists"); + assertThat(result.getBody().getError()).isEqualTo("Project already exists"); + assertThat(result.getBody().getErrorKey()).isEqualTo("025"); + assertThat(result.getBody().getMessage()).contains("Project with key 'EXISTING' already exists"); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test @@ -81,9 +87,12 @@ void createProject_whenProjectKeyGenerationException_thenReturnInternalServerErr assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR"); + assertThat(result.getBody().getError()).isEqualTo("Internal error"); assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_KEY_GENERATION_FAILED"); assertThat(result.getBody().getMessage()).isEqualTo("Failed to generate a unique project key."); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test @@ -99,6 +108,8 @@ void getProject_whenFound_thenReturnOk() throws Exception { assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()).isNotNull(); assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); + assertThat(result.getBody().getError()).isNull(); + assertThat(result.getBody().getErrorKey()).isNull(); verify(projectsFacade).getProject("PROJ01"); } @@ -110,23 +121,29 @@ void getProject_whenNotFound_thenReturnNotFound() throws Exception { assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("NOT_FOUND"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_NOT_FOUND"); + assertThat(result.getBody().getError()).isEqualTo("Not Found"); + assertThat(result.getBody().getErrorKey()).isEqualTo("012"); assertThat(result.getBody().getMessage()).contains("UNKNOWN"); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } @Test void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception { when(projectsFacade.getProject(anyString())) - .thenThrow(new ProjectCreationException("Database error")); + .thenThrow(new RuntimeException("Database error")); ResponseEntity result = sut.getProject("PROJ01"); assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("INTERNAL_ERROR"); - assertThat(result.getBody().getErrorKey()).isEqualTo("INTERNAL_ERROR"); + assertThat(result.getBody().getError()).isEqualTo("Internal error"); + assertThat(result.getBody().getErrorKey()).isEqualTo("003"); assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + assertThat(result.getBody().getProjectKey()).isNull(); + assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorDescription()).isNull(); } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index dc0cf009..c11a5964 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -9,6 +9,9 @@ import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import static org.assertj.core.api.Assertions.assertThat; @@ -35,29 +38,29 @@ void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception CreateProjectRequest request = new CreateProjectRequest("My Project"); request.setProjectKey("PROJ01"); - org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = - new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + ProjectResponse serviceResponse = + new ProjectResponse(); serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus("Initiated"); + serviceResponse.setStatus(Status.PENDING); when(projectService.createProject(org.mockito.ArgumentMatchers.any( - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + ProjectRequest.class))) .thenReturn(serviceResponse); CreateProjectResponse response = sut.createProject(request); assertThat(response).isNotNull(); assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Initiated"); + assertThat(response.getStatus()).isEqualTo("Pending"); verify(projectService).createProject(org.mockito.ArgumentMatchers.any( - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class)); + ProjectRequest.class)); } @Test void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { CreateProjectRequest request = new CreateProjectRequest("My Project"); when(projectService.createProject(org.mockito.ArgumentMatchers.any( - org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest.class))) + ProjectRequest.class))) .thenReturn(null); CreateProjectResponse response = sut.createProject(request); @@ -67,10 +70,10 @@ void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { @Test void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse serviceResponse = - new org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse(); + ProjectResponse serviceResponse = + new ProjectResponse(); serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus("Found"); + serviceResponse.setStatus(Status.RUNNING); when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); @@ -78,7 +81,7 @@ void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { assertThat(response).isNotNull(); assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Found"); + assertThat(response.getStatus()).isEqualTo("Running"); } @Test diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java new file mode 100644 index 00000000..b7cf95a5 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -0,0 +1,207 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ProjectMapperTest { + + private ProjectMapper projectMapper; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectMapper = Mappers.getMapper(ProjectMapper.class); + } + + @Test + void to_service_request_maps_all_fields_correctly() { + CreateProjectRequest apiRequest = new CreateProjectRequest("My Project"); + apiRequest.setProjectKey("PROJ01"); + apiRequest.setProjectKeyPattern("SS%06d"); + apiRequest.setProjectDescription("A test project"); + + ProjectRequest result = projectMapper.toServiceRequest(apiRequest); + + assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("SS%06d", result.getProjectKeyPattern()); + assertEquals("My Project", result.getProjectName()); + assertEquals("A test project", result.getProjectDescription()); + } + + @Test + void to_service_request_returns_null_when_input_is_null() { + CreateProjectRequest apiRequest = null; + + ProjectRequest result = projectMapper.toServiceRequest(apiRequest); + + assertNull(result); + } + + @Test + void to_service_request_maps_only_required_field() { + CreateProjectRequest apiRequest = new CreateProjectRequest("Only Name"); + + ProjectRequest result = projectMapper.toServiceRequest(apiRequest); + + assertNotNull(result); + assertNull(result.getProjectKey()); + assertNull(result.getProjectKeyPattern()); + assertEquals("Only Name", result.getProjectName()); + assertNull(result.getProjectDescription()); + } + + @Test + void to_api_response_maps_all_fields_correctly() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.PENDING) + .projectFlavor("AMP") + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Pending", result.getStatus()); + assertEquals("AMP", result.getProjectFlavor()); + assertEquals("/api/pub/v0/projects/PROJ01", result.getLocation()); + } + + @Test + void to_api_response_returns_null_when_input_is_null() { + ProjectResponse serviceResponse = null; + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNull(result); + } + + @Test + void to_api_response_maps_with_null_status() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ02") + .status(null) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ02", result.getProjectKey()); + assertNull(result.getStatus()); + assertEquals("/api/pub/v0/projects/PROJ02", result.getLocation()); + } + + @Test + void to_api_response_does_not_set_location_when_project_key_is_null() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey(null) + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertNull(result.getProjectKey()); + assertEquals("Running", result.getStatus()); + assertNull(result.getLocation()); + } + + @Test + void to_api_response_does_not_set_location_when_project_key_is_empty() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("", result.getProjectKey()); + assertEquals("Failed", result.getStatus()); + assertNull(result.getLocation()); + } + + @Test + void to_api_response_maps_status_running_to_db_value() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("RUN01") + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("Running", result.getStatus()); + } + + @Test + void to_api_response_maps_status_failed_to_db_value() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("FAIL01") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("Failed", result.getStatus()); + } + + @Test + void to_api_response_maps_null_project_flavor() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ03") + .status(Status.PENDING) + .projectFlavor(null) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals("PROJ03", result.getProjectKey()); + assertNull(result.getProjectFlavor()); + } + + @Test + void to_api_response_sets_error_description_when_status_is_failed() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("FAIL01") + .status(Status.FAILED) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertEquals( + "There was an error when creating the project FAIL01.\n\n " + + "The error has been reported to our Support team as an incident. " + + "You will be informed about the incident via email.", + result.getErrorDescription() + ); + } + + @Test + void to_api_response_sets_null_error_description_when_status_is_not_failed() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + + CreateProjectResponse result = projectMapper.toApiResponse(serviceResponse); + + assertNotNull(result); + assertNull(result.getErrorDescription()); + } +} diff --git a/core/pom.xml b/core/pom.xml index 6705b443..c3aa14b9 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -41,6 +41,14 @@ spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-devtools + runtime + true + + org.opendevstack.apiservice @@ -119,6 +127,13 @@ ${project.version} + + + org.opendevstack.apiservice + service-projects + ${project.version} + + org.opendevstack.apiservice diff --git a/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java b/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java index bcb48664..f13e57a2 100644 --- a/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java +++ b/core/src/main/java/org/opendevstack/apiservice/core/DevstackApiServiceApplication.java @@ -2,10 +2,14 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; @SpringBootApplication(scanBasePackages = { "org.opendevstack.apiservice" }) @EnableCaching +@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") +@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") public class DevstackApiServiceApplication { public static void main(String[] args) { diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java new file mode 100644 index 00000000..da25ff0b --- /dev/null +++ b/core/src/main/java/org/opendevstack/apiservice/core/config/JacksonConfig.java @@ -0,0 +1,21 @@ +package org.opendevstack.apiservice.core.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Global Jackson configuration. + * Ensures that null properties are omitted from all JSON responses. + */ +@Configuration +public class JacksonConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL); + } +} + + diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java index 635a1e7e..f3299cb9 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java @@ -78,7 +78,8 @@ public class ProjectEntity { private String projectFlavor; /** - * Provisioning status. Known values: {@code Failed}, {@code Pending} (null = completed). + * Provisioning status. Known values: {@code Pending}, {@code Running}, {@code Failed} + * ({@code null} = completed successfully). */ @Column(name = "status", length = 50) private String status; diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java index 68f344cc..2a5dc000 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java @@ -20,26 +20,11 @@ */ @Repository public interface ProjectRepository extends JpaRepository { - - /** - * Finds a project by its unique business key regardless of soft-delete status. Use - * this when you explicitly need to handle deleted projects (e.g. restore flows). - * @param projectKey the Atlassian-style project key - * @return the matching project, or {@link Optional#empty()} if it has never existed - */ - Optional findByProjectKey(String projectKey); - - /** - * Returns all active (non-deleted) projects. - * @return list of active projects; empty list if none exist - */ + + Optional findByProjectKeyIgnoreCase(String projectKey); + List findByDeletedFalse(); - - /** - * Checks whether an active project with the given key already exists. - * @param projectKey the project key to look up - * @return {@code true} if an active project with this key exists - */ - boolean existsByProjectKey(String projectKey); + + boolean existsByProjectKeyIgnoreCase(String projectKey); } diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java index 164c08ef..0360a184 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/PersistenceTestApplication.java @@ -10,8 +10,6 @@ */ @SpringBootConfiguration @EnableAutoConfiguration -@EntityScan(basePackages = "org.opendevstack.apiservice.persistence.entity") -@EnableJpaRepositories(basePackages = "org.opendevstack.apiservice.persistence.repository") public class PersistenceTestApplication { } \ No newline at end of file diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java index be517a67..b944e73e 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/integration/ProjectRepositoryIntegrationTest.java @@ -113,19 +113,17 @@ void tearDown() { } private void cleanupTestData() { - TEST_KEYS.forEach(key -> projectRepository.findByProjectKey(key).ifPresent(projectRepository::delete)); + TEST_KEYS.forEach(key -> projectRepository.findByProjectKeyIgnoreCase(key).ifPresent(projectRepository::delete)); } - // ── findByProjectKey ────────────────────────────────────────────────────── - @Nested - @DisplayName("findByProjectKey") - class FindByProjectKey { + @DisplayName("findByProjectKeyIgnoreCase") + class findByProjectKeyIgnoreCase { @Test @DisplayName("returns active project by its key") void returnsActiveProjectByKey() { - Optional result = projectRepository.findByProjectKey("IT-ACTIVE-01"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-ACTIVE-01"); assertThat(result).isPresent(); assertThat(result.get().getProjectKey()).isEqualTo("IT-ACTIVE-01"); @@ -135,7 +133,7 @@ void returnsActiveProjectByKey() { @Test @DisplayName("returns soft-deleted project by its key") void returnsDeletedProjectByKey() { - Optional result = projectRepository.findByProjectKey("IT-DELETED-01"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-DELETED-01"); assertThat(result).isPresent(); assertThat(result.get().getProjectKey()).isEqualTo("IT-DELETED-01"); @@ -145,7 +143,7 @@ void returnsDeletedProjectByKey() { @Test @DisplayName("returns empty Optional for a key that has never existed") void returnsEmptyForUnknownKey() { - Optional result = projectRepository.findByProjectKey("NONEXISTENTKEY"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("NONEXISTENTKEY"); assertThat(result).isEmpty(); } @@ -153,7 +151,7 @@ void returnsEmptyForUnknownKey() { @Test @DisplayName("returned entity contains all persisted fields") void returnedEntityContainsAllFields() { - Optional result = projectRepository.findByProjectKey("IT-DELETED-01"); + Optional result = projectRepository.findByProjectKeyIgnoreCase("IT-DELETED-01"); assertThat(result).isPresent(); ProjectEntity entity = result.get(); @@ -164,8 +162,6 @@ void returnedEntityContainsAllFields() { } } - // ── findByDeletedFalse ──────────────────────────────────────────────────── - @Nested @DisplayName("findByDeletedFalse") class FindByDeletedFalse { @@ -212,24 +208,22 @@ class ExistsByProjectKey { @Test @DisplayName("returns true for an existing active project key") void returnsTrueForActiveKey() { - assertThat(projectRepository.existsByProjectKey("IT-ACTIVE-01")).isTrue(); + assertThat(projectRepository.existsByProjectKeyIgnoreCase("IT-ACTIVE-01")).isTrue(); } @Test @DisplayName("returns true for a soft-deleted project key") void returnsTrueForDeletedKey() { - assertThat(projectRepository.existsByProjectKey("IT-DELETED-01")).isTrue(); + assertThat(projectRepository.existsByProjectKeyIgnoreCase("IT-DELETED-01")).isTrue(); } @Test @DisplayName("returns false for a key that has never existed") void returnsFalseForNonExistentKey() { - assertThat(projectRepository.existsByProjectKey("NONEXISTENTKEY")).isFalse(); + assertThat(projectRepository.existsByProjectKeyIgnoreCase("NONEXISTENTKEY")).isFalse(); } } - - // ── Persistence lifecycle ───────────────────────────────────────────────── - + @Nested @DisplayName("Persistence lifecycle") class PersistenceLifecycle { diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java index e067117d..ca30d904 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ProjectRepositoryTest.java @@ -123,24 +123,22 @@ void save_duplicateProjectKey_throwsException() { } } - - // ── findByProjectKey ────────────────────────────────────────────────────── - + @Nested - @DisplayName("findByProjectKey") - class FindByProjectKey { + @DisplayName("findByProjectKeyIgnoreCase") + class findByProjectKeyIgnoreCase { @Test @DisplayName("returns project regardless of deleted flag") void returnsProjectRegardlessOfDeletedFlag() { - assertThat(repository.findByProjectKey("ACTIVE-01")).isPresent(); - assertThat(repository.findByProjectKey("DELETED-01")).isPresent(); + assertThat(repository.findByProjectKeyIgnoreCase("ACTIVE-01")).isPresent(); + assertThat(repository.findByProjectKeyIgnoreCase("DELETED-01")).isPresent(); } @Test @DisplayName("returns empty for unknown key") void returnsEmptyForUnknownKey() { - assertThat(repository.findByProjectKey("UNKNOWN")).isEmpty(); + assertThat(repository.findByProjectKeyIgnoreCase("UNKNOWN")).isEmpty(); } } diff --git a/persistence/src/test/resources/application.properties b/persistence/src/test/resources/application.properties new file mode 100644 index 00000000..bee83c56 --- /dev/null +++ b/persistence/src/test/resources/application.properties @@ -0,0 +1,18 @@ +# ── HikariCP tuning for Testcontainers ───────────────────────────────────────── +# +# When the @Container PostgreSQL instance stops (after the test class finishes), +# HikariCP's background keepalive thread tries to validate open connections and +# logs "Failed to validate connection" warnings. These settings eliminate those +# warnings and make the pool give up quickly on unreachable connections during +# teardown — they have no effect on test correctness. + +# Disable background keepalive probes entirely (default: 0 = disabled already, +# but some Spring Boot auto-config versions set it higher). +spring.datasource.hikari.keepalive-time=0 + +# Reduce how long HikariCP waits to acquire a connection (default: 30 000 ms). +# During teardown this prevents the pool from blocking on a dead container. +spring.datasource.hikari.connection-timeout=3000 + +# Reduce how long HikariCP waits to validate a connection (default: 5 000 ms). +spring.datasource.hikari.validation-timeout=1000 diff --git a/pom.xml b/pom.xml index c825aa00..6076af29 100644 --- a/pom.xml +++ b/pom.xml @@ -36,6 +36,7 @@ 0.0.47 0.2.7 3.6.1 + 1.6.3 diff --git a/service-projects/pom.xml b/service-projects/pom.xml index cd1a9313..2a82727a 100644 --- a/service-projects/pom.xml +++ b/service-projects/pom.xml @@ -35,6 +35,18 @@ ${jackson-databind-nullable.version} + + org.mapstruct + mapstruct + ${mapstruct.version} + + + + org.opendevstack.apiservice + persistence + ${project.version} + + org.opendevstack.apiservice external-service-api @@ -71,5 +83,28 @@ test + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + ${mapstruct.version} + + + + + + diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java new file mode 100644 index 00000000..18a64916 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.serviceproject.mapper; + +import java.util.Arrays; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; + +@Mapper(componentModel = "spring") +public interface ProjectResponseMapper { + + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + ProjectResponse toCreateProjectResponse(ProjectEntity entity); + + @Named("mapStatus") + default Status mapStatus(String value) { + if (value == null) { + return null; + } + + return Arrays.stream(Status.values()) + .filter(s -> s.getDbValue().equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "Unknown Status db value: '" + value + "'")); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java similarity index 90% rename from service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java rename to service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java index 22122b6e..186ffaae 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectRequest.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -7,7 +7,7 @@ @Data @NoArgsConstructor @AllArgsConstructor -public class CreateProjectRequest { +public class ProjectRequest { private String projectKey; diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java similarity index 57% rename from service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java rename to service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java index 1dcd6077..40ee62ac 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/CreateProjectResponse.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java @@ -9,17 +9,11 @@ @NoArgsConstructor @AllArgsConstructor @Builder -public class CreateProjectResponse { +public class ProjectResponse { private String projectKey; - private String status; + private Status status; - private String message; - - private String error; - - private String errorKey; - - private String errorDescription; + private String projectFlavor; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java new file mode 100644 index 00000000..960efc64 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/Status.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.serviceproject.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum Status { + + PENDING("Pending"), + RUNNING("Running"), + FAILED("Failed"); + + private final String dbValue; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java index 63f2d668..c2abcc6d 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -1,12 +1,12 @@ package org.opendevstack.apiservice.serviceproject.service; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; public interface ProjectService { - CreateProjectResponse createProject(CreateProjectRequest request); + ProjectResponse createProject(ProjectRequest request); - CreateProjectResponse getProject(String projectKey); + ProjectResponse getProject(String projectKey); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 3e8caabb..1d8d7b62 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -5,12 +5,17 @@ import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; import org.opendevstack.apiservice.externalservice.jira.service.JiraService; import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectRequest; -import org.opendevstack.apiservice.serviceproject.model.CreateProjectResponse; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; import org.springframework.stereotype.Service; +import java.util.Optional; + @Service @Slf4j @AllArgsConstructor @@ -23,15 +28,25 @@ public class ProjectServiceImpl implements ProjectService { private final JiraService jiraService; private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectRepository projectRepository; + + private final ProjectResponseMapper projectResponseMapper; @Override - public CreateProjectResponse createProject(CreateProjectRequest request) { - return CreateProjectResponse.builder().build(); + public ProjectResponse createProject(ProjectRequest request) { + return ProjectResponse.builder().build(); } @Override - public CreateProjectResponse getProject(String projectKey) { - return CreateProjectResponse.builder().build(); + public ProjectResponse getProject(String projectKey) { + Optional project = projectRepository.findByProjectKeyIgnoreCase(projectKey); + + if (project.isPresent()) { + return projectResponseMapper.toCreateProjectResponse(project.get()); + } + + return null; } } diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java new file mode 100644 index 00000000..827ba411 --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -0,0 +1,192 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.persistence.entity.ProjectEntity; +import org.opendevstack.apiservice.persistence.repository.ProjectRepository; +import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; + +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectServiceImplTest { + + @Mock + private OpenshiftService openshiftService; + + @Mock + private BitbucketService bitbucketService; + + @Mock + private JiraService jiraService; + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private ProjectRepository projectRepository; + + @Mock + private ProjectResponseMapper projectResponseMapper; + + private ProjectServiceImpl projectService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + projectService = new ProjectServiceImpl( + openshiftService, + bitbucketService, + jiraService, + generateProjectKeyService, + projectRepository, + projectResponseMapper + ); + } + + @Test + void get_project_returns_response_when_project_exists() { + + String projectKey = "MY-PROJECT"; + UUID projectId = UUID.randomUUID(); + + ProjectEntity projectEntity = ProjectEntity.builder() + .id(projectId) + .projectKey(projectKey) + .projectName("My Project") + .description("Test project") + .configurationItem("CI-123") + .location("eu") + .projectFlavor("AMP") + .status("Completed") + .deleted(false) + .ldapGroupManager("cn=my-project-manager,ou=groups,dc=example,dc=com") + .ldapGroupTeam("cn=my-project-team,ou=groups,dc=example,dc=com") + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey(projectKey) + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(projectEntity)); + when(projectResponseMapper.toCreateProjectResponse(projectEntity)).thenReturn(expectedResponse); + + + ProjectResponse result = projectService.getProject(projectKey); + + + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + assertEquals(Status.RUNNING, result.getStatus()); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + verify(projectResponseMapper).toCreateProjectResponse(projectEntity); + } + + @Test + void get_project_returns_null_when_project_does_not_exist() { + + String projectKey = "NON-EXISTING"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); + + + ProjectResponse result = projectService.getProject(projectKey); + + + assertNull(result); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + verify(projectResponseMapper, never()).toCreateProjectResponse(any()); + } + + @Test + void create_project_returns_empty_response() { + + ProjectRequest request = new ProjectRequest(); + request.setProjectKey("NEW-PROJECT"); + request.setProjectKeyPattern("NEW%06d"); + request.setProjectName("New Project"); + request.setProjectDescription("New test project"); + + + ProjectResponse result = projectService.createProject(request); + + + assertNotNull(result); + assertNull(result.getProjectKey()); + } + + @Test + void get_project_propagates_repository_exception() { + + String projectKey = "ERROR-PROJECT"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)) + .thenThrow(new RuntimeException("Database connection error")); + + assertThrows(RuntimeException.class, () -> projectService.getProject(projectKey)); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } + + @Test + void get_project_returns_null_when_project_key_is_null() { + + when(projectRepository.findByProjectKeyIgnoreCase(null)).thenReturn(Optional.empty()); + + + ProjectResponse result = projectService.getProject(null); + + + assertNull(result); + verify(projectRepository).findByProjectKeyIgnoreCase(null); + } + + @Test + void get_project_returns_response_for_soft_deleted_project() { + + String projectKey = "DELETED-PROJECT"; + + ProjectEntity deletedEntity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("Deleted Project") + .configurationItem("CI-456") + .location("eu") + .deleted(true) + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey(projectKey) + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(deletedEntity)); + when(projectResponseMapper.toCreateProjectResponse(deletedEntity)).thenReturn(expectedResponse); + + + ProjectResponse result = projectService.getProject(projectKey); + + + assertNotNull(result); + assertEquals(projectKey, result.getProjectKey()); + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } +} \ No newline at end of file From f7ce0c5d6a605048eabb404863b0d06650201a7e Mon Sep 17 00:00:00 2001 From: Vali Tuguran Date: Mon, 23 Mar 2026 13:41:01 +0100 Subject: [PATCH 04/27] Add api-project-component-v0 module. (#10) Added project components module functionality for v0. --------- Co-authored-by: valeriuc --- .gitignore | 2 + .../openapi/api-project-component-v0.yaml | 166 ++++++++++++++++++ api-project-component-v0/pom.xml | 163 +++++++++++++++++ .../controller/ComponentsResponseFactory.java | 24 +++ .../ProjectComponentsController.java | 52 ++++++ .../project/facade/ComponentsFacade.java | 43 +++++ .../mapper/ComponentResponseMapper.java | 15 ++ .../ProjectComponentsControllerTest.java | 100 +++++++++++ .../project/facade/ComponentsServiceTest.java | 74 ++++++++ .../project/util/TestObjectsBuilder.java | 42 +++++ pom.xml | 1 + 11 files changed, 682 insertions(+) create mode 100644 api-project-component-v0/openapi/api-project-component-v0.yaml create mode 100644 api-project-component-v0/pom.xml create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java diff --git a/.gitignore b/.gitignore index 5183737d..c765529b 100644 --- a/.gitignore +++ b/.gitignore @@ -95,5 +95,7 @@ api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/a api-project-platform/src/main/java/org/opendevstack/apiservice/projectplatform/model api-project/src/main/java/org/opendevstack/apiservice/project/api api-project/src/main/java/org/opendevstack/apiservice/project/model +api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/api +api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/model **/.openapi-generator diff --git a/api-project-component-v0/openapi/api-project-component-v0.yaml b/api-project-component-v0/openapi/api-project-component-v0.yaml new file mode 100644 index 00000000..dcd3912a --- /dev/null +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -0,0 +1,166 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: + - name: ProjectComponents + description: API for managing project components + +paths: + /projects/{projectId}/components/: + post: + tags: + - projectComponents + summary: Create a component in a project + operationId: createProjectComponent + description: Retrieves information about a specific component + parameters: + - name: projectId + in: path + required: true + description: Project id + schema: + type: string + requestBody: + description: Component data + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentRequest' + responses: + '201': + description: Created + headers: + Location: + schema: + type: string + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' + '400': + description: Bad Request + '401': + $ref: '#/components/responses/UnauthorizedError' + '403': + description: Forbidden + '404': + description: Endpoint not found / Project not found / Product not found + '500': + description: Internal Server Error + + /projects/{projectId}/components/{componentId}: + get: + tags: + - projectComponents + summary: Get component information + operationId: getProjectComponent + description: Retrieves information about a specific component + parameters: + - name: projectId + in: path + required: true + description: Project id + schema: + type: string + - name: componentId + in: path + required: true + description: Component id + schema: + type: string + responses: + '200': + description: Component information + content: + application/json: + schema: + $ref: '#/components/schemas/Component' + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + responses: + UnauthorizedError: + description: Authentication information is missing or invalid + headers: + WWW_Authenticate: + schema: + type: string + schemas: + Component: + type: object + properties: + id: + type: string + description: Component ID + name: + type: string + description: Name of the component + productDescription: + type: string + description: Description of the product + productName: + type: string + description: Name of the product (e.g. Docker plain) + productId: + type: string + description: Product ID + environment: + type: string + description: Environment (e.g. DEV) + status: + type: string + description: Status of the component (e.g. READY, NOT_READY) + resultTraceback: + type: string + description: Traceback information in case of error + repositoryURL: + type: string + description: URL of the repository (for ODS products) + params: + type: object + description: Additional parameters (key-value pairs) + component-type: + type: string + description: Type of component (ods|awx) + CreateComponentResponse: + type: object + properties: + errorCode: + type: integer + field: + type: string + message: + type: string + location: + type: string + format: url + CreateComponentRequest: + type: object + properties: + name: + type: string + description: component name + productId: + type: string + description: product id + params: + type: object + additionalProperties: + type: string \ No newline at end of file diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml new file mode 100644 index 00000000..a3f50df0 --- /dev/null +++ b/api-project-component-v0/pom.xml @@ -0,0 +1,163 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + api-project-component-v0 + API Project Components V0 + API module for managing EDP project components + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + org.springframework.boot + spring-boot-starter-validation + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + org.opendevstack.apiservice + service-projects + ${project.version} + + + + io.jsonwebtoken + jjwt-api + 0.12.3 + + + + io.jsonwebtoken + jjwt-impl + 0.12.3 + runtime + + + + io.jsonwebtoken + jjwt-jackson + 0.12.3 + runtime + + + + org.projectlombok + lombok + provided + + + + org.mapstruct + mapstruct + 1.6.3 + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-core + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + + + + org.openapitools + openapi-generator-maven-plugin + + + generate-api-project-component-v0 + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-component-v0.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + + \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java new file mode 100644 index 00000000..7eb10ce0 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java @@ -0,0 +1,24 @@ +package org.opendevstack.apiservice.project.controller; + +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; + +public class ComponentsResponseFactory { + + private ComponentsResponseFactory() { + } + + public static CreateComponentResponse error(String projectId) { + CreateComponentResponse response = new CreateComponentResponse(); + response.setErrorCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); + response.setMessage("Failed to create component for project '" + projectId + "'"); + return response; + } + + public static CreateComponentResponse entityCreated(String projectId, String componentName) { + CreateComponentResponse response = new CreateComponentResponse(); + response.setErrorCode(HttpStatus.CREATED.value()); + response.setMessage(componentName + " component created successfully in project " + projectId); + return response; + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java new file mode 100644 index 00000000..9f50deef --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -0,0 +1,52 @@ +package org.opendevstack.apiservice.project.controller; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.api.ProjectComponentsApi; +import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@Slf4j +public class ProjectComponentsController implements ProjectComponentsApi { + + private final ComponentsFacade componentsFacade; + + private final ComponentResponseMapper componentResponseMapper; + + @Override + public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + try { + Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); + if (component == null) { + log.error("Failed to create component for project '{}'", projectId); + return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); + } + return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.entityCreated(projectId, component.getName())); + } catch (Exception e) { + log.error("Error while trying to create component for project '" + projectId + "': " + e.getMessage(), e); + return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); + } + } + + @Override + public ResponseEntity getProjectComponent(String projectId, String componentId) { + try { + Component component = componentsFacade.getProjectComponent(projectId, componentId); + if (component == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + return ResponseEntity.status(HttpStatus.OK).body(component); + } catch (Exception e) { + log.error("Error retrieving component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java new file mode 100644 index 00000000..012f21da --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -0,0 +1,43 @@ +package org.opendevstack.apiservice.project.facade; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.springframework.stereotype.Service; + +@Service +@AllArgsConstructor +@Slf4j +public class ComponentsFacade { + + private final MarketplaceExternalServicePlaceholder marketplaceExternalService; + + public Component getProjectComponent(String projectId, String componentId) { + return marketplaceExternalService.getProjectComponent(projectId, componentId); + } + + public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + return marketplaceExternalService.createProjectComponent(projectId, createComponentRequest); + } + + @Service + class MarketplaceExternalServicePlaceholder implements ExternalService { + + @Override + public boolean isHealthy() { + return false; + } + + public Component getProjectComponent(String projectId, String componentId) { + log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); + return null; + } + + public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); + return null; + } + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java new file mode 100644 index 00000000..4c3198b5 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@Mapper(componentModel = "spring") +public interface ComponentResponseMapper { + + default ResponseEntity toResponseEntity(CreateComponentResponse response) { + return new ResponseEntity<>(response, HttpStatus.valueOf(response.getErrorCode())); + } + +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java new file mode 100644 index 00000000..23aec13b --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -0,0 +1,100 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.*; + +@ExtendWith(MockitoExtension.class) +class ProjectComponentsControllerTest { + + @Mock + private ComponentsFacade componentsFacade; + + private final ComponentResponseMapper componentResponseMapper = Mappers.getMapper(ComponentResponseMapper.class); + + private ProjectComponentsController projectComponentsController; + + @BeforeEach + void setup() { + projectComponentsController = new ProjectComponentsController(componentsFacade, componentResponseMapper); + } + + @Test + void testCreateProjectComponent_whenSuccess_thenReturnOk() throws Exception { + Component testComponent = buildTestComponent(); + String testProjectId = "testProjectId"; + CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); + CreateComponentResponse testServiceResponseSuccess = buildTestCreateComponentResponseSuccess(testComponent.getName(), + testProjectId); + + when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) + .thenReturn(testComponent); + + ResponseEntity response = projectComponentsController.createProjectComponent(testProjectId, + testCreateComponentRequest); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEqualTo(testServiceResponseSuccess); + } + + @Test + void testCreateProjectComponent_whenFailure_thenReturnErrorResponse() throws Exception { + CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); + String testProjectId = "testProjectId"; + CreateComponentResponse testServiceResponseFailure = buildTestCreateComponentResponseFailure(testProjectId); + + when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) + .thenReturn(null); + + ResponseEntity response = projectComponentsController.createProjectComponent(testProjectId, + testCreateComponentRequest); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEqualTo(testServiceResponseFailure); + } + + @Test + void testGetProjectComponent_whenSuccess_thenReturnOk() throws Exception { + Component testComponent = buildTestComponent(); + + when(componentsFacade.getProjectComponent(anyString(), anyString())) + .thenReturn(testComponent); + + ResponseEntity response = projectComponentsController.getProjectComponent("projectId", + "testId"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).isEqualTo(testComponent); + } + + @Test + void testGetProjectComponent_whenFailure_thenReturnErrorResponse() throws Exception { + when(componentsFacade.getProjectComponent(anyString(), anyString())) + .thenReturn(null); + + ResponseEntity response = projectComponentsController.getProjectComponent("projectId", + "testId"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).isNull(); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java new file mode 100644 index 00000000..31d2bcb2 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java @@ -0,0 +1,74 @@ +package org.opendevstack.apiservice.project.facade; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; + +@ExtendWith(MockitoExtension.class) +class ComponentsServiceTest { + + @Mock + private ComponentsFacade.MarketplaceExternalServicePlaceholder marketPlaceExternalServicePlaceholder; + + private ComponentsFacade componentsFacade; + + @BeforeEach + void setup() { + componentsFacade = new ComponentsFacade(marketPlaceExternalServicePlaceholder); + } + + @Test + void testGetProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { + Component testComponent = buildTestComponent(); + + when(marketPlaceExternalServicePlaceholder.getProjectComponent(anyString(), eq("testId"))) + .thenReturn(testComponent); + + Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); + assertThat(retrievedComponent).isEqualTo(testComponent); + } + + @Test + void testGetProjectComponent_whenNoComponentFound_thenReturnNull() throws Exception { + when(marketPlaceExternalServicePlaceholder.getProjectComponent(anyString(), eq("testId"))) + .thenReturn(null); + + Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); + assertThat(retrievedComponent).isNull(); + } + + @Test + void testCreateProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { + Component testComponent = buildTestComponent(); + CreateComponentRequest testRequest = buildTestCreateComponentRequest(); + + when(marketPlaceExternalServicePlaceholder.createProjectComponent(anyString(), eq(testRequest))) + .thenReturn(testComponent); + + Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); + assertThat(retrievedComponent).isEqualTo(testComponent); + } + + + @Test + void testCreateProjectComponent_whenFailure_thenReturnNull() throws Exception { + CreateComponentRequest testRequest = buildTestCreateComponentRequest(); + + when(marketPlaceExternalServicePlaceholder.createProjectComponent(anyString(), eq(testRequest))) + .thenReturn(null); + + Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); + assertThat(retrievedComponent).isNull(); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java new file mode 100644 index 00000000..60ca92b0 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -0,0 +1,42 @@ +package org.opendevstack.apiservice.project.util; + +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; + +public class TestObjectsBuilder { + + private TestObjectsBuilder() { + } + + public static Component buildTestComponent() { + Component component = new Component(); + component.setId("testId"); + component.setName("testComponentName"); + component.environment("testEnv"); + component.setComponentType("testComponentType"); + return component; + } + + public static CreateComponentRequest buildTestCreateComponentRequest() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("testComponentName"); + request.setProductId("testProductId"); + return request; + } + + public static CreateComponentResponse buildTestCreateComponentResponseSuccess(String componentName, String projectId) { + CreateComponentResponse response = new CreateComponentResponse(); + response.setErrorCode(HttpStatus.CREATED.value()); + response.setMessage(componentName + " component created successfully in project " + projectId); + return response; + } + + public static CreateComponentResponse buildTestCreateComponentResponseFailure(String projectId) { + CreateComponentResponse response = new CreateComponentResponse(); + response.setErrorCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); + response.setMessage("Failed to create component for project '" + projectId + "'"); + return response; + } +} diff --git a/pom.xml b/pom.xml index 6076af29..01d9d242 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,7 @@ api-project-users api-project-platform api-project + api-project-component-v0 From 17266958b959915c234d6394f5344bd6ad26d703 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Thu, 26 Mar 2026 08:34:20 +0100 Subject: [PATCH 05/27] Persistence improvement (#12) This pull request introduces a new `core-contracts` module, which defines the core interfaces, data structures, and contract classes for API service modules. It also integrates this new module as a dependency into the `persistence` module. The changes lay the foundation for a modular, contract-driven architecture by specifying interfaces for API modules, authentication, policies, error handling, and persistence. The most important changes are: **Introduction of `core-contracts` module:** * Added a new Maven module `core-contracts` with its own `pom.xml`, specifying dependencies on Spring, Jackson, Lombok, and Jakarta Servlet API. **API and Policy Contracts:** * Defined core interfaces and data classes for API modules (`ApiModule`), API definitions (`ApiDefinition`), and policy evaluation (`PolicyEvaluator`, `PolicyRule`, `PolicyContext`, `PolicyTypes`). These provide a standardized contract for API registration, discovery, and policy enforcement. * **Authentication and Authorization:** * Introduced enums and logic for authentication types (`AuthType`) and authorization decisions (`AuthorizationDecision`), including a utility method for combining decisions. **Persistence Contracts:** * Added interfaces for data access to API definitions, clients, and policies (`ApiDefinitionDao`, `ClientDao`, `PolicyDao`), including read-only projections for client information **Error Handling:** * Introduced a simple `GatewayError` value class for standardized error representation in the core contracts. **Integration with Persistence Module:** * Updated `persistence/pom.xml` to add a dependency on the new `core-contracts` module, enabling the persistence layer to implement and use these contracts. --- core-contracts/pom.xml | 41 ++++++ .../core/contracts/api/ApiModule.java | 14 ++ .../core/contracts/auth/AuthType.java | 7 + .../contracts/auth/AuthorizationDecision.java | 18 +++ .../persistence/ApiDefinitionDao.java | 21 +++ .../core/contracts/persistence/ClientDao.java | 12 ++ .../contracts/persistence/ClientInfo.java | 22 +++ .../core/contracts/persistence/PolicyDao.java | 18 +++ .../core/contracts/policy/PolicyContext.java | 40 ++++++ .../contracts/policy/PolicyEvaluator.java | 10 ++ .../core/contracts/policy/PolicyRule.java | 20 +++ .../core/contracts/policy/PolicyTypes.java | 14 ++ .../contracts/registry/ApiDefinition.java | 32 +++++ persistence/pom.xml | 6 + .../persistence/dao/ApiDefinitionDaoImpl.java | 77 +++++++++++ .../persistence/dao/ClientDaoImpl.java | 29 ++++ .../persistence/dao/PolicyDaoImpl.java | 71 ++++++++++ .../entity/ApiDefinitionEntity.java | 51 +++++++ .../persistence/entity/AuditableEntity.java | 39 ++++++ .../entity/AuthorizationPolicyEntity.java | 36 +++++ .../persistence/entity/ClientAppEntity.java | 46 +++++++ .../entity/ClientAppProjectFlavorEntity.java | 75 +++++++++++ .../persistence/entity/ProjectEntity.java | 26 +--- .../ApiDefinitionJpaRepository.java | 17 +++ .../AuthorizationPolicyJpaRepository.java | 16 +++ .../repository/ClientAppRepository.java | 45 +++++++ .../002_create_client_apps_table.sql | 8 +- .../003_create_policy_engine_tables.sql | 81 +++++++++++ .../repository/ClientAppRepositoryTest.java | 126 ++++++++++++++++++ pom.xml | 1 + 30 files changed, 987 insertions(+), 32 deletions(-) create mode 100644 core-contracts/pom.xml create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java create mode 100644 core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java create mode 100644 persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java create mode 100644 persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql create mode 100644 persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java diff --git a/core-contracts/pom.xml b/core-contracts/pom.xml new file mode 100644 index 00000000..1db3a6d3 --- /dev/null +++ b/core-contracts/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + core-contracts + core-contracts + + + + jakarta.servlet + jakarta.servlet-api + provided + + + org.springframework + spring-context + + + org.springframework + spring-web + + + com.fasterxml.jackson.core + jackson-databind + + + org.projectlombok + lombok + provided + + + diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java new file mode 100644 index 00000000..ccbc5160 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/api/ApiModule.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.core.contracts.api; + +import java.util.List; + +public interface ApiModule { + + String getName(); + + String getBasePath(); + + List getSupportedVersions(); + + boolean isLocal(); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java new file mode 100644 index 00000000..4558b322 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthType.java @@ -0,0 +1,7 @@ +package org.opendevstack.apiservice.core.contracts.auth; + +public enum AuthType { + NONE, + OBO, + CLIENT_CREDENTIALS +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java new file mode 100644 index 00000000..8a63ab31 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/auth/AuthorizationDecision.java @@ -0,0 +1,18 @@ +package org.opendevstack.apiservice.core.contracts.auth; + +public enum AuthorizationDecision { + + PERMIT, + DENY, + ABSTAIN; + + public static AuthorizationDecision combine(AuthorizationDecision a, AuthorizationDecision b) { + if (a == DENY || b == DENY) { + return DENY; + } + if (a == PERMIT || b == PERMIT) { + return PERMIT; + } + return ABSTAIN; + } +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java new file mode 100644 index 00000000..c69a7af5 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ApiDefinitionDao.java @@ -0,0 +1,21 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.List; +import java.util.Optional; + +/** + * Data access contract for API definitions. + * Implementations are provided by the persistence module. + */ +public interface ApiDefinitionDao { + + Optional findByApiId(String apiId); + + Optional findByBasePathAndVersion(String basePath, String version); + + List findAllEnabled(); + + List findAll(); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java new file mode 100644 index 00000000..16f68fe4 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientDao.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import java.util.Optional; + +/** + * Data access contract for registered clients. + * Implementations are provided by the persistence module. + */ +public interface ClientDao { + + Optional findByClientId(String clientId); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java new file mode 100644 index 00000000..5c8d6715 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/ClientInfo.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; +import lombok.experimental.Accessors; + +import java.util.UUID; + +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor +@EqualsAndHashCode +@ToString +public final class ClientInfo { + + private final UUID id; + private final String clientId; + private final String name; + private final boolean enabled; +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java new file mode 100644 index 00000000..bcd1a4cf --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/persistence/PolicyDao.java @@ -0,0 +1,18 @@ +package org.opendevstack.apiservice.core.contracts.persistence; + +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.List; + +/** + * Data access contract for authorization policies. + * Implementations are provided by the persistence module. + */ +public interface PolicyDao { + + List findByApiDefinitionId(String apiDefinitionId); + + List findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId); + + List findGlobalByApiDefinitionId(String apiDefinitionId); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java new file mode 100644 index 00000000..91e5be9d --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyContext.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.Map; + +@Getter +@AllArgsConstructor +public class PolicyContext { + + private final String clientId; + private final String subject; + private final Map claims; + private final ApiDefinition apiDefinition; + private final HttpServletRequest request; + private final PolicyRule activeRule; + private final Map requestBody; + + public PolicyContext(String clientId, String subject, Map claims, + ApiDefinition apiDefinition, HttpServletRequest request) { + this(clientId, subject, claims, apiDefinition, request, null, null); + } + + /** + * Returns a new context with the given rule set, leaving this instance unchanged. + */ + public PolicyContext withRule(PolicyRule rule) { + return new PolicyContext(clientId, subject, claims, apiDefinition, request, rule, requestBody); + } + + /** + * Returns a new context with the given request body, leaving this instance unchanged. + */ + public PolicyContext withRequestBody(Map body) { + return new PolicyContext(clientId, subject, claims, apiDefinition, request, activeRule, body); + } +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java new file mode 100644 index 00000000..2994a192 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyEvaluator.java @@ -0,0 +1,10 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; + +public interface PolicyEvaluator { + + boolean supports(String policyType); + + AuthorizationDecision evaluate(PolicyContext context); +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java new file mode 100644 index 00000000..2abf7f1b --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyRule.java @@ -0,0 +1,20 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PolicyRule { + + private UUID id; + private String apiDefinitionId; + private String clientId; + private String policyType; + private Map config; +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java new file mode 100644 index 00000000..96577271 --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.core.contracts.policy; + +/** + * Well-known policy type constants used by core. + * Modules are free to define their own policy type strings + * without modifying this class. + */ +public final class PolicyTypes { + + private PolicyTypes() {} + + public static final String ALLOWED_CLIENTS = "ALLOWED_CLIENTS"; + public static final String SCOPE_REQUIRED = "SCOPE_REQUIRED"; +} diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java new file mode 100644 index 00000000..a3021e1f --- /dev/null +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/registry/ApiDefinition.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.core.contracts.registry; + +import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiDefinition { + + private String id; + private String name; + private String basePath; + private String version; + private Set authTypes; + private boolean isPublic; + private String proxyUrl; + private boolean enabled; + + public boolean isLocal() { + return proxyUrl == null || proxyUrl.isBlank(); + } + + public boolean requiresAuth() { + return authTypes != null + && !authTypes.isEmpty() + && !(authTypes.size() == 1 && authTypes.contains(AuthType.NONE)); + } +} diff --git a/persistence/pom.xml b/persistence/pom.xml index a5fd5f9f..92dc1e5e 100644 --- a/persistence/pom.xml +++ b/persistence/pom.xml @@ -42,6 +42,12 @@ true + + org.opendevstack.apiservice + core-contracts + ${project.version} + + org.springframework.boot diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java new file mode 100644 index 00000000..a95a85ff --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ApiDefinitionDaoImpl.java @@ -0,0 +1,77 @@ +package org.opendevstack.apiservice.persistence.dao; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.persistence.entity.ApiDefinitionEntity; +import org.opendevstack.apiservice.persistence.repository.ApiDefinitionJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class ApiDefinitionDaoImpl implements ApiDefinitionDao { + + private final ApiDefinitionJpaRepository repository; + + public ApiDefinitionDaoImpl(ApiDefinitionJpaRepository repository) { + this.repository = repository; + } + + @Override + public Optional findByApiId(String apiId) { + return repository.findByApiId(apiId) + .map(this::toDto); + } + + @Override + public Optional findByBasePathAndVersion(String basePath, String version) { + return repository.findByBasePathAndVersion(basePath, version) + .map(this::toDto); + } + + @Override + public List findAllEnabled() { + return repository.findByEnabledTrue().stream() + .map(this::toDto) + .toList(); + } + + @Override + public List findAll() { + return repository.findAll().stream() + .map(this::toDto) + .toList(); + } + + private ApiDefinition toDto(ApiDefinitionEntity entity) { + Set authTypes = Arrays.stream(entity.getAuthTypes()) + .map(s -> { + try { + return AuthType.valueOf(s); + } catch (IllegalArgumentException e) { + log.warn("Unknown AuthType '{}' in api_definitions row {}", s, entity.getApiId()); + return null; + } + }) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toSet()); + + return new ApiDefinition( + entity.getApiId(), + entity.getName(), + entity.getBasePath(), + entity.getVersion(), + authTypes, + entity.isPublic(), + entity.getProxyUrl(), + entity.isEnabled() + ); + } +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java new file mode 100644 index 00000000..31db2284 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.persistence.dao; + +import org.opendevstack.apiservice.core.contracts.persistence.ClientDao; +import org.opendevstack.apiservice.core.contracts.persistence.ClientInfo; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +public class ClientDaoImpl implements ClientDao { + + private final ClientAppRepository repository; + + public ClientDaoImpl(ClientAppRepository repository) { + this.repository = repository; + } + + @Override + public Optional findByClientId(String clientId) { + return repository.findByClientId(clientId) + .map(entity -> new ClientInfo( + entity.getId(), + entity.getClientId(), + entity.getClientName(), + entity.isEnabled() + )); + } +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java new file mode 100644 index 00000000..408d54bf --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java @@ -0,0 +1,71 @@ +package org.opendevstack.apiservice.persistence.dao; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.persistence.entity.AuthorizationPolicyEntity; +import org.opendevstack.apiservice.persistence.repository.AuthorizationPolicyJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class PolicyDaoImpl implements PolicyDao { + + private final AuthorizationPolicyJpaRepository repository; + private final ObjectMapper objectMapper; + + PolicyDaoImpl(AuthorizationPolicyJpaRepository repository, ObjectMapper objectMapper) { + this.repository = repository; + this.objectMapper = objectMapper; + } + + @Override + public List findByApiDefinitionId(String apiDefinitionId) { + return repository.findByApiDefinitionId(apiDefinitionId).stream() + .map(this::toDto) + .toList(); + } + + @Override + public List findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId) { + return repository.findByApiDefinitionIdAndClientId(apiDefinitionId, clientId).stream() + .map(this::toDto) + .toList(); + } + + @Override + public List findGlobalByApiDefinitionId(String apiDefinitionId) { + return repository.findByApiDefinitionIdAndClientIdIsNull(apiDefinitionId).stream() + .map(this::toDto) + .toList(); + } + + private PolicyRule toDto(AuthorizationPolicyEntity entity) { + Map config = parseConfig(entity.getPolicyConfig()); + return new PolicyRule( + entity.getId(), + entity.getApiDefinitionId(), + entity.getClientId(), + entity.getPolicyType(), + config + ); + } + + private Map parseConfig(String json) { + if (json == null || json.isBlank()) { + return Collections.emptyMap(); + } + try { + return objectMapper.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + log.warn("Failed to parse policy config JSON: {}", e.getMessage()); + return Collections.emptyMap(); + } + } +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java new file mode 100644 index 00000000..1ef2fec1 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ApiDefinitionEntity.java @@ -0,0 +1,51 @@ +package org.opendevstack.apiservice.persistence.entity; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.util.UUID; + +@Entity +@Table(name = "api_definitions") +@Getter +@Setter +@NoArgsConstructor +public class ApiDefinitionEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false, unique = true) + private String apiId; + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String basePath; + + @Column(nullable = false) + private String version; + + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "auth_types", columnDefinition = "varchar(30)[]", nullable = false) + private String[] authTypes = new String[0]; + + @Column(nullable = false) + private boolean isPublic; + + private String proxyUrl; + + @Column(nullable = false) + private boolean enabled = true; +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java new file mode 100644 index 00000000..47c90641 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuditableEntity.java @@ -0,0 +1,39 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import java.time.OffsetDateTime; +import lombok.Getter; +import lombok.Setter; + +/** + * Shared audit timestamps for persistence entities. + */ +@MappedSuperclass +@Getter +@Setter +public abstract class AuditableEntity { + + /** Original creation timestamp (UTC). Set automatically on first persist. */ + @Column(name = "created_at", nullable = false, updatable = false, + columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime createdAt; + + /** Timestamp of last update (UTC). Updated automatically on every merge. */ + @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMPTZ") + private OffsetDateTime updatedAt; + + @PrePersist + protected void onPrePersist() { + OffsetDateTime now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PreUpdate + protected void onPreUpdate() { + this.updatedAt = OffsetDateTime.now(); + } +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java new file mode 100644 index 00000000..1ff60e87 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/AuthorizationPolicyEntity.java @@ -0,0 +1,36 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Entity +@Table(name = "authorization_policies") +@Getter +@Setter +@NoArgsConstructor +public class AuthorizationPolicyEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String apiDefinitionId; + + private String clientId; + + @Column(nullable = false) + private String policyType; + + @Column(columnDefinition = "jsonb") + private String policyConfig; +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java new file mode 100644 index 00000000..74f0875b --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java @@ -0,0 +1,46 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * JPA entity mapping the {@code client_apps} table. + */ +@Entity +@Table(name = "client_apps") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ClientAppEntity extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** Azure AD application/client identifier. */ + @Column(name = "client_id", nullable = false, unique = true, length = 36) + private String clientId; + + /** Optional display name for the client application. */ + @Column(name = "client_name", length = 255) + private String clientName; + + /** Whether the client is allowed to call the API. */ + @Column(name = "enabled", nullable = false) + @Builder.Default + private boolean enabled = true; + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java new file mode 100644 index 00000000..5d0015ed --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppProjectFlavorEntity.java @@ -0,0 +1,75 @@ +package org.opendevstack.apiservice.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +/** + * JPA entity mapping the {@code client_app_project_flavors} table. + */ +@Entity +@Table(name = "client_app_project_flavors") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@ToString(exclude = "clientApp") +public class ClientAppProjectFlavorEntity extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + /** Owning client application. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "client_app_id", nullable = false) + private ClientAppEntity clientApp; + + /** Human-readable flavor name. */ + @Column(name = "name", nullable = false, length = 50) + private String name; + + /** Project key generation pattern. */ + @Column(name = "project_key_pattern", nullable = false, length = 100) + private String projectKeyPattern; + + /** Default project owner. */ + @Column(name = "project_owner", length = 255) + private String projectOwner; + + /** Service account associated with the flavor. */ + @Column(name = "service_account", length = 255) + private String serviceAccount; + + /** Default CMDB configuration item. */ + @Column(name = "config_item", length = 255) + private String configItem; + + /** Allowed CMDB configuration item overrides. */ + @JdbcTypeCode(SqlTypes.ARRAY) + @Column(name = "allowed_config_items", nullable = false, columnDefinition = "TEXT[]") + @Builder.Default + private String[] allowedConfigItems = new String[0]; + + /** Deployment region. */ + @Column(name = "location", length = 50) + private String location; + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java index f3299cb9..ae28dac2 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ProjectEntity.java @@ -5,10 +5,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; -import java.time.OffsetDateTime; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; @@ -39,7 +36,7 @@ @AllArgsConstructor @Builder @ToString(exclude = { "description", "ldapGroupManager", "ldapGroupTeam", "ldapGroupStakeholder" }) -public class ProjectEntity { +public class ProjectEntity extends AuditableEntity { @Id @GeneratedValue(strategy = GenerationType.UUID) @@ -103,25 +100,4 @@ public class ProjectEntity { @Column(name = "ldap_group_stakeholder", columnDefinition = "TEXT") private String ldapGroupStakeholder; - /** Original creation timestamp (UTC). Set automatically on first persist. */ - @Column(name = "created_at", nullable = false, updatable = false, - columnDefinition = "TIMESTAMPTZ") - private OffsetDateTime createdAt; - - /** Timestamp of last update (UTC). Updated automatically on every merge. */ - @Column(name = "updated_at", nullable = false, columnDefinition = "TIMESTAMPTZ") - private OffsetDateTime updatedAt; - - @PrePersist - void onPrePersist() { - OffsetDateTime now = OffsetDateTime.now(); - this.createdAt = now; - this.updatedAt = now; - } - - @PreUpdate - void onPreUpdate() { - this.updatedAt = OffsetDateTime.now(); - } - } diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java new file mode 100644 index 00000000..74e8bd51 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ApiDefinitionJpaRepository.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.persistence.repository; + +import org.opendevstack.apiservice.persistence.entity.ApiDefinitionEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApiDefinitionJpaRepository extends JpaRepository { + + Optional findByApiId(String apiId); + + Optional findByBasePathAndVersion(String basePath, String version); + + List findByEnabledTrue(); +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java new file mode 100644 index 00000000..8f71fb73 --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java @@ -0,0 +1,16 @@ +package org.opendevstack.apiservice.persistence.repository; + +import org.opendevstack.apiservice.persistence.entity.AuthorizationPolicyEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AuthorizationPolicyJpaRepository extends JpaRepository { + + List findByApiDefinitionId(String apiDefinitionId); + + List findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId); + + List findByApiDefinitionIdAndClientIdIsNull(String apiDefinitionId); +} diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java new file mode 100644 index 00000000..1af9a8de --- /dev/null +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java @@ -0,0 +1,45 @@ +package org.opendevstack.apiservice.persistence.repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * Spring Data JPA repository for {@link ClientAppEntity}. + */ +@Repository +public interface ClientAppRepository extends JpaRepository { + + /** + * Finds a client application by its Azure AD client identifier. + * @param clientId Azure AD application/client UUID + * @return matching client app, if present + */ + Optional findByClientId(String clientId); + + /** + * Returns all enabled client applications. + * @return enabled client apps + */ + List findByEnabledTrue(); + + /** + * Checks if a client application exists for the given Azure AD client identifier. + * @param clientId Azure AD application/client UUID + * @return {@code true} if the client app exists + */ + boolean existsByClientId(String clientId); + + /** + * Loads a client application together with its project flavor configuration. + * @param clientId Azure AD application/client UUID + * @return client app with project flavors initialized, if present + */ + @EntityGraph(attributePaths = "projectFlavors") + Optional findDetailedByClientId(String clientId); + +} \ No newline at end of file diff --git a/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql b/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql index 4c2b0779..88144811 100644 --- a/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql +++ b/persistence/src/main/resources/db/changelog/migrations/002_create_client_apps_table.sql @@ -8,9 +8,7 @@ CREATE EXTENSION IF NOT EXISTS pgcrypto; CREATE TABLE IF NOT EXISTS client_apps ( id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, client_id VARCHAR(36) NOT NULL, - client_name VARCHAR(255), - permissions TEXT[] NOT NULL DEFAULT '{}', - role_scope TEXT, + client_name VARCHAR(255) NOT NULL, enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -22,8 +20,6 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_client_apps_client_id COMMENT ON TABLE client_apps IS 'Azure AD clients authorised to call the service APIs'; COMMENT ON COLUMN client_apps.client_id IS 'Azure AD Application (client) UUID'; COMMENT ON COLUMN client_apps.client_name IS 'Azure AD application display name'; -COMMENT ON COLUMN client_apps.permissions IS 'Granted permissions; known values: project:add, project:detail, project:list'; -COMMENT ON COLUMN client_apps.role_scope IS 'OAuth2 scope or role granted to this client (e.g. api.read, api.write)'; COMMENT ON COLUMN client_apps.enabled IS 'When FALSE the client is denied access without removing the row'; --rollback DROP INDEX IF EXISTS uq_client_apps_client_id; @@ -38,7 +34,6 @@ CREATE TABLE IF NOT EXISTS client_app_project_flavors ( client_app_id UUID NOT NULL, name VARCHAR(50) NOT NULL, project_key_pattern VARCHAR(100) NOT NULL, - template_id INT, project_owner VARCHAR(255), service_account VARCHAR(255), config_item VARCHAR(255), @@ -58,7 +53,6 @@ COMMENT ON TABLE client_app_project_flavors IS 'Project fl COMMENT ON COLUMN client_app_project_flavors.client_app_id IS 'FK to client_apps.id (UUID)'; COMMENT ON COLUMN client_app_project_flavors.name IS 'Flavor name (e.g. DLSS, AMP)'; COMMENT ON COLUMN client_app_project_flavors.project_key_pattern IS 'printf-style pattern used to generate the project key (e.g. DLSS%06d)'; -COMMENT ON COLUMN client_app_project_flavors.template_id IS 'ODS/Jira template identifier'; COMMENT ON COLUMN client_app_project_flavors.project_owner IS 'Default project owner username'; COMMENT ON COLUMN client_app_project_flavors.service_account IS 'Service account associated with the flavor'; COMMENT ON COLUMN client_app_project_flavors.config_item IS 'Default CMDB configuration item for projects created under this flavor'; diff --git a/persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql b/persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql new file mode 100644 index 00000000..4c160773 --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/003_create_policy_engine_tables.sql @@ -0,0 +1,81 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:006-create-api-definitions +-- Description: API definitions table Each row represents a managed API module. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS api_definitions ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + api_id VARCHAR(100) NOT NULL, + name VARCHAR(255) NOT NULL, + base_path VARCHAR(255) NOT NULL, + version VARCHAR(20) NOT NULL, + auth_types VARCHAR(30)[] NOT NULL DEFAULT '{}', + is_public BOOLEAN NOT NULL DEFAULT FALSE, + proxy_url VARCHAR(500), + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_api_definitions_api_id + ON api_definitions (api_id); + +COMMENT ON TABLE api_definitions IS 'Managed API modules with auth and routing metadata'; +COMMENT ON COLUMN api_definitions.api_id IS 'Unique logical identifier (e.g. project-v0, project-users-v1)'; +COMMENT ON COLUMN api_definitions.base_path IS 'URL base path (e.g. projects, projects/users)'; +COMMENT ON COLUMN api_definitions.version IS 'API version (e.g. v0, v1)'; +COMMENT ON COLUMN api_definitions.auth_types IS 'Allowed OAuth2 flow types: NONE, OBO, CLIENT_CREDENTIALS'; +COMMENT ON COLUMN api_definitions.is_public IS 'When TRUE the API is accessible without authentication'; +COMMENT ON COLUMN api_definitions.proxy_url IS 'If set, requests are proxied to this URL (null = local controller)'; + +--rollback DROP INDEX IF EXISTS uq_api_definitions_api_id; +--rollback DROP TABLE IF EXISTS api_definitions; + + +-- ============================================================================ +-- changeset ods:006-create-authorization-policies +-- Description: Flexible, JSON-configured authorization policies. +-- Each policy ties an API definition to optional client-specific rules. +-- policy_config stores evaluator-specific parameters as JSONB. +-- ============================================================================ +CREATE TABLE IF NOT EXISTS authorization_policies ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + api_definition_id VARCHAR(100) NOT NULL, + client_id VARCHAR(36), + policy_type VARCHAR(100) NOT NULL, + policy_config JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_auth_policies_api_def_id + ON authorization_policies (api_definition_id); + +CREATE INDEX IF NOT EXISTS idx_auth_policies_client_id + ON authorization_policies (client_id); + +COMMENT ON TABLE authorization_policies IS 'JSON-configured authorization policy rules'; +COMMENT ON COLUMN authorization_policies.api_definition_id IS 'Logical API id (matches api_definitions.api_id)'; +COMMENT ON COLUMN authorization_policies.client_id IS 'Azure client id (NULL = global rule for all clients)'; +COMMENT ON COLUMN authorization_policies.policy_type IS 'Evaluator type (e.g. ALLOWED_CLIENTS, SCOPE_REQUIRED, FLAVOR_RESTRICTION)'; +COMMENT ON COLUMN authorization_policies.policy_config IS 'Evaluator-specific config as JSONB'; + +--rollback DROP INDEX IF EXISTS idx_auth_policies_client_id; +--rollback DROP INDEX IF EXISTS idx_auth_policies_api_def_id; +--rollback DROP TABLE IF EXISTS authorization_policies; + + +-- ============================================================================ +-- changeset ods:006-seed-api-definitions +-- Description: Seed API definitions. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-v0', 'Project API v0', 'projects', 'v0', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE), + ('project-users-v1', 'Project Users API v1', 'projects/*/users', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE), + ('project-platforms-v1', 'Project Platforms API v1', 'projects/*/platforms', 'v1', ARRAY['NONE'], FALSE, TRUE) +ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-v0', 'project-users-v1'); + diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java new file mode 100644 index 00000000..bf313460 --- /dev/null +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java @@ -0,0 +1,126 @@ +package org.opendevstack.apiservice.persistence.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * Integration tests for {@link ClientAppRepository}. + */ +@DataJpaTest +@Testcontainers +@AutoConfigureTestDatabase(replace = Replace.NONE) +class ClientAppRepositoryTest { + + @Container + @SuppressWarnings("resource") + static final PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:18-alpine") + .withDatabaseName("devstack_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void postgresProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.datasource.hikari.maximum-pool-size", () -> "2"); + registry.add("spring.datasource.hikari.minimum-idle", () -> "0"); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "create"); + } + + @Autowired + private ClientAppRepository repository; + + private ClientAppEntity enabledClientApp; + + @BeforeEach + void setUp() { + repository.deleteAll(); + + enabledClientApp = createClientApp("11111111-1111-1111-1111-111111111111", true); + createClientApp("22222222-2222-2222-2222-222222222222", false); + } + + private ClientAppEntity createClientApp(String clientId, boolean enabled) { + ClientAppEntity clientApp = ClientAppEntity.builder() + .clientId(clientId) + .clientName(enabled ? "Enabled app" : "Disabled app") + .enabled(enabled) + .build(); + + return repository.saveAndFlush(clientApp); + } + + @Nested + @DisplayName("findByClientId") + class FindByClientId { + + @Test + @DisplayName("returns the matching client app") + void returnsMatchingClientApp() { + Optional found = repository.findByClientId("11111111-1111-1111-1111-111111111111"); + + assertThat(found).isPresent(); + assertThat(found.get().getClientName()).isEqualTo("Enabled app"); + assertThat(found.get().isEnabled()).isTrue(); + } + + @Test + @DisplayName("returns empty for unknown client id") + void returnsEmptyForUnknownClientId() { + assertThat(repository.findByClientId("99999999-9999-9999-9999-999999999999")).isEmpty(); + } + + } + + @Nested + @DisplayName("findByEnabledTrue") + class FindByEnabledTrue { + + @Test + @DisplayName("returns only enabled client apps") + void returnsOnlyEnabledClientApps() { + List enabledApps = repository.findByEnabledTrue(); + + assertThat(enabledApps).hasSize(1); + assertThat(enabledApps.get(0).getClientId()).isEqualTo("11111111-1111-1111-1111-111111111111"); + } + + } + @Nested + @DisplayName("existsByClientId") + class ExistsByClientId { + + @Test + @DisplayName("returns true for existing client id") + void returnsTrueForExistingClientId() { + assertThat(repository.existsByClientId("11111111-1111-1111-1111-111111111111")).isTrue(); + } + + @Test + @DisplayName("returns false for unknown client id") + void returnsFalseForUnknownClientId() { + assertThat(repository.existsByClientId("99999999-9999-9999-9999-999999999999")).isFalse(); + } + + } + +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 01d9d242..4e30a284 100644 --- a/pom.xml +++ b/pom.xml @@ -47,6 +47,7 @@ external-service-api persistence core + core-contracts external-service-aap external-service-projects-info-service external-service-uipath From d2bd7bea5311df0934ea3ec6ef9bc4004abae266 Mon Sep 17 00:00:00 2001 From: Angel MP Date: Fri, 27 Mar 2026 10:02:27 +0100 Subject: [PATCH 06/27] Feature/add project request validation and exception handling (#11) --- .../exception/GlobalExceptionHandler.java | 167 ++++++++------- api-project/openapi/api-project.yaml | 36 +++- .../project/controller/ProjectController.java | 4 + .../advice/ProjectExceptionHandler.java | 71 ++++++ .../exception/ProjectValidationException.java | 15 ++ .../validation/ProjectRequestValidator.java | 28 +++ .../controller/ProjectControllerTest.java | 39 ++-- .../advice/ProjectExceptionHandlerTest.java | 202 ++++++++++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 4 +- .../project/mapper/ProjectMapperTest.java | 18 +- .../ProjectRequestValidatorTest.java | 68 ++++++ 11 files changed, 530 insertions(+), 122 deletions(-) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java diff --git a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java index c53ea143..0cae6b70 100644 --- a/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java +++ b/api-project-users/src/main/java/org/opendevstack/apiservice/projectusers/exception/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package org.opendevstack.apiservice.projectusers.exception; +import org.opendevstack.apiservice.projectusers.controller.ProjectUserController; import org.opendevstack.apiservice.projectusers.model.ValidationErrorResponse; import org.opendevstack.apiservice.projectusers.model.BaseApiResponse; import org.opendevstack.apiservice.projectusers.model.FieldError; @@ -32,7 +33,7 @@ * messages. */ @Slf4j -@ControllerAdvice +@ControllerAdvice(assignableTypes = ProjectUserController.class) public class GlobalExceptionHandler { /** @@ -69,17 +70,17 @@ public ResponseEntity handleMethodArgumentNotValidExcep fieldErrors.add(fieldError); }); - String errorMessage = String.format( - ErrorMessages.REQUEST_VALIDATION_FAILED, - fieldErrors.size()); - - ValidationErrorResponse errorResponse = new ValidationErrorResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setErrorCode(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setFieldErrors(fieldErrors); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUEST_VALIDATION_FAILED, + fieldErrors.size()); + + ValidationErrorResponse errorResponse = new ValidationErrorResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setErrorCode(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setFieldErrors(fieldErrors); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -96,13 +97,13 @@ public ResponseEntity handleConstraintViolationException( .map(this::formatConstraintViolation) .toList(); - String errorMessage = String.format(ErrorMessages.PARAMETER_VALIDATION_FAILED, String.join("; ", errors)); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format(ErrorMessages.PARAMETER_VALIDATION_FAILED, String.join("; ", errors)); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -115,8 +116,8 @@ public ResponseEntity handleHttpMessageNotReadableException( log.warn("Invalid request body: {}", ex.getMessage()); - String errorMessage = ErrorMessages.INVALID_REQUEST_BODY; - String errorCode = ErrorCodes.PROJECT_USER_ERROR; + String errorMessage = ErrorMessages.INVALID_REQUEST_BODY; + String errorCode = ErrorCodes.PROJECT_USER_ERROR; Throwable cause = ex.getCause(); @@ -172,15 +173,15 @@ public ResponseEntity handleMissingPathVariableException( log.warn("Missing path variable: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.REQUIRED_PATH_PARAMETER_MISSING, - ex.getVariableName()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUIRED_PATH_PARAMETER_MISSING, + ex.getVariableName()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -192,15 +193,15 @@ public ResponseEntity handleMissingServletRequestParameterExcep log.warn("Missing request parameter: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.REQUIRED_REQUEST_PARAMETER_MISSING, - ex.getParameterName(), ex.getParameterType()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.REQUIRED_REQUEST_PARAMETER_MISSING, + ex.getParameterName(), ex.getParameterType()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -212,17 +213,17 @@ public ResponseEntity handleMethodArgumentTypeMismatchException log.warn("Method argument type mismatch: {}", ex.getMessage()); - String errorMessage = String.format( - ErrorMessages.PARAMETER_TYPE_CONVERSION_FAILED, - ex.getName(), - ex.getValue(), - ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(errorMessage); - errorResponse.setError(ErrorCodes.INVALID_ROLE); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + String errorMessage = String.format( + ErrorMessages.PARAMETER_TYPE_CONVERSION_FAILED, + ex.getName(), + ex.getValue(), + ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "unknown"); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(errorMessage); + errorResponse.setError(ErrorCodes.INVALID_ROLE); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } /** @@ -233,11 +234,11 @@ public ResponseEntity handleProjectNotFoundException( ProjectNotFoundException ex) { log.warn("Project not found: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @@ -249,11 +250,11 @@ public ResponseEntity handleUserNotFoundException( UserNotFoundException ex) { log.warn("User not found: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); } @@ -297,11 +298,11 @@ public ResponseEntity handleInvalidRoleException( InvalidRoleException ex) { log.warn("Invalid role: {}", ex.getMessage()); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ex.getMessage()); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ex.getMessage()); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); } @@ -313,12 +314,12 @@ public ResponseEntity handleAutomationPlatformException( AutomationPlatformException ex) { log.error("Automation platform error: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(String.format(ErrorMessages.EXTERNAL_SERVICE_ERROR, ex.getMessage())); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(String.format(ErrorMessages.EXTERNAL_SERVICE_ERROR, ex.getMessage())); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(errorResponse); } /** @@ -327,12 +328,12 @@ public ResponseEntity handleAutomationPlatformException( @ExceptionHandler(ProjectUserException.class) public ResponseEntity handleProjectUserException(ProjectUserException ex) { log.error("Project user operation failed: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(String.format(ErrorMessages.OPERATION_FAILED, ex.getMessage())); - errorResponse.setError(ex.getErrorCode()); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(String.format(ErrorMessages.OPERATION_FAILED, ex.getMessage())); + errorResponse.setError(ex.getErrorCode()); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } /** @@ -341,12 +342,12 @@ public ResponseEntity handleProjectUserException(ProjectUserExc @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { log.error("Unexpected error occurred: {}", ex.getMessage(), ex); - BaseApiResponse errorResponse = new BaseApiResponse(); - errorResponse.setSuccess(false); - errorResponse.setMessage(ErrorMessages.UNEXPECTED_ERROR); - errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); - errorResponse.setTimestamp(java.time.OffsetDateTime.now()); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + BaseApiResponse errorResponse = new BaseApiResponse(); + errorResponse.setSuccess(false); + errorResponse.setMessage(ErrorMessages.UNEXPECTED_ERROR); + errorResponse.setError(ErrorCodes.PROJECT_USER_ERROR); + errorResponse.setTimestamp(java.time.OffsetDateTime.now()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); } /** diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 9c83d307..30820a93 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.0.4 info: title: ODS API Server description: API documentation for ODS (Open DevStack) API Service @@ -124,22 +124,44 @@ components: projectKey: type: string description: Optional project key. If not provided, a unique key will be generated. - projectKeyPattern: - type: string - description: Optional pattern for generating the project key (e.g. 'SS%06d'). + pattern: "^[A-Z]{2}[A-Z0-9]{1,8}$" + minLength: 3 + maxLength: 10 projectName: type: string description: Name of the project. + pattern: "^[A-Za-z0-9 ]{0,80}$" + maxLength: 80 projectDescription: type: string description: Description of the project. - required: - - projectName + maxLength: 255 + projectFlavor: + type: string + description: Flavor of the project. Either projectFlavor or configurationItem must be provided. + configurationItem: + type: string + description: Configuration item for the project. Either projectFlavor or configurationItem must be provided. + location: + type: string + description: Location of the project. + x2OdsAccount: + type: string + description: Technical account of the project. + owner: + type: string + description: Owner of the project. + oneOf: + - required: [projectFlavor] + - required: [configurationItem] + additionalProperties: false CreateProjectResponse: type: object properties: projectKey: type: string + httStatus: + type: string status: type: string projectFlavor: @@ -153,4 +175,4 @@ components: errorDescription: type: string location: - type: string + type: string \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index fe8012dd..89aadde6 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; @@ -30,9 +31,12 @@ public class ProjectController implements ProjectsApi { private final ProjectsFacade projectsFacade; + private final ProjectRequestValidator projectRequestValidator; + @PostMapping @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { + projectRequestValidator.validate(createProjectRequest); try { return ResponseEntity .status(HttpStatus.OK) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java new file mode 100644 index 00000000..6aa6db20 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -0,0 +1,71 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice(assignableTypes = ProjectController.class) +@Slf4j +public class ProjectExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "projectKey", ErrorKey.PROJECT_KEY_INVALID_FORMAT, + "projectName", ErrorKey.PROJECT_NAME_INVALID_FORMAT, + "projectDescription", ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT, + "projectFlavor", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM + ); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + + if (fieldError != null) { + String field = fieldError.getField(); + + ErrorKey key = FIELD_ERROR_MAP.getOrDefault(field, ErrorKey.BAD_REQUEST_BODY); + + response.setErrorKey(key.getKey()); + response.setMessage(key.getMessage()); + } else { + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage(ErrorKey.BAD_REQUEST_BODY.getMessage()); + } + + return ResponseEntity.badRequest().body(response); + } + + @ExceptionHandler(ProjectValidationException.class) + public ResponseEntity handleValidationException(ProjectValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey(); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(errorKey.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } +} + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java new file mode 100644 index 00000000..8b656e5b --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectValidationException extends RuntimeException { + + private final ErrorKey errorKey; + + public ProjectValidationException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java new file mode 100644 index 00000000..a964261c --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.project.validation; + +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.springframework.stereotype.Component; + +@Component +public class ProjectRequestValidator { + + public void validate(CreateProjectRequest request) { + validateFlavorOrConfigItem(request); + } + + private void validateFlavorOrConfigItem(CreateProjectRequest request) { + String projectFlavor = request.getProjectFlavor(); + String configurationItem = request.getConfigurationItem(); + + boolean hasFlavor = projectFlavor != null && !projectFlavor.trim().isEmpty(); + boolean hasConfigItem = configurationItem != null && !configurationItem.trim().isEmpty(); + + if (!hasFlavor && !hasConfigItem) { + throw new ProjectValidationException( + ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM + ); + } + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index 5e57b723..f40526bf 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -1,15 +1,16 @@ package org.opendevstack.apiservice.project.controller; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -19,22 +20,31 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) class ProjectControllerTest { @Mock private ProjectsFacade projectsFacade; + @Mock + private ProjectRequestValidator projectRequestValidator; + private ProjectController sut; + private AutoCloseable mocks; @BeforeEach void setup() { - sut = new ProjectController(projectsFacade); + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectController(projectsFacade, projectRequestValidator); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); } @Test - void createProject_whenSuccess_thenReturnOk() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + void create_project_returns_ok_when_creation_succeeds() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); CreateProjectResponse serviceResponse = new CreateProjectResponse(); @@ -54,11 +64,12 @@ void createProject_whenSuccess_thenReturnOk() throws Exception { assertThat(result.getBody().getError()).isNull(); assertThat(result.getBody().getErrorKey()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validate(request); } @Test - void createProject_whenProjectCreationException_thenReturnConflict() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + void create_project_returns_conflict_when_project_creation_exception_is_thrown() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("EXISTING"); when(projectsFacade.createProject(any(CreateProjectRequest.class))) @@ -74,11 +85,12 @@ void createProject_whenProjectCreationException_thenReturnConflict() throws Exce assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validate(request); } @Test - void createProject_whenProjectKeyGenerationException_thenReturnInternalServerError() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + void create_project_returns_internal_server_error_when_project_key_generation_exception_is_thrown() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); when(projectsFacade.createProject(any(CreateProjectRequest.class))) .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); @@ -93,10 +105,11 @@ void createProject_whenProjectKeyGenerationException_thenReturnInternalServerErr assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectRequestValidator).validate(request); } @Test - void getProject_whenFound_thenReturnOk() throws Exception { + void get_project_returns_ok_when_project_exists() { CreateProjectResponse serviceResponse = new CreateProjectResponse(); serviceResponse.setProjectKey("PROJ01"); serviceResponse.setStatus("Initiated"); @@ -114,7 +127,7 @@ void getProject_whenFound_thenReturnOk() throws Exception { } @Test - void getProject_whenNotFound_thenReturnNotFound() throws Exception { + void get_project_returns_not_found_when_project_does_not_exist() { when(projectsFacade.getProject("UNKNOWN")).thenReturn(null); ResponseEntity result = sut.getProject("UNKNOWN"); @@ -130,7 +143,7 @@ void getProject_whenNotFound_thenReturnNotFound() throws Exception { } @Test - void getProject_whenServiceThrows_thenReturnInternalServerError() throws Exception { + void get_project_returns_internal_server_error_when_service_throws_exception() { when(projectsFacade.getProject(anyString())) .thenThrow(new RuntimeException("Database error")); diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java new file mode 100644 index 00000000..a3d51427 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -0,0 +1,202 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; + +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ProjectExceptionHandlerTest { + + private ProjectExceptionHandler sut; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectExceptionHandler(); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_project_key_invalid_format() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.PROJECT_KEY_INVALID_FORMAT); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("018", result.getBody().getErrorKey()); + assertEquals("projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_project_name_invalid_format() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.PROJECT_NAME_INVALID_FORMAT); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("019", result.getBody().getErrorKey()); + assertEquals("projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_validation_exception_returns_bad_request_response_for_missing_flavor_and_config_item() { + ProjectValidationException exception = new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("023", result.getBody().getErrorKey()); + assertEquals("Project flavour and config item cannot be both null", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_method_argument_not_valid_exception_returns_bad_request_response_for_request_body_validation_errors() { + CreateProjectRequest target = new CreateProjectRequest(); + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(target, "createProjectRequest"); + bindingResult.addError(new FieldError("createProjectRequest", "projectName", null, false, null, null, + "must not be null")); + + MethodParameter methodParameter; + try { + methodParameter = new MethodParameter( + ProjectController.class.getMethod("createProject", CreateProjectRequest.class), + 0); + } catch (NoSuchMethodException e) { + fail("Failed to reflect controller method for test: " + e.getMessage()); + return; + } + + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity result = sut.handleMethodArgumentNotValidException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), result.getBody().getError()); + assertEquals("019", result.getBody().getErrorKey()); + assertEquals("projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @ParameterizedTest + @MethodSource("provideValidationCases") + void handle_method_argument_not_valid_exception_parameterized( + String failingField, + String expectedErrorKey, + String expectedMessage + ) { + CreateProjectRequest target = new CreateProjectRequest(); + BeanPropertyBindingResult bindingResult = + new BeanPropertyBindingResult(target, "createProjectRequest"); + + bindingResult.addError(new FieldError( + "createProjectRequest", + failingField, + null, + false, + null, + null, + "invalid" + )); + + MethodParameter methodParameter; + try { + methodParameter = new MethodParameter( + ProjectController.class.getMethod("createProject", CreateProjectRequest.class), + 0); + } catch (NoSuchMethodException e) { + fail("Reflection error: " + e.getMessage()); + return; + } + + MethodArgumentNotValidException exception = + new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity result = + sut.handleMethodArgumentNotValidException(exception); + + CreateProjectResponse body = result.getBody(); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(body); + + assertEquals(ProjectController.API_BASE_PATH, body.getLocation()); + assertEquals(HttpStatus.BAD_REQUEST.getReasonPhrase(), body.getError()); + + assertEquals(expectedErrorKey, body.getErrorKey()); + assertEquals(expectedMessage, body.getMessage()); + } + + private static Stream provideValidationCases() { + return Stream.of( + Arguments.of( + "projectName", + "019", + "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$" + ), + Arguments.of( + "projectKey", + "018", + "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$" + ), + Arguments.of( + "projectDescription", + "020", + "projectDescription not met the pattern ^.{0,255}$" + ), + Arguments.of( + "unknownField", + "014", + "Bad Request" + ) + ); + } +} + diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index c11a5964..89ff3989 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -35,7 +35,7 @@ void setup() { @Test void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); ProjectResponse serviceResponse = @@ -58,7 +58,7 @@ void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception @Test void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { - CreateProjectRequest request = new CreateProjectRequest("My Project"); + CreateProjectRequest request = new CreateProjectRequest(); when(projectService.createProject(org.mockito.ArgumentMatchers.any( ProjectRequest.class))) .thenReturn(null); diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java index b7cf95a5..7ecbd890 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/mapper/ProjectMapperTest.java @@ -26,17 +26,14 @@ void setUp() { @Test void to_service_request_maps_all_fields_correctly() { - CreateProjectRequest apiRequest = new CreateProjectRequest("My Project"); + CreateProjectRequest apiRequest = new CreateProjectRequest(); apiRequest.setProjectKey("PROJ01"); - apiRequest.setProjectKeyPattern("SS%06d"); apiRequest.setProjectDescription("A test project"); ProjectRequest result = projectMapper.toServiceRequest(apiRequest); assertNotNull(result); assertEquals("PROJ01", result.getProjectKey()); - assertEquals("SS%06d", result.getProjectKeyPattern()); - assertEquals("My Project", result.getProjectName()); assertEquals("A test project", result.getProjectDescription()); } @@ -48,19 +45,6 @@ void to_service_request_returns_null_when_input_is_null() { assertNull(result); } - - @Test - void to_service_request_maps_only_required_field() { - CreateProjectRequest apiRequest = new CreateProjectRequest("Only Name"); - - ProjectRequest result = projectMapper.toServiceRequest(apiRequest); - - assertNotNull(result); - assertNull(result.getProjectKey()); - assertNull(result.getProjectKeyPattern()); - assertEquals("Only Name", result.getProjectName()); - assertNull(result.getProjectDescription()); - } @Test void to_api_response_maps_all_fields_correctly() { diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java new file mode 100644 index 00000000..3f46360a --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.project.validation; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ProjectRequestValidatorTest { + + private ProjectRequestValidator sut; + + @BeforeEach + void setUp() { + sut = new ProjectRequestValidator(); + } + + @Test + void validate_throws_exception_when_project_flavor_and_config_item_both_null() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(null); + request.setConfigurationItem(null); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validate(request) + ); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @Test + void validate_throws_exception_when_project_flavor_and_config_item_both_empty() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor(""); + request.setConfigurationItem(" "); + + ProjectValidationException exception = assertThrows( + ProjectValidationException.class, + () -> sut.validate(request) + ); + + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "STANDARD, null", + "null, JIRA", + "STANDARD, JIRA" + }) + void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid Name"); + request.setProjectFlavor("null".equals(projectFlavor) ? null : projectFlavor); + request.setConfigurationItem("null".equals(configurationItem) ? null : configurationItem); + + assertDoesNotThrow(() -> sut.validate(request)); + } +} \ No newline at end of file From c273bbe7845cafe61c3d93c53450228cf530c2f4 Mon Sep 17 00:00:00 2001 From: Angel MP Date: Tue, 31 Mar 2026 13:27:00 +0200 Subject: [PATCH 07/27] Feature/create project v0 (#14) --- api-project/openapi/api-project.yaml | 10 +- api-project/pom.xml | 13 ++ .../project/controller/ProjectController.java | 49 ++--- .../controller/ProjectResponseFactory.java | 8 + .../advice/ProjectExceptionHandler.java | 54 +++++- .../ClientAppNotRegisteredException.java | 8 + .../project/exception/ErrorKey.java | 5 +- .../exception/ProjectCreationException.java | 2 +- .../ProjectKeyGenerationException.java | 12 -- .../project/facade/ProjectsFacade.java | 7 +- .../facade/impl/ProjectCreationCommand.java | 31 +++ .../impl/ProjectCreationCommandBuilder.java | 142 ++++++++++++++ .../facade/impl/ProjectsFacadeImpl.java | 71 ++++++- .../mapper/AutomationParametersMapper.java | 26 +++ .../mapper/ProjectCreationResponseMapper.java | 25 +++ .../project/mapper/ProjectMapper.java | 3 + .../project/service/ClientAppService.java | 28 +++ .../validation/ProjectRequestValidator.java | 34 ++++ .../controller/ProjectControllerTest.java | 72 +------ .../advice/ProjectExceptionHandlerTest.java | 74 ++++++- .../ProjectCreationCommandBuilderTest.java | 180 ++++++++++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 176 ++++++++++++----- .../project/service/ClientAppServiceTest.java | 58 ++++++ .../ProjectRequestValidatorTest.java | 101 +++++++++- .../AutomationPlatformException.java | 2 +- .../service/AutomationPlatformService.java | 4 +- .../persistence/dao/ClientDaoImpl.java | 16 +- .../persistence/entity/ClientAppEntity.java | 14 +- .../repository/ClientAppRepository.java | 6 +- ...04_alter_client_apps_client_id_to_uuid.sql | 12 ++ .../repository/ClientAppRepositoryTest.java | 28 +-- service-projects/pom.xml | 8 +- .../ProjectExistenceServiceException.java | 12 ++ .../mapper/ProjectResponseMapper.java | 1 + .../serviceproject/model/ProjectRequest.java | 24 ++- .../serviceproject/model/ProjectResponse.java | 10 + .../service/GenerateProjectKeyService.java | 3 +- .../service/ProjectExistenceService.java | 8 + .../service/ProjectService.java | 2 +- .../impl/GenerateProjectKeyServiceImpl.java | 99 +--------- .../impl/ProjectExistenceServiceImpl.java | 84 ++++++++ .../service/impl/ProjectServiceImpl.java | 48 +++-- .../GenerateProjectKeyServiceImplTest.java | 45 ++--- .../impl/ProjectExistenceServiceImplTest.java | 137 +++++++++++++ .../service/impl/ProjectServiceImplTest.java | 44 ----- 45 files changed, 1399 insertions(+), 397 deletions(-) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java delete mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java create mode 100644 persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java create mode 100644 service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java create mode 100644 service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 30820a93..26f00f7b 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -138,19 +138,21 @@ components: maxLength: 255 projectFlavor: type: string - description: Flavor of the project. Either projectFlavor or configurationItem must be provided. + description: Project flavor. Must be provided if configurationItem is not present. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. configurationItem: type: string - description: Configuration item for the project. Either projectFlavor or configurationItem must be provided. + description: Configuration item. Must be present if projectFlavor is not provided. If both projectFlavor and configurationItem are missing, error BAD_REQUEST_FLAVOR_CONFIG_ITEM (023) is returned. location: type: string - description: Location of the project. + description: Location of the project. Must be one of the allowed locations. If not in the allowed list, error INVALID_LOCATION (011) is returned. x2OdsAccount: type: string description: Technical account of the project. + pattern: "^x2[a-zA-Z0-9]{0,13}$" owner: type: string - description: Owner of the project. + description: Owner of the project. If x2OdsAccount is provided but owner is missing, error MANDATORY_OWNER (024) is returned. + pattern: "^[a-z]{1,10}$" oneOf: - required: [projectFlavor] - required: [configurationItem] diff --git a/api-project/pom.xml b/api-project/pom.xml index e77eda9f..3a7962e1 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -33,6 +33,13 @@ org.springframework.boot spring-boot-starter-validation + + + org.springframework.boot + spring-boot-devtools + runtime + true + org.springdoc @@ -44,6 +51,12 @@ service-projects ${project.version} + + + org.opendevstack.apiservice + persistence + ${project.version} + io.jsonwebtoken diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index 89aadde6..b7141337 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -4,11 +4,9 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectsApi; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -19,6 +17,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @RequestMapping(ProjectController.API_BASE_PATH) @AllArgsConstructor @@ -37,44 +37,27 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - try { - return ResponseEntity - .status(HttpStatus.OK) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(projectsFacade.createProject(createProjectRequest)); - } catch (ProjectCreationException e) { - log.error("Project creation conflict: {}", e.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.conflict(e.getMessage(), API_BASE_PATH)); - } catch (ProjectKeyGenerationException e) { - log.error("Failed to generate project key: {}", e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .header(HTTP_HEADER_LOCATION, API_BASE_PATH) - .body(ProjectResponseFactory.projectKeyGenerationFailed(API_BASE_PATH)); - } + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); + projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); + return ResponseEntity + .status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, API_BASE_PATH) + .body(projectResponse); } @GetMapping("/{projectKey}") @Override public ResponseEntity getProject(@PathVariable String projectKey) { String location = API_BASE_PATH + "/" + projectKey; - try { - CreateProjectResponse response = projectsFacade.getProject(projectKey); - if (response == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .header(HTTP_HEADER_LOCATION, location) - .body(ProjectResponseFactory.notFound(projectKey, location)); - } - return ResponseEntity - .status(HttpStatus.OK) - .header(HTTP_HEADER_LOCATION, location) - .body(response); - } catch (Exception e) { - log.error("Unexpected error retrieving project '{}': {}", projectKey, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + CreateProjectResponse response = projectsFacade.getProject(projectKey); + if (response == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) .header(HTTP_HEADER_LOCATION, location) - .body(ProjectResponseFactory.internalError(location)); + .body(ProjectResponseFactory.notFound(projectKey, location)); } + return ResponseEntity.status(HttpStatus.OK) + .header(HTTP_HEADER_LOCATION, location) + .body(response); } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java index b1ed5d2a..caf14c9d 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -39,6 +39,14 @@ public static CreateProjectResponse internalError(String location) { location); } + public static CreateProjectResponse internalError(String location, String message) { + return error( + ErrorKey.INTERNAL_ERROR.getMessage(), + ErrorKey.INTERNAL_ERROR.getKey(), + message, + location); + } + private static CreateProjectResponse error(String error, String errorKey, String message, String location) { CreateProjectResponse response = new CreateProjectResponse(); response.setError(error); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java index 6aa6db20..56308e1c 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -1,8 +1,11 @@ package org.opendevstack.apiservice.project.controller.advice; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.springframework.http.HttpStatus; @@ -23,7 +26,9 @@ public class ProjectExceptionHandler { "projectName", ErrorKey.PROJECT_NAME_INVALID_FORMAT, "projectDescription", ErrorKey.PROJECT_DESCRIPTION_INVALID_FORMAT, "projectFlavor", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, - "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM + "configurationItem", ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, + "x2OdsAccount", ErrorKey.PROJECT_X2ACCOUNT_INVALID_FORMAT, + "owner", ErrorKey.PROJECT_OWNER_INVALID_FORMAT ); @ExceptionHandler(MethodArgumentNotValidException.class) @@ -55,6 +60,18 @@ public ResponseEntity handleMethodArgumentNotValidExcepti return ResponseEntity.badRequest().body(response); } + @ExceptionHandler(ClientAppNotRegisteredException.class) + public ResponseEntity handleClientAppNotRegisteredException( + ClientAppNotRegisteredException ex) { + log.warn("ClientApp registration error: {}", ex.getMessage()); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.FORBIDDEN.getReasonPhrase()); + response.setErrorKey(ErrorKey.CLIENT_APP_NOT_REGISTERED.getKey()); + response.setMessage(ErrorKey.CLIENT_APP_NOT_REGISTERED.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + @ExceptionHandler(ProjectValidationException.class) public ResponseEntity handleValidationException(ProjectValidationException ex) { log.warn("Validation error: {}", ex.getMessage()); @@ -66,6 +83,41 @@ public ResponseEntity handleValidationException(ProjectVa response.setMessage(errorKey.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + + @ExceptionHandler(ProjectCreationException.class) + public ResponseEntity handleProjectCreationException( + ProjectCreationException ex) { + log.error("Project creation error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(AutomationPlatformException.class) + public ResponseEntity handleAutomationPlatformException( + AutomationPlatformException ex) { + log.error("Failed to execute automated job: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage(ex.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.INTERNAL_ERROR.getMessage()); + response.setErrorKey(ErrorKey.INTERNAL_ERROR.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java new file mode 100644 index 00000000..a3b8c751 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ClientAppNotRegisteredException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ClientAppNotRegisteredException extends RuntimeException { + + public ClientAppNotRegisteredException(String clientId) { + super(String.format("ClientApp with clientId '%s' is not registered", clientId)); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java index 4ee644d5..fd7904b8 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -28,7 +28,10 @@ public enum ErrorKey { BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"), MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"), PROJECT_ALREADY_EXISTS("025", "Project already exists"), - PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"); + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"), + CLIENT_APP_NOT_REGISTERED("027", "ClientApp not registered, manual registration required"), + INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), + INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); private String key; private String message; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java index 87e12af6..8189cdc7 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectCreationException.java @@ -1,6 +1,6 @@ package org.opendevstack.apiservice.project.exception; -public class ProjectCreationException extends Exception { +public class ProjectCreationException extends RuntimeException { public ProjectCreationException(String message) { super(message); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java deleted file mode 100644 index be9b66a0..00000000 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectKeyGenerationException.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.opendevstack.apiservice.project.exception; - -public class ProjectKeyGenerationException extends Exception { - - public ProjectKeyGenerationException(String message) { - super(message); - } - - public ProjectKeyGenerationException(String message, Throwable cause) { - super(message, cause); - } -} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index b30ad4f4..f8b0112e 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -1,14 +1,13 @@ package org.opendevstack.apiservice.project.facade; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import java.util.UUID; + public interface ProjectsFacade { - CreateProjectResponse createProject(CreateProjectRequest request) - throws ProjectCreationException, ProjectKeyGenerationException; + CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId); CreateProjectResponse getProject(String projectKey); } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java new file mode 100644 index 00000000..2267c0b1 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommand.java @@ -0,0 +1,31 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ProjectCreationCommand { + + private String projectKey; + + private String projectName; + + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java new file mode 100644 index 00000000..10b087dd --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -0,0 +1,142 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.logging.log4j.util.Strings; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ProjectCreationCommandBuilder { + + private final GenerateProjectKeyService generateProjectKeyService; + + private final ProjectExistenceService projectExistenceService; + + public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp) { + ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + + String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); + String configurationItem = firstNonBlank(request.getConfigurationItem(), flavor.getConfigItem()); + String owner = firstNonBlank(request.getOwner(), flavor.getProjectOwner()); + String x2account = firstNonBlank(request.getX2OdsAccount(), flavor.getServiceAccount()); + String location = firstNonBlank(request.getLocation(), flavor.getLocation()); + String projectKey = resolveProjectKey(request.getProjectKey(), flavor); + String projectName = firstNonBlank(request.getProjectName(), projectKey); + String projectDescription = firstNonBlank(request.getProjectDescription(), "project " + projectFlavor); + + return new ProjectCreationCommand( + projectKey, + projectName, + projectDescription, + projectFlavor, + configurationItem, + location, + x2account, + owner, + clientApp.getId()); + } + + private ClientAppProjectFlavorEntity resolveFlavor(CreateProjectRequest request, ClientAppEntity clientApp) { + List flavors = clientApp.getProjectFlavors(); + + if (flavors == null || flavors.isEmpty()) { + log.warn("ClientApp '{}' has no project flavors configured", clientApp.getClientId()); + throw new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + } + + if (Strings.isNotEmpty(request.getProjectFlavor())) { + return resolveByFlavorName(request.getProjectFlavor(), flavors, clientApp.getClientId()); + } + + if (Strings.isNotEmpty(request.getConfigurationItem())) { + return resolveByConfigurationItem(request.getConfigurationItem(), flavors, clientApp.getClientId()); + } + + throw new ProjectValidationException(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM); + } + + private ClientAppProjectFlavorEntity resolveByFlavorName( + String flavorName, List flavors, UUID clientId) { + return flavors.stream() + .filter(f -> flavorName.equals(f.getName())) + .findFirst() + .orElseThrow(() -> { + log.warn("Flavor '{}' is not configured for clientApp '{}'", flavorName, clientId); + return new ProjectValidationException(ErrorKey.INVALID_PROJECT_FLAVOR); + }); + } + + private ClientAppProjectFlavorEntity resolveByConfigurationItem( + String configurationItem, List flavors, UUID clientId) { + List matchingFlavors = flavors.stream() + .filter(f -> configurationItem.equals(f.getConfigItem())) + .toList(); + + if (matchingFlavors.size() != 1) { + log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", + configurationItem, clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); + + if (!isAllowedConfigItem(configurationItem, matchedFlavor)) { + log.warn("ConfigItem '{}' is not in the allowed list for flavor '{}' of clientApp '{}'", + configurationItem, matchedFlavor.getName(), clientId); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + } + + return matchedFlavor; + } + + private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { + try { + if (Strings.isNotEmpty(existingProjectKey)) { + if (!projectExistenceService.isProjectFound(existingProjectKey)) { + return existingProjectKey; + } + + throw new ProjectValidationException(ErrorKey.PROJECT_ALREADY_EXISTS); + } + + String pattern = flavor.getProjectKeyPattern(); + + return generateProjectKeyService.generateProjectKey(pattern); + } catch (ProjectKeyGenerationException e) { + throw new ProjectCreationException("Error generating the project key", e); + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if the generated key exists: " + e.getMessage(), e); + } + } + + private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { + String[] allowedConfigItems = flavor.getAllowedConfigItems(); + + if (allowedConfigItems == null || allowedConfigItems.length == 0) { + return true; + } + + return Arrays.asList(allowedConfigItems).contains(configurationItem); + } + + private String firstNonBlank(String preferred, String fallback) { + return Strings.isNotEmpty(preferred) ? preferred : fallback; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index ea66ee83..87a9046c 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -1,31 +1,92 @@ package org.opendevstack.apiservice.project.facade.impl; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.service.ClientAppService; +import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Map; +import java.util.UUID; + @Component("apiProjectFacadeImpl") +@Slf4j public class ProjectsFacadeImpl implements ProjectsFacade { + @Value("${apis.projects.ansible-workflow-name}") + private String createProjectWorkflow; + private final ProjectService projectService; + private final ProjectMapper projectMapper; + + private final AutomationParametersMapper automationParametersMapper; + + private final ProjectCreationResponseMapper projectCreationResponseMapper; + + private final ProjectCreationCommandBuilder projectCreationCommandBuilder; + + private final ClientAppService clientAppService; + + private final AutomationPlatformService automationPlatformService; public ProjectsFacadeImpl( ProjectService projectService, - ProjectMapper projectMapper) { + ProjectMapper projectMapper, + AutomationParametersMapper automationParametersMapper, + ProjectCreationResponseMapper projectCreationResponseMapper, + ProjectCreationCommandBuilder projectCreationCommandBuilder, + ClientAppService clientAppService, + AutomationPlatformService automationPlatformService) { this.projectService = projectService; this.projectMapper = projectMapper; + this.automationParametersMapper = automationParametersMapper; + this.projectCreationResponseMapper = projectCreationResponseMapper; + this.projectCreationCommandBuilder = projectCreationCommandBuilder; + this.clientAppService = clientAppService; + this.automationPlatformService = automationPlatformService; } @Override - public CreateProjectResponse createProject(CreateProjectRequest request) - throws ProjectCreationException, ProjectKeyGenerationException { - return projectMapper.toApiResponse(projectService.createProject(projectMapper.toServiceRequest(request))); + public CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId) { + + ClientAppEntity clientApp = clientAppService.findByClientId(clientId); + + ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp); + + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); + projectRequest.setStatus(Status.PENDING); + + ProjectResponse project = projectService.saveProject(projectRequest); + + String projectId = project.getProjectId().toString(); + Map workflowParameters = automationParametersMapper.toWorkflowParameters(command, projectId); + + AutomationExecutionResult automationExecutionResult = automationPlatformService + .executeWorkflow(createProjectWorkflow, workflowParameters); + + if (automationExecutionResult.isSuccessful()) { + return projectCreationResponseMapper.toSuccessResponse(command, project); + } else { + projectRequest.setProjectId(project.getProjectId()); + projectRequest.setStatus(Status.FAILED); + projectService.saveProject(projectRequest); + throw new ProjectCreationException("Failed to create project: " + + automationExecutionResult.getErrorDetails()); + } } @Override diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java new file mode 100644 index 00000000..fa1ea7e5 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/AutomationParametersMapper.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; + +import java.util.HashMap; +import java.util.Map; + +@Mapper(componentModel = "spring") +public interface AutomationParametersMapper { + + default Map toWorkflowParameters(ProjectCreationCommand command, String projectId) { + Map parameters = new HashMap<>(); + parameters.put("geographic_region", command.getLocation()); + parameters.put("project_flavor", command.getProjectFlavor()); + parameters.put("project_owner", command.getOwner()); + parameters.put("project_id", projectId); + parameters.put("configuration_item", command.getConfigurationItem()); + parameters.put("project_key", command.getProjectKey()); + parameters.put("special_account", command.getX2OdsAccount()); + parameters.put("description", command.getProjectDescription()); + parameters.put("project_name", command.getProjectName()); + parameters.put("client_id", command.getClientId().toString()); + return parameters; + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java new file mode 100644 index 00000000..0eb9a531 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; + +@Mapper(componentModel = "spring") +public interface ProjectCreationResponseMapper { + + @Mapping(target = "message", constant = "The project creation process has been successfully initiated.") + @Mapping(target = "status", ignore = true) + @Mapping(target = "httStatus", constant = "OK") + @Mapping(target = "errorKey", constant = "000") + @Mapping(target = "projectKey", source = "project.projectKey") + @Mapping(target = "projectFlavor", ignore = true) + @Mapping(target = "location", ignore = true) + @Mapping(target = "error", ignore = true) + @Mapping(target = "errorDescription", ignore = true) + CreateProjectResponse toSuccessResponse(ProjectCreationCommand command, ProjectResponse project); +} + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java index 181b571c..adb2032f 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectMapper.java @@ -3,6 +3,7 @@ import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; +import org.opendevstack.apiservice.project.facade.impl.ProjectCreationCommand; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; @@ -16,6 +17,8 @@ public interface ProjectMapper { ProjectRequest toServiceRequest(CreateProjectRequest apiRequest); + ProjectRequest toServiceRequest(ProjectCreationCommand command); + @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") @Mapping(source = "projectKey", target = "location", qualifiedByName = "mapLocation") @Mapping(source = ".", target = "errorDescription", qualifiedByName = "mapErrorDescription") diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java new file mode 100644 index 00000000..8c153187 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/service/ClientAppService.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.project.service; + +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ClientAppService { + + private final ClientAppRepository clientAppRepository; + + @Transactional(readOnly = true) + public ClientAppEntity findByClientId(UUID clientId) { + log.debug("Looking up ClientApp for clientId={}", clientId); + return clientAppRepository.findDetailedByClientId(clientId) + .orElseThrow(() -> { + log.warn("ClientApp not found for clientId={}", clientId); + return new ClientAppNotRegisteredException(clientId.toString()); + }); + } +} \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java index a964261c..effe5729 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -3,13 +3,21 @@ import org.opendevstack.apiservice.project.exception.ErrorKey; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.List; + @Component public class ProjectRequestValidator { + @Value("${apis.projects.locations}") + private List locations; + public void validate(CreateProjectRequest request) { validateFlavorOrConfigItem(request); + validateOwnerIfX2AccountIsPresent(request); + validateLocation(request); } private void validateFlavorOrConfigItem(CreateProjectRequest request) { @@ -25,4 +33,30 @@ private void validateFlavorOrConfigItem(CreateProjectRequest request) { ); } } + + private void validateOwnerIfX2AccountIsPresent(CreateProjectRequest request) { + String x2Account = request.getX2OdsAccount(); + String owner = request.getOwner(); + + boolean hasX2Account = x2Account != null && !x2Account.trim().isEmpty(); + boolean hasOwner = owner != null && !owner.trim().isEmpty(); + + if(hasX2Account && !hasOwner) { + throw new ProjectValidationException( + ErrorKey.MANDATORY_OWNER + ); + } + } + + private void validateLocation(CreateProjectRequest request) { + String location = request.getLocation(); + + if (location == null || location.trim().isEmpty()) { + return; + } + + if (!locations.contains(location)) { + throw new ProjectValidationException(ErrorKey.INVALID_LOCATION); + } + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index f40526bf..fbd11231 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -5,8 +5,6 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.project.exception.ProjectCreationException; -import org.opendevstack.apiservice.project.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -16,10 +14,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.UUID; + class ProjectControllerTest { @Mock @@ -43,16 +42,17 @@ void tearDown() throws Exception { } @Test - void create_project_returns_ok_when_creation_succeeds() throws Exception { + void create_project_returns_ok_when_creation_succeeds() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey("PROJ01"); CreateProjectResponse serviceResponse = new CreateProjectResponse(); serviceResponse.setProjectKey("PROJ01"); serviceResponse.setStatus("Initiated"); + serviceResponse.setErrorKey("000"); serviceResponse.setMessage("The project creation process has been successfully initiated."); - when(projectsFacade.createProject(any(CreateProjectRequest.class))) + when(projectsFacade.createProject(any(CreateProjectRequest.class), any(UUID.class))) .thenReturn(serviceResponse); ResponseEntity result = sut.createProject(request); @@ -62,50 +62,10 @@ void create_project_returns_ok_when_creation_succeeds() throws Exception { assertThat(result.getBody().getProjectKey()).isEqualTo("PROJ01"); assertThat(result.getBody().getStatus()).isEqualTo("Initiated"); assertThat(result.getBody().getError()).isNull(); - assertThat(result.getBody().getErrorKey()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); - } - - @Test - void create_project_returns_conflict_when_project_creation_exception_is_thrown() throws Exception { - CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectKey("EXISTING"); - - when(projectsFacade.createProject(any(CreateProjectRequest.class))) - .thenThrow(new ProjectCreationException("Project with key 'EXISTING' already exists")); - - ResponseEntity result = sut.createProject(request); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Project already exists"); - assertThat(result.getBody().getErrorKey()).isEqualTo("025"); - assertThat(result.getBody().getMessage()).contains("Project with key 'EXISTING' already exists"); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); - } - - @Test - void create_project_returns_internal_server_error_when_project_key_generation_exception_is_thrown() throws Exception { - CreateProjectRequest request = new CreateProjectRequest(); - - when(projectsFacade.createProject(any(CreateProjectRequest.class))) - .thenThrow(new ProjectKeyGenerationException("Failed to generate unique project key after 10 retries")); - - ResponseEntity result = sut.createProject(request); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Internal error"); - assertThat(result.getBody().getErrorKey()).isEqualTo("PROJECT_KEY_GENERATION_FAILED"); - assertThat(result.getBody().getMessage()).isEqualTo("Failed to generate a unique project key."); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); + assertThat(result.getBody().getErrorKey()).isEqualTo("000"); assertThat(result.getBody().getErrorDescription()).isNull(); verify(projectRequestValidator).validate(request); + verify(projectsFacade).createProject(any(CreateProjectRequest.class), any(UUID.class)); } @Test @@ -140,23 +100,7 @@ void get_project_returns_not_found_when_project_does_not_exist() { assertThat(result.getBody().getProjectKey()).isNull(); assertThat(result.getBody().getStatus()).isNull(); assertThat(result.getBody().getErrorDescription()).isNull(); - } - - @Test - void get_project_returns_internal_server_error_when_service_throws_exception() { - when(projectsFacade.getProject(anyString())) - .thenThrow(new RuntimeException("Database error")); - - ResponseEntity result = sut.getProject("PROJ01"); - - assertThat(result.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(result.getBody()).isNotNull(); - assertThat(result.getBody().getError()).isEqualTo("Internal error"); - assertThat(result.getBody().getErrorKey()).isEqualTo("003"); - assertThat(result.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); - assertThat(result.getBody().getProjectKey()).isNull(); - assertThat(result.getBody().getStatus()).isNull(); - assertThat(result.getBody().getErrorDescription()).isNull(); + verify(projectsFacade).getProject("UNKNOWN"); } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java index a3d51427..45435a01 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -7,8 +7,11 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.exception.AutomationPlatformException; import org.opendevstack.apiservice.project.controller.ProjectController; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -22,9 +25,9 @@ import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.fail; class ProjectExceptionHandlerTest { @@ -197,6 +200,71 @@ private static Stream provideValidationCases() { "Bad Request" ) ); - } -} + } + + @Test + void handle_project_creation_exception_returns_conflict() { + ProjectCreationException exception = new ProjectCreationException("error message"); + + ResponseEntity result = sut.handleProjectCreationException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("error message", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } + + @Test + void handle_client_app_not_registered_exception_returns_forbidden() { + ClientAppNotRegisteredException exception = new ClientAppNotRegisteredException("client-123"); + + ResponseEntity result = sut.handleClientAppNotRegisteredException(exception); + + assertEquals(HttpStatus.FORBIDDEN, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals(HttpStatus.FORBIDDEN.getReasonPhrase(), result.getBody().getError()); + assertEquals("027", result.getBody().getErrorKey()); + assertEquals("ClientApp not registered, manual registration required", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + + @Test + void handle_automation_platform_exception_returns_internal_server_error() { + AutomationPlatformException exception = new AutomationPlatformException("AAP job failed"); + + ResponseEntity result = sut.handleAutomationPlatformException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("AAP job failed", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + } + @Test + void handle_generic_exception_returns_internal_server_error() { + RuntimeException exception = new RuntimeException("Database error"); + + ResponseEntity result = sut.handleGenericException(exception); + + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Internal error", result.getBody().getError()); + assertEquals("003", result.getBody().getErrorKey()); + assertEquals("An error occurred while processing the request.", result.getBody().getMessage()); + assertNull(result.getBody().getProjectKey()); + assertNull(result.getBody().getStatus()); + assertNull(result.getBody().getErrorDescription()); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java new file mode 100644 index 00000000..c537efd3 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -0,0 +1,180 @@ +package org.opendevstack.apiservice.project.facade.impl; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectCreationCommandBuilderTest { + + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Mock + private GenerateProjectKeyService generateProjectKeyService; + + @Mock + private ProjectExistenceService projectExistenceService; + + private ProjectCreationCommandBuilder sut; + + private AutoCloseable mocks; + + @BeforeEach + void set_up() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectCreationCommandBuilder(generateProjectKeyService, projectExistenceService); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); + } + + @Test + void build_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setOwner(null); + request.setLocation(null); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.build(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + assertEquals("owner1", result.getOwner()); + assertEquals("eu", result.getLocation()); + assertEquals("KEY01", result.getProjectKey()); + } + + @Test + void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.build(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + } + + @Test + void build_generates_project_key_when_request_project_key_is_null() throws ProjectExistenceServiceException, org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); + when(projectExistenceService.isProjectFound("DLSS000001")).thenReturn(false); + + ProjectCreationCommand result = sut.build(request, clientApp); + + assertEquals("DLSS000001", result.getProjectKey()); + verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); + } + + @Test + void build_throws_validation_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(true); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp)); + assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); + } + + @Test + void build_throws_validation_exception_when_flavor_and_config_item_are_missing() { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request(null, null, "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp)); + assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_throws_validation_exception_when_configuration_item_matches_multiple_flavors() { + ClientAppEntity clientApp = build_client_app(List.of( + build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"), + build_flavor("AMP", "CI-001", new String[] {}, "eu", "owner2"))); + CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); + + ProjectValidationException ex = assertThrows(ProjectValidationException.class, + () -> sut.build(request, clientApp)); + assertEquals(ErrorKey.INVALID_CONFIG_ITEM, ex.getErrorKey()); + } + + @Test + void build_throws_project_key_generation_exception_when_generation_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, null); + + when(generateProjectKeyService.generateProjectKey("DLSS%06d")) + .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); + + assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); + } + + private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectKey(projectKey); + request.setProjectFlavor(flavor); + request.setConfigurationItem(configItem); + request.setProjectName("Test Project"); + request.setProjectDescription("A test project"); + request.setOwner("testowner"); + request.setLocation("eu"); + request.setX2OdsAccount("x2test"); + return request; + } + + private ClientAppEntity build_client_app(List flavors) { + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(CLIENT_ID) + .clientName("Test App") + .build(); + entity.setProjectFlavors(flavors); + return entity; + } + + private ClientAppProjectFlavorEntity build_flavor( + String name, String configItem, String[] allowedConfigItems, String location, String projectOwner) { + return ClientAppProjectFlavorEntity.builder() + .name(name) + .configItem(configItem) + .allowedConfigItems(allowedConfigItems) + .projectKeyPattern(name + "%06d") + .location(location) + .projectOwner(projectOwner) + .build(); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index 89ff3989..eb15edd8 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -1,95 +1,175 @@ package org.opendevstack.apiservice.project.facade.impl; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.aap.model.AutomationExecutionResult; +import org.opendevstack.apiservice.externalservice.aap.service.AutomationPlatformService; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.project.exception.ProjectCreationException; +import org.opendevstack.apiservice.project.mapper.AutomationParametersMapper; +import org.opendevstack.apiservice.project.mapper.ProjectCreationResponseMapper; import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; import org.opendevstack.apiservice.serviceproject.service.ProjectService; -import static org.assertj.core.api.Assertions.assertThat; +import java.lang.reflect.Field; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@ExtendWith(MockitoExtension.class) class ProjectsFacadeImplTest { + private static final UUID CLIENT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + @Mock private ProjectService projectService; - - private final ProjectMapper projectMapper = Mappers.getMapper(ProjectMapper.class); + + @Mock + private ProjectMapper projectMapper; + + @Mock + private AutomationParametersMapper automationParametersMapper; + + @Mock + private ProjectCreationResponseMapper projectCreationResponseMapper; + + @Mock + private ProjectCreationCommandBuilder projectCreationCommandBuilder; + + @Mock + private ClientAppService clientAppService; + + @Mock + private AutomationPlatformService automationPlatformService; private ProjectsFacadeImpl sut; + private AutoCloseable mocks; @BeforeEach - void setup() { - sut = new ProjectsFacadeImpl(projectService, projectMapper); + void set_up() throws Exception { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectsFacadeImpl( + projectService, + projectMapper, + automationParametersMapper, + projectCreationResponseMapper, + projectCreationCommandBuilder, + clientAppService, + automationPlatformService); + + Field workflowField = ProjectsFacadeImpl.class.getDeclaredField("createProjectWorkflow"); + workflowField.setAccessible(true); + workflowField.set(sut, "create-project-workflow"); + } + + @AfterEach + void tear_down() throws Exception { + mocks.close(); } @Test - void createProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { + void create_project_returns_success_response_when_automation_is_successful() { CreateProjectRequest request = new CreateProjectRequest(); - request.setProjectKey("PROJ01"); - - ProjectResponse serviceResponse = - new ProjectResponse(); - serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus(Status.PENDING); - - when(projectService.createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class))) - .thenReturn(serviceResponse); - - CreateProjectResponse response = sut.createProject(request); - - assertThat(response).isNotNull(); - assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Pending"); - verify(projectService).createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class)); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + ProjectRequest serviceRequest = new ProjectRequest(); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build(); + CreateProjectResponse apiResponse = new CreateProjectResponse(); + apiResponse.setStatus("Pending"); + apiResponse.setProjectFlavor("DLSS"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); + when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of("project_key", "DLSS01")); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.success("job-1", "ok")); + when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); + + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); + + assertEquals("Pending", result.getStatus()); + assertEquals("DLSS", result.getProjectFlavor()); + verify(projectCreationCommandBuilder).build(request, clientApp); + verify(projectMapper).toServiceRequest(command); + verify(automationParametersMapper) + .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); + verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); } @Test - void createProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + void create_project_throws_project_creation_exception_when_automation_is_not_successful() { CreateProjectRequest request = new CreateProjectRequest(); - when(projectService.createProject(org.mockito.ArgumentMatchers.any( - ProjectRequest.class))) - .thenReturn(null); - - CreateProjectResponse response = sut.createProject(request); - - assertThat(response).isNull(); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); + when(projectService.saveProject(any(ProjectRequest.class))) + .thenReturn(ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("DLSS01") + .status(Status.PENDING) + .build()); + when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) + .thenReturn(Map.of()); + when(automationPlatformService.executeWorkflow(anyString(), anyMap())) + .thenReturn(AutomationExecutionResult.failure("job-1", "error", "workflow failed")); + + assertThrows(ProjectCreationException.class, () -> sut.createProject(request, CLIENT_ID)); } @Test - void getProject_whenServiceReturnsValue_thenMapToApiModel() throws Exception { - ProjectResponse serviceResponse = - new ProjectResponse(); - serviceResponse.setProjectKey("PROJ01"); - serviceResponse.setStatus(Status.RUNNING); + void get_project_returns_mapped_response_when_service_returns_project() { + ProjectResponse serviceResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.RUNNING) + .build(); + CreateProjectResponse mappedResponse = new CreateProjectResponse(); + mappedResponse.setProjectKey("PROJ01"); + mappedResponse.setStatus("Running"); when(projectService.getProject("PROJ01")).thenReturn(serviceResponse); + when(projectMapper.toApiResponse(serviceResponse)).thenReturn(mappedResponse); - CreateProjectResponse response = sut.getProject("PROJ01"); + CreateProjectResponse result = sut.getProject("PROJ01"); - assertThat(response).isNotNull(); - assertThat(response.getProjectKey()).isEqualTo("PROJ01"); - assertThat(response.getStatus()).isEqualTo("Running"); + assertEquals("PROJ01", result.getProjectKey()); + assertEquals("Running", result.getStatus()); } @Test - void getProject_whenServiceReturnsNull_thenReturnNull() throws Exception { + void get_project_returns_null_when_service_returns_null() { when(projectService.getProject("UNKNOWN")).thenReturn(null); + when(projectMapper.toApiResponse(null)).thenReturn(null); - CreateProjectResponse response = sut.getProject("UNKNOWN"); + CreateProjectResponse result = sut.getProject("UNKNOWN"); - assertThat(response).isNull(); + assertNull(result); } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java new file mode 100644 index 00000000..a0737fa7 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/service/ClientAppServiceTest.java @@ -0,0 +1,58 @@ +package org.opendevstack.apiservice.project.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; +import org.opendevstack.apiservice.persistence.repository.ClientAppRepository; +import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; + +class ClientAppServiceTest { + + @Mock + private ClientAppRepository clientAppRepository; + private ClientAppService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new ClientAppService(clientAppRepository); + } + + @Test + void findByClientId_returns_entity_when_client_exists() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + ClientAppEntity entity = ClientAppEntity.builder() + .clientId(clientId) + .clientName("Test App") + .build(); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.of(entity)); + + ClientAppEntity result = sut.findByClientId(clientId); + + assertThat(result).isNotNull(); + assertThat(result.getClientId()).isEqualTo(clientId); + assertThat(result.getClientName()).isEqualTo("Test App"); + verify(clientAppRepository).findDetailedByClientId(clientId); + } + + @Test + void findByClientId_throws_exception_when_client_not_found() { + + UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + when(clientAppRepository.findDetailedByClientId(clientId)).thenReturn(Optional.empty()); + + assertThrows( + ClientAppNotRegisteredException.class, + () -> sut.findByClientId(clientId)); + verify(clientAppRepository).findDetailedByClientId(clientId); + } +} \ No newline at end of file diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java index 3f46360a..596f39cd 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java @@ -8,6 +8,9 @@ import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import java.lang.reflect.Field; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -17,8 +20,13 @@ class ProjectRequestValidatorTest { private ProjectRequestValidator sut; @BeforeEach - void setUp() { + void setUp() throws NoSuchFieldException, IllegalAccessException { sut = new ProjectRequestValidator(); + + Field locationsField = ProjectRequestValidator.class.getDeclaredField("locations"); + locationsField.setAccessible(true); + locationsField.set(sut, List.of("MADRID", "BARCELONA", "SANT_CUGAT")); + } @Test @@ -29,8 +37,8 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_null() { request.setConfigurationItem(null); ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.validate(request) + ProjectValidationException.class, + () -> sut.validate(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); @@ -44,8 +52,8 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() request.setConfigurationItem(" "); ProjectValidationException exception = assertThrows( - ProjectValidationException.class, - () -> sut.validate(request) + ProjectValidationException.class, + () -> sut.validate(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); @@ -53,9 +61,9 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() @ParameterizedTest @CsvSource({ - "STANDARD, null", - "null, JIRA", - "STANDARD, JIRA" + "STANDARD, null", + "null, JIRA", + "STANDARD, JIRA" }) void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { CreateProjectRequest request = new CreateProjectRequest(); @@ -65,4 +73,81 @@ void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, assertDoesNotThrow(() -> sut.validate(request)); } + + @Test + void validate_throws_exception_when_x2account_present_but_owner_missing() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + request.setX2OdsAccount("x2abc123"); + request.setOwner(null); + + ProjectValidationException exception = + assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + + assertEquals(ErrorKey.MANDATORY_OWNER, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "x2abc, owner1", + "x2x, xowner", + "null, owner1", + "x2valid, ownerX" + }) + void validate_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setX2OdsAccount("null".equals(x2) ? null : x2); + request.setOwner(owner); + + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_succeeds_when_location_is_null_or_empty() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setLocation(null); + assertDoesNotThrow(() -> sut.validate(request)); + + request.setLocation(""); + assertDoesNotThrow(() -> sut.validate(request)); + + request.setLocation(" "); + assertDoesNotThrow(() -> sut.validate(request)); + } + + @Test + void validate_throws_exception_when_location_not_in_allowed_list() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + + request.setLocation("INVALID_LOCATION_NOT_ALLOWED"); + + ProjectValidationException exception = + assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + + assertEquals(ErrorKey.INVALID_LOCATION, exception.getErrorKey()); + } + + @ParameterizedTest + @CsvSource({ + "MADRID", + "BARCELONA", + "SANT_CUGAT" + }) + void validate_succeeds_when_location_is_allowed(String location) { + CreateProjectRequest request = new CreateProjectRequest(); + request.setProjectName("Valid"); + request.setProjectFlavor("STANDARD"); + request.setLocation(location); + + assertDoesNotThrow(() -> sut.validate(request)); + } } \ No newline at end of file diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java index 2c7686f5..6a0805bd 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/exception/AutomationPlatformException.java @@ -3,7 +3,7 @@ /** * Exception thrown when there are issues with automation platform operations. */ -public class AutomationPlatformException extends Exception { +public class AutomationPlatformException extends RuntimeException { private final String errorCode; diff --git a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java index 57cecb78..57b042f9 100644 --- a/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java +++ b/external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/service/AutomationPlatformService.java @@ -22,7 +22,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the execution result * @throws AutomationPlatformException if workflow execution fails */ - AutomationExecutionResult executeWorkflow(String workflowName, Map parameters) throws AutomationPlatformException; + AutomationExecutionResult executeWorkflow(String workflowName, Map parameters); /** * Executes a workflow on the automation platform asynchronously. @@ -50,7 +50,7 @@ public interface AutomationPlatformService extends ExternalService { * @return the workflow job status and result * @throws AutomationPlatformException if status check fails */ - AutomationJobStatus getWorkflowJobStatus(String workflowId) throws AutomationPlatformException; + AutomationJobStatus getWorkflowJobStatus(String workflowId); /** diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java index 31db2284..d539c21f 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/ClientDaoImpl.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Service; import java.util.Optional; +import java.util.UUID; @Service public class ClientDaoImpl implements ClientDao { @@ -18,10 +19,21 @@ public ClientDaoImpl(ClientAppRepository repository) { @Override public Optional findByClientId(String clientId) { - return repository.findByClientId(clientId) + if (clientId == null || clientId.isBlank()) { + return Optional.empty(); + } + + final UUID clientUuid; + try { + clientUuid = UUID.fromString(clientId); + } catch (IllegalArgumentException ex) { + return Optional.empty(); + } + + return repository.findByClientId(clientUuid) .map(entity -> new ClientInfo( entity.getId(), - entity.getClientId(), + entity.getClientId().toString(), entity.getClientName(), entity.isEnabled() )); diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java index 74f0875b..98a14214 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/entity/ClientAppEntity.java @@ -1,11 +1,16 @@ package org.opendevstack.apiservice.persistence.entity; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; + +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,8 +36,8 @@ public class ClientAppEntity extends AuditableEntity { private UUID id; /** Azure AD application/client identifier. */ - @Column(name = "client_id", nullable = false, unique = true, length = 36) - private String clientId; + @Column(name = "client_id", nullable = false, unique = true) + private UUID clientId; /** Optional display name for the client application. */ @Column(name = "client_name", length = 255) @@ -43,4 +48,9 @@ public class ClientAppEntity extends AuditableEntity { @Builder.Default private boolean enabled = true; + /** Project flavor configurations associated with this client. */ + @OneToMany(mappedBy = "clientApp", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private List projectFlavors = new ArrayList<>(); + } \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java index 1af9a8de..b088a853 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepository.java @@ -19,7 +19,7 @@ public interface ClientAppRepository extends JpaRepository findByClientId(String clientId); + Optional findByClientId(UUID clientId); /** * Returns all enabled client applications. @@ -32,7 +32,7 @@ public interface ClientAppRepository extends JpaRepository findDetailedByClientId(String clientId); + Optional findDetailedByClientId(UUID clientId); } \ No newline at end of file diff --git a/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql new file mode 100644 index 00000000..852026fb --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/004_alter_client_apps_client_id_to_uuid.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +--changeset ods:004-alter-client-apps-client-id-to-uuid +-- Description: Converts client_apps.client_id from VARCHAR(36) to UUID to align DB and Java types. +ALTER TABLE client_apps + ALTER COLUMN client_id TYPE UUID + USING client_id::uuid; + +--rollback ALTER TABLE client_apzzps +--rollback ALTER COLUMN client_id TYPE VARCHAR(36) +--rollback USING client_id::text; + diff --git a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java index bf313460..4d433e77 100644 --- a/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java +++ b/persistence/src/test/java/org/opendevstack/apiservice/persistence/repository/ClientAppRepositoryTest.java @@ -1,10 +1,5 @@ package org.opendevstack.apiservice.persistence.repository; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -14,13 +9,18 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + /** * Integration tests for {@link ClientAppRepository}. */ @@ -55,11 +55,11 @@ static void postgresProperties(DynamicPropertyRegistry registry) { void setUp() { repository.deleteAll(); - enabledClientApp = createClientApp("11111111-1111-1111-1111-111111111111", true); - createClientApp("22222222-2222-2222-2222-222222222222", false); + enabledClientApp = createClientApp(UUID.fromString("11111111-1111-1111-1111-111111111111"), true); + createClientApp(UUID.fromString("22222222-2222-2222-2222-222222222222"), false); } - private ClientAppEntity createClientApp(String clientId, boolean enabled) { + private ClientAppEntity createClientApp(UUID clientId, boolean enabled) { ClientAppEntity clientApp = ClientAppEntity.builder() .clientId(clientId) .clientName(enabled ? "Enabled app" : "Disabled app") @@ -76,7 +76,7 @@ class FindByClientId { @Test @DisplayName("returns the matching client app") void returnsMatchingClientApp() { - Optional found = repository.findByClientId("11111111-1111-1111-1111-111111111111"); + Optional found = repository.findByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111")); assertThat(found).isPresent(); assertThat(found.get().getClientName()).isEqualTo("Enabled app"); @@ -86,7 +86,7 @@ void returnsMatchingClientApp() { @Test @DisplayName("returns empty for unknown client id") void returnsEmptyForUnknownClientId() { - assertThat(repository.findByClientId("99999999-9999-9999-9999-999999999999")).isEmpty(); + assertThat(repository.findByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isEmpty(); } } @@ -101,7 +101,7 @@ void returnsOnlyEnabledClientApps() { List enabledApps = repository.findByEnabledTrue(); assertThat(enabledApps).hasSize(1); - assertThat(enabledApps.get(0).getClientId()).isEqualTo("11111111-1111-1111-1111-111111111111"); + assertThat(enabledApps.get(0).getClientId().toString()).isEqualTo("11111111-1111-1111-1111-111111111111"); } } @@ -112,13 +112,13 @@ class ExistsByClientId { @Test @DisplayName("returns true for existing client id") void returnsTrueForExistingClientId() { - assertThat(repository.existsByClientId("11111111-1111-1111-1111-111111111111")).isTrue(); + assertThat(repository.existsByClientId(UUID.fromString("11111111-1111-1111-1111-111111111111"))).isTrue(); } @Test @DisplayName("returns false for unknown client id") void returnsFalseForUnknownClientId() { - assertThat(repository.existsByClientId("99999999-9999-9999-9999-999999999999")).isFalse(); + assertThat(repository.existsByClientId(UUID.fromString("99999999-9999-9999-9999-999999999999"))).isFalse(); } } diff --git a/service-projects/pom.xml b/service-projects/pom.xml index 2a82727a..f391ffee 100644 --- a/service-projects/pom.xml +++ b/service-projects/pom.xml @@ -69,7 +69,13 @@ org.opendevstack.apiservice external-service-ocp ${project.version} - + + + + org.opendevstack.apiservice + external-service-aap + ${project.version} + org.projectlombok diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java new file mode 100644 index 00000000..fa3a5d02 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/exception/ProjectExistenceServiceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.serviceproject.exception; + +public class ProjectExistenceServiceException extends Exception { + + public ProjectExistenceServiceException(String message) { + super(message); + } + + public ProjectExistenceServiceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java index 18a64916..72060ad6 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -12,6 +12,7 @@ public interface ProjectResponseMapper { @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") + @Mapping(source = "id", target = "projectId") ProjectResponse toCreateProjectResponse(ProjectEntity entity); @Named("mapStatus") diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java index 186ffaae..9bc0f91b 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectRequest.java @@ -4,16 +4,32 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @Data @NoArgsConstructor @AllArgsConstructor public class ProjectRequest { + private UUID projectId; + private String projectKey; - - private String projectKeyPattern; - + private String projectName; - + private String projectDescription; + + private String projectFlavor; + + private String configurationItem; + + private String location; + + private String x2OdsAccount; + + private String owner; + + private UUID clientId; + + private Status status; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java index 40ee62ac..6db50630 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model/ProjectResponse.java @@ -5,15 +5,25 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @Data @NoArgsConstructor @AllArgsConstructor @Builder public class ProjectResponse { + private UUID projectId; + private String projectKey; private Status status; private String projectFlavor; + + private String message; + + private String error; + + private String errorKey; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java index 289b9776..b00d7bf0 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/GenerateProjectKeyService.java @@ -1,11 +1,12 @@ package org.opendevstack.apiservice.serviceproject.service; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; public interface GenerateProjectKeyService { String DEFAULT_PROJECT_KEY_PATTERN = "SS%06d"; - String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException; + String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java new file mode 100644 index 00000000..ed13614d --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.serviceproject.service; + +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; + +public interface ProjectExistenceService { + + boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException; +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java index c2abcc6d..f2ba3aad 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -5,7 +5,7 @@ public interface ProjectService { - ProjectResponse createProject(ProjectRequest request); + ProjectResponse saveProject(ProjectRequest request); ProjectResponse getProject(String projectKey); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java index 2e53781e..7b80fd30 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImpl.java @@ -1,21 +1,15 @@ package org.opendevstack.apiservice.serviceproject.service.impl; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.security.SecureRandom; -import java.util.Comparator; import java.util.Random; -import java.util.Set; @Service @Slf4j @@ -23,37 +17,29 @@ public class GenerateProjectKeyServiceImpl implements GenerateProjectKeyService private static final int MAX_RETRIES = 10; - private final OpenshiftService openshiftService; - - private final BitbucketService bitbucketService; - - private final JiraService jiraService; + private final ProjectExistenceService projectExistenceService; private final Random random; @Autowired - public GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, - OpenshiftService openshiftService) { - this(bitbucketService, jiraService, openshiftService, new SecureRandom()); + public GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService) { + this(projectExistenceService, new SecureRandom()); } - GenerateProjectKeyServiceImpl(BitbucketService bitbucketService, JiraService jiraService, - OpenshiftService openshiftService, Random random) { - this.bitbucketService = bitbucketService; - this.jiraService = jiraService; - this.openshiftService = openshiftService; + GenerateProjectKeyServiceImpl(ProjectExistenceService projectExistenceService, Random random) { + this.projectExistenceService = projectExistenceService; this.random = random; } @Override - public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException { + public String generateProjectKey(String projectKeyPattern) throws ProjectKeyGenerationException, ProjectExistenceServiceException { String pattern = resolveProjectKeyPattern(projectKeyPattern); for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { int randomNumber = random.nextInt(1_000_000); String projectKey = String.format(pattern, randomNumber); - if (!isProjectFound(projectKey)) { + if (!projectExistenceService.isProjectFound(projectKey)) { log.debug("Generated unique project key '{}' on attempt {}", projectKey, attempt); return projectKey; } @@ -71,71 +57,4 @@ private String resolveProjectKeyPattern(String projectKeyPattern) { } return projectKeyPattern; } - - private boolean isProjectFound(String projectKey) throws ProjectKeyGenerationException { - try { - if (existsInAnyBitbucketInstance(projectKey)) { - return true; - } - - if (existsInAnyJiraInstance(projectKey)) { - return true; - } - - return existsInAnyOpenshift(projectKey); - } catch (BitbucketException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Bitbucket", projectKey), e); - } catch (JiraException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Jira", projectKey), e); - } catch (OpenshiftException e) { - throw new ProjectKeyGenerationException( - String.format("Failed to check project '%s' in Openshift", projectKey), e); - } - } - - private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { - Set instances = bitbucketService.getAvailableInstances(); - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (bitbucketService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } - - private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { - Set instances = jiraService.getAvailableInstances(); - - if (instances == null || instances.isEmpty()) { - return jiraService.projectExists(projectKey); - } - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (jiraService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } - - private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { - Set instances = openshiftService.getAvailableInstances(); - - if (instances == null || instances.isEmpty()) { - return false; - } - - for (String instanceName : instances.stream().sorted(Comparator.naturalOrder()).toList()) { - if (openshiftService.projectExists(instanceName, projectKey)) { - return true; - } - } - - return false; - } } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java new file mode 100644 index 00000000..a4483767 --- /dev/null +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -0,0 +1,84 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.opendevstack.apiservice.externalservice.bitbucket.exception.BitbucketException; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.exception.JiraException; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.exception.OpenshiftException; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.util.Comparator; +import java.util.Set; + +@Service +public class ProjectExistenceServiceImpl implements ProjectExistenceService { + + private final BitbucketService bitbucketService; + private final JiraService jiraService; + private final OpenshiftService openshiftService; + private final ProjectService projectService; + + @Autowired + public ProjectExistenceServiceImpl(BitbucketService bitbucketService, JiraService jiraService, + OpenshiftService openshiftService, ProjectService projectService) { + this.bitbucketService = bitbucketService; + this.jiraService = jiraService; + this.openshiftService = openshiftService; + this.projectService = projectService; + } + + @Override + public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException { + try { + if (existsInCollection(projectKey)) return true; + if (existsInAnyBitbucketInstance(projectKey)) return true; + if (existsInAnyJiraInstance(projectKey)) return true; + return existsInAnyOpenshift(projectKey); + } catch (BitbucketException e) { + throw new ProjectExistenceServiceException("Failed to check project in Bitbucket", e); + } catch (JiraException e) { + throw new ProjectExistenceServiceException("Failed to check project in Jira", e); + } catch (OpenshiftException e) { + throw new ProjectExistenceServiceException("Failed to check project in Openshift", e); + } catch (RuntimeException e) { + throw new ProjectExistenceServiceException("Unexpected error while checking project existence", e); + } + } + + private boolean existsInCollection(String projectKey) { + return projectService.getProject(projectKey) != null; + } + + private boolean existsInAnyBitbucketInstance(String projectKey) throws BitbucketException { + Set instances = bitbucketService.getAvailableInstances(); + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (bitbucketService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyJiraInstance(String projectKey) throws JiraException { + Set instances = jiraService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) { + return jiraService.projectExists(projectKey); + } + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (jiraService.projectExists(instance, projectKey)) return true; + } + return false; + } + + private boolean existsInAnyOpenshift(String projectKey) throws OpenshiftException { + Set instances = openshiftService.getAvailableInstances(); + if (instances == null || instances.isEmpty()) return false; + for (String instance : instances.stream().sorted(Comparator.naturalOrder()).toList()) { + if (openshiftService.projectExists(instance, projectKey)) return true; + } + return false; + } +} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 1d8d7b62..ccd3be4c 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -1,41 +1,53 @@ package org.opendevstack.apiservice.serviceproject.service.impl; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.Optional; @Service @Slf4j -@AllArgsConstructor public class ProjectServiceImpl implements ProjectService { - private final OpenshiftService openshiftService; - - private final BitbucketService bitbucketService; - - private final JiraService jiraService; - - private final GenerateProjectKeyService generateProjectKeyService; + private static final String MANAGER_ROLE = "MANAGER"; + private static final String TEAM_ROLE = "TEAM"; + private static final String STAKEHOLDER_ROLE = "STAKEHOLDER"; + @Value("${ldap.group.pattern}") + private String ldapGroupPattern; + private final ProjectRepository projectRepository; private final ProjectResponseMapper projectResponseMapper; + + public ProjectServiceImpl(ProjectRepository projectRepository, + ProjectResponseMapper projectResponseMapper) { + this.projectRepository = projectRepository; + this.projectResponseMapper = projectResponseMapper; + } @Override - public ProjectResponse createProject(ProjectRequest request) { - return ProjectResponse.builder().build(); + public ProjectResponse saveProject(ProjectRequest request) { + ProjectEntity entity = new ProjectEntity(); + entity.setProjectKey(request.getProjectKey()); + entity.setProjectName(request.getProjectName()); + entity.setProjectFlavor(request.getProjectFlavor()); + entity.setConfigurationItem(request.getConfigurationItem()); + entity.setDescription(request.getProjectFlavor() + " project"); + entity.setLdapGroupManager(getLdapGroup(MANAGER_ROLE, request.getProjectKey())); + entity.setLdapGroupTeam(getLdapGroup(TEAM_ROLE, request.getProjectKey())); + entity.setLdapGroupStakeholder(getLdapGroup(STAKEHOLDER_ROLE, request.getProjectKey())); + entity.setStatus(request.getStatus().getDbValue()); + entity.setLocation(request.getLocation()); + ProjectEntity save = projectRepository.save(entity); + return projectResponseMapper.toCreateProjectResponse(save); } @Override @@ -48,5 +60,11 @@ public ProjectResponse getProject(String projectKey) { return null; } + + private String getLdapGroup(String role, String projectKey) { + return ldapGroupPattern + .replace("{{projectKey}}", projectKey) + .replace("{{role}}", role); + } } diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java index ebba5236..6e5b02fd 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/GenerateProjectKeyServiceImplTest.java @@ -7,13 +7,10 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException; +import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import java.util.Random; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -25,13 +22,7 @@ class GenerateProjectKeyServiceImplTest { @Mock - private BitbucketService bitbucketService; - - @Mock - private JiraService jiraService; - - @Mock - private OpenshiftService openshiftService; + private ProjectExistenceService projectExistenceService; @Mock private Random random; @@ -40,17 +31,13 @@ class GenerateProjectKeyServiceImplTest { @BeforeEach void setup() { - tested = new GenerateProjectKeyServiceImpl(bitbucketService, jiraService, openshiftService, random); - when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("dev")); - when(jiraService.getAvailableInstances()).thenReturn(Set.of("default")); - when(openshiftService.getAvailableInstances()).thenReturn(Set.of()); + tested = new GenerateProjectKeyServiceImpl(projectExistenceService, random); } @Test - void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exception { + void generate_project_key_returns_key_when_first_candidate_is_free() throws Exception { when(random.nextInt(1_000_000)).thenReturn(7); - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + when(projectExistenceService.isProjectFound("SS000007")).thenReturn(false); String result = tested.generateProjectKey(null); @@ -58,14 +45,10 @@ void generateProjectKey_whenFirstCandidateIsFree_thenReturnKey() throws Exceptio } @Test - void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws Exception { + void generate_project_key_retries_until_unique_key_found() throws Exception { when(random.nextInt(1_000_000)).thenReturn(1, 2); - - when(bitbucketService.projectExists("dev", "SS000001")).thenReturn(true); - when(jiraService.projectExists("default", "SS000001")).thenReturn(false); - - when(bitbucketService.projectExists("dev", "SS000002")).thenReturn(false); - when(jiraService.projectExists("default", "SS000002")).thenReturn(false); + when(projectExistenceService.isProjectFound("SS000001")).thenReturn(true); + when(projectExistenceService.isProjectFound("SS000002")).thenReturn(false); String result = tested.generateProjectKey("SS%06d"); @@ -73,12 +56,9 @@ void generateProjectKey_whenFirstCandidateExists_thenRetryUntilUnique() throws E } @Test - void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() throws Exception { + void generate_project_key_throws_exception_when_no_unique_key_after_max_retries() throws Exception { when(random.nextInt(1_000_000)).thenReturn(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11); - - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - - when(jiraService.projectExists(anyString(), anyString())).thenReturn(true); + when(projectExistenceService.isProjectFound(anyString())).thenReturn(true); assertThatThrownBy(() -> tested.generateProjectKey("SS%06d")) .isInstanceOf(ProjectKeyGenerationException.class) @@ -86,10 +66,9 @@ void generateProjectKey_whenNoUniqueKeyAfterMaxRetries_thenThrowException() thro } @Test - void generateProjectKey_whenCustomPatternProvided_thenUseIt() throws Exception { + void generate_project_key_uses_custom_pattern_when_provided() throws Exception { when(random.nextInt(1_000_000)).thenReturn(42); - when(bitbucketService.projectExists(anyString(), anyString())).thenReturn(false); - when(jiraService.projectExists(anyString(), anyString())).thenReturn(false); + when(projectExistenceService.isProjectFound("AB0042")).thenReturn(false); String result = tested.generateProjectKey("AB%04d"); diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java new file mode 100644 index 00000000..aaabce3d --- /dev/null +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java @@ -0,0 +1,137 @@ +package org.opendevstack.apiservice.serviceproject.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; +import org.opendevstack.apiservice.externalservice.jira.service.JiraService; +import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; +import org.opendevstack.apiservice.serviceproject.exception.ProjectExistenceServiceException; +import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import org.opendevstack.apiservice.serviceproject.service.ProjectService; + +import java.util.Collections; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ProjectExistenceServiceImplTest { + + @Mock + private BitbucketService bitbucketService; + @Mock + private JiraService jiraService; + @Mock + private OpenshiftService openshiftService; + @Mock + private ProjectService projectService; + + private ProjectExistenceServiceImpl sut; + + @BeforeEach + void setUp() throws Exception { + MockitoAnnotations.openMocks(this).close(); + sut = new ProjectExistenceServiceImpl(bitbucketService, jiraService, openshiftService, projectService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_db() throws Exception { + when(projectService.getProject("KEY1")).thenReturn(new ProjectResponse()); + + boolean result = sut.isProjectFound("KEY1"); + assertTrue(result); + verify(projectService).getProject("KEY1"); + verifyNoInteractions(bitbucketService, jiraService, openshiftService); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_bitbucket() throws Exception { + when(projectService.getProject("KEY2")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY2")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY2"); + assertTrue(result); + verify(bitbucketService).projectExists("inst1", "KEY2"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_jira() throws Exception { + when(projectService.getProject("KEY3")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY3")).thenReturn(true); + boolean result = sut.isProjectFound("KEY3"); + assertTrue(result); + verify(jiraService).projectExists("jira1", "KEY3"); + } + + @Test + void is_project_found_returns_true_when_project_exists_in_openshift() throws Exception { + when(projectService.getProject("KEY4")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + when(openshiftService.projectExists("ocp1", "KEY4")).thenReturn(true); + + boolean result = sut.isProjectFound("KEY4"); + assertTrue(result); + verify(openshiftService).projectExists("ocp1", "KEY4"); + } + + @Test + void is_project_found_returns_false_when_project_not_found_anywhere() throws Exception { + when(projectService.getProject("KEY5")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Set.of("inst1")); + when(bitbucketService.projectExists("inst1", "KEY5")).thenReturn(false); + when(jiraService.getAvailableInstances()).thenReturn(Set.of("jira1")); + when(jiraService.projectExists("jira1", "KEY5")).thenReturn(false); + when(openshiftService.getAvailableInstances()).thenReturn(Set.of("ocp1")); + + when(openshiftService.projectExists("ocp1", "KEY5")).thenReturn(false); + boolean result = sut.isProjectFound("KEY5"); + assertFalse(result); + } + + @Test + void is_project_found_throws_exception_when_bitbucket_fails() { + when(projectService.getProject("KEY6")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY6")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY6"); + }); + } + + @Test + void is_project_found_throws_exception_when_jira_fails() { + when(projectService.getProject("KEY7")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> sut.isProjectFound("KEY7")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY7"); + }); + } + + @Test + void is_project_found_throws_exception_when_openshift_fails() { + when(projectService.getProject("KEY8")).thenReturn(null); + when(bitbucketService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(jiraService.getAvailableInstances()).thenReturn(Collections.emptySet()); + when(openshiftService.getAvailableInstances()).thenThrow(new RuntimeException("fail")); + + assertThrows(ProjectExistenceServiceException.class, () -> { + sut.isProjectFound("KEY8"); + }); + } +} \ No newline at end of file diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java index 827ba411..51adb000 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -4,16 +4,11 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.externalservice.bitbucket.service.BitbucketService; -import org.opendevstack.apiservice.externalservice.jira.service.JiraService; -import org.opendevstack.apiservice.externalservice.ocp.service.OpenshiftService; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; import org.opendevstack.apiservice.serviceproject.mapper.ProjectResponseMapper; -import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; -import org.opendevstack.apiservice.serviceproject.service.GenerateProjectKeyService; import java.util.Optional; import java.util.UUID; @@ -29,18 +24,6 @@ class ProjectServiceImplTest { - @Mock - private OpenshiftService openshiftService; - - @Mock - private BitbucketService bitbucketService; - - @Mock - private JiraService jiraService; - - @Mock - private GenerateProjectKeyService generateProjectKeyService; - @Mock private ProjectRepository projectRepository; @@ -53,10 +36,6 @@ class ProjectServiceImplTest { void setUp() { MockitoAnnotations.openMocks(this); projectService = new ProjectServiceImpl( - openshiftService, - bitbucketService, - jiraService, - generateProjectKeyService, projectRepository, projectResponseMapper ); @@ -89,10 +68,8 @@ void get_project_returns_response_when_project_exists() { when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(projectEntity)); when(projectResponseMapper.toCreateProjectResponse(projectEntity)).thenReturn(expectedResponse); - ProjectResponse result = projectService.getProject(projectKey); - assertNotNull(result); assertEquals(projectKey, result.getProjectKey()); @@ -107,33 +84,14 @@ void get_project_returns_null_when_project_does_not_exist() { String projectKey = "NON-EXISTING"; when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); - ProjectResponse result = projectService.getProject(projectKey); - assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); verify(projectResponseMapper, never()).toCreateProjectResponse(any()); } - @Test - void create_project_returns_empty_response() { - - ProjectRequest request = new ProjectRequest(); - request.setProjectKey("NEW-PROJECT"); - request.setProjectKeyPattern("NEW%06d"); - request.setProjectName("New Project"); - request.setProjectDescription("New test project"); - - - ProjectResponse result = projectService.createProject(request); - - - assertNotNull(result); - assertNull(result.getProjectKey()); - } - @Test void get_project_propagates_repository_exception() { @@ -150,10 +108,8 @@ void get_project_propagates_repository_exception() { void get_project_returns_null_when_project_key_is_null() { when(projectRepository.findByProjectKeyIgnoreCase(null)).thenReturn(Optional.empty()); - ProjectResponse result = projectService.getProject(null); - assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(null); From a0a4d2f8cd8112df3dc18dd6278d3d5f5faddb87 Mon Sep 17 00:00:00 2001 From: Vali Tuguran Date: Tue, 31 Mar 2026 17:16:21 +0200 Subject: [PATCH 08/27] Add mocks for create/get component (#13) Add marketplace 2.0 external service, mocks for create/get component and fix REST paths. --------- Co-authored-by: zxBCN Valeriu_Tuguran,Constantin (IT EDP) EXTERNAL --- .../openapi/api-project-component-v0.yaml | 6 +- api-project-component-v0/pom.xml | 7 + .../controller/ComponentsResponseFactory.java | 4 +- .../ProjectComponentsController.java | 8 +- .../project/facade/ComponentsFacade.java | 41 +++--- .../project/mapper/MarketplaceMapper.java | 30 ++++ .../ProjectComponentsControllerTest.java | 2 +- ...iceTest.java => ComponentsFacadeTest.java} | 35 +++-- .../project/util/TestObjectsBuilder.java | 24 +++- core/pom.xml | 12 ++ external-service-marketplace/pom.xml | 129 ++++++++++++++++++ .../model/CreateComponentParameter.java | 17 +++ .../marketplace/model/ProjectComponent.java | 18 +++ .../service/MarketplaceService.java | 15 ++ .../impl/MarketplaceServiceMockImpl.java | 74 ++++++++++ pom.xml | 1 + 16 files changed, 380 insertions(+), 43 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java rename api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/{ComponentsServiceTest.java => ComponentsFacadeTest.java} (58%) create mode 100644 external-service-marketplace/pom.xml create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java diff --git a/api-project-component-v0/openapi/api-project-component-v0.yaml b/api-project-component-v0/openapi/api-project-component-v0.yaml index dcd3912a..868e72e4 100644 --- a/api-project-component-v0/openapi/api-project-component-v0.yaml +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -12,14 +12,14 @@ servers: default: localhost:8080 description: Development environment tags: - - name: ProjectComponents + - name: Project Components description: API for managing project components paths: /projects/{projectId}/components/: post: tags: - - projectComponents + - Project Components summary: Create a component in a project operationId: createProjectComponent description: Retrieves information about a specific component @@ -61,7 +61,7 @@ paths: /projects/{projectId}/components/{componentId}: get: tags: - - projectComponents + - Project Components summary: Get component information operationId: getProjectComponent description: Retrieves information about a specific component diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml index a3f50df0..845c6d57 100644 --- a/api-project-component-v0/pom.xml +++ b/api-project-component-v0/pom.xml @@ -44,6 +44,12 @@ ${project.version} + + org.opendevstack.apiservice + external-service-marketplace + ${project.version} + + io.jsonwebtoken jjwt-api @@ -89,6 +95,7 @@ spring-boot-starter-test test + org.springframework.security spring-security-core diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java index 7eb10ce0..c3f876a8 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java @@ -15,10 +15,10 @@ public static CreateComponentResponse error(String projectId) { return response; } - public static CreateComponentResponse entityCreated(String projectId, String componentName) { + public static CreateComponentResponse entityCreated(String projectId, String componentId) { CreateComponentResponse response = new CreateComponentResponse(); response.setErrorCode(HttpStatus.CREATED.value()); - response.setMessage(componentName + " component created successfully in project " + projectId); + response.setMessage(componentId + " component created successfully in project " + projectId); return response; } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java index 9f50deef..f8b0ae88 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -3,18 +3,20 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectComponentsApi; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.opendevstack.apiservice.project.model.CreateComponentResponse; -import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @AllArgsConstructor @Slf4j +@RequestMapping("/api/pub/v0") public class ProjectComponentsController implements ProjectComponentsApi { private final ComponentsFacade componentsFacade; @@ -25,11 +27,12 @@ public class ProjectComponentsController implements ProjectComponentsApi { public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { try { Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); + log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); if (component == null) { log.error("Failed to create component for project '{}'", projectId); return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); } - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.entityCreated(projectId, component.getName())); + return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.entityCreated(projectId, component.getId())); } catch (Exception e) { log.error("Error while trying to create component for project '" + projectId + "': " + e.getMessage(), e); return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); @@ -40,6 +43,7 @@ public ResponseEntity createProjectComponent(String pro public ResponseEntity getProjectComponent(String projectId, String componentId) { try { Component component = componentsFacade.getProjectComponent(projectId, componentId); + log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); if (component == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 012f21da..f36476f8 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -2,42 +2,41 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; +import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.springframework.stereotype.Service; +import java.util.List; + @Service @AllArgsConstructor @Slf4j public class ComponentsFacade { - private final MarketplaceExternalServicePlaceholder marketplaceExternalService; - - public Component getProjectComponent(String projectId, String componentId) { - return marketplaceExternalService.getProjectComponent(projectId, componentId); - } - - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - return marketplaceExternalService.createProjectComponent(projectId, createComponentRequest); - } - - @Service - class MarketplaceExternalServicePlaceholder implements ExternalService { + private final MarketplaceService marketplaceExternalService; - @Override - public boolean isHealthy() { - return false; - } + private final MarketplaceMapper marketplaceMapper; - public Component getProjectComponent(String projectId, String componentId) { - log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); + public Component getProjectComponent(String projectId, String componentId) { + ProjectComponent marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); + if (marketplaceComponent == null) { + log.info("Marketplace component with id {} not found", componentId); return null; } + return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); + } - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); + public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + List createComponentParameterList = marketplaceMapper.mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest); + ProjectComponent marketplaceComponent = marketplaceExternalService.createProjectComponent(projectId, createComponentParameterList); + if (marketplaceComponent == null) { + log.error("Failed to create component in marketplace for project with id {}", projectId); return null; } + return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java new file mode 100644 index 00000000..f1420643 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -0,0 +1,30 @@ +package org.opendevstack.apiservice.project.mapper; + + +import org.mapstruct.Mapper; +import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; +import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface MarketplaceMapper { + + default Component mapMarketplaceComponentToV0Component(ProjectComponent source) { + if (source == null) { + return null; + } + Component target = new Component(); + target.setId(source.getComponentId()); + target.setStatus(source.getStatus()); + return target; + } + + default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { + return createComponentRequest.getParams().entrySet().stream() + .map(entry -> new CreateComponentParameter(entry.getKey(), "string", entry.getValue())) + .toList(); + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java index 23aec13b..a98c778a 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -40,7 +40,7 @@ void testCreateProjectComponent_whenSuccess_thenReturnOk() throws Exception { Component testComponent = buildTestComponent(); String testProjectId = "testProjectId"; CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); - CreateComponentResponse testServiceResponseSuccess = buildTestCreateComponentResponseSuccess(testComponent.getName(), + CreateComponentResponse testServiceResponseSuccess = buildTestCreateComponentResponseSuccess(testComponent.getId(), testProjectId); when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java similarity index 58% rename from api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java rename to api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 31d2bcb2..3935839c 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsServiceTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -3,45 +3,55 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestMarketplaceComponent; @ExtendWith(MockitoExtension.class) -class ComponentsServiceTest { +class ComponentsFacadeTest { + + private final MarketplaceMapper marketplaceMapper = Mappers.getMapper(MarketplaceMapper.class); @Mock - private ComponentsFacade.MarketplaceExternalServicePlaceholder marketPlaceExternalServicePlaceholder; + private MarketplaceService marketplaceExternalService; private ComponentsFacade componentsFacade; @BeforeEach void setup() { - componentsFacade = new ComponentsFacade(marketPlaceExternalServicePlaceholder); + componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); } @Test void testGetProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { - Component testComponent = buildTestComponent(); + ProjectComponent testComponent = buildTestMarketplaceComponent(); - when(marketPlaceExternalServicePlaceholder.getProjectComponent(anyString(), eq("testId"))) + when(marketplaceExternalService.getProjectComponent(anyString(), eq("testId"))) .thenReturn(testComponent); Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); - assertThat(retrievedComponent).isEqualTo(testComponent); + assertThat(retrievedComponent.getId()).isEqualTo(testComponent.getComponentId()); + assertThat(retrievedComponent.getStatus()).isEqualTo(testComponent.getStatus()); } @Test void testGetProjectComponent_whenNoComponentFound_thenReturnNull() throws Exception { - when(marketPlaceExternalServicePlaceholder.getProjectComponent(anyString(), eq("testId"))) + when(marketplaceExternalService.getProjectComponent(anyString(), eq("testId"))) .thenReturn(null); Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); @@ -50,14 +60,15 @@ void testGetProjectComponent_whenNoComponentFound_thenReturnNull() throws Except @Test void testCreateProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { - Component testComponent = buildTestComponent(); + ProjectComponent testComponent = buildTestMarketplaceComponent(); CreateComponentRequest testRequest = buildTestCreateComponentRequest(); - when(marketPlaceExternalServicePlaceholder.createProjectComponent(anyString(), eq(testRequest))) + when(marketplaceExternalService.createProjectComponent(anyString(), any(List.class))) .thenReturn(testComponent); Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); - assertThat(retrievedComponent).isEqualTo(testComponent); + assertThat(retrievedComponent.getId()).isEqualTo(testComponent.getComponentId()); + assertThat(retrievedComponent.getStatus()).isEqualTo(testComponent.getStatus()); } @@ -65,7 +76,7 @@ void testCreateProjectComponent_whenSuccess_thenReturnCorrectComponent() throws void testCreateProjectComponent_whenFailure_thenReturnNull() throws Exception { CreateComponentRequest testRequest = buildTestCreateComponentRequest(); - when(marketPlaceExternalServicePlaceholder.createProjectComponent(anyString(), eq(testRequest))) + when(marketplaceExternalService.createProjectComponent(anyString(), any(List.class))) .thenReturn(null); Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java index 60ca92b0..14cc84f7 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -1,10 +1,15 @@ package org.opendevstack.apiservice.project.util; +import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; +import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; +import java.util.ArrayList; +import java.util.List; + public class TestObjectsBuilder { private TestObjectsBuilder() { @@ -19,6 +24,14 @@ public static Component buildTestComponent() { return component; } + public static ProjectComponent buildTestMarketplaceComponent() { + ProjectComponent component = new ProjectComponent(); + component.setComponentId("testComponentId"); + component.setCanBeDeleted(false); + component.setComponentUrl("http://test.component.url"); + return component; + } + public static CreateComponentRequest buildTestCreateComponentRequest() { CreateComponentRequest request = new CreateComponentRequest(); request.setName("testComponentName"); @@ -26,10 +39,17 @@ public static CreateComponentRequest buildTestCreateComponentRequest() { return request; } - public static CreateComponentResponse buildTestCreateComponentResponseSuccess(String componentName, String projectId) { + public static List buildTestMarketplaceCreateComponentParameters() { + List parameters = new ArrayList<>(); + parameters.add(new CreateComponentParameter("name", "string", "testComponentName")); + parameters.add(new CreateComponentParameter("productId", "string", "testProductId")); + return parameters; + } + + public static CreateComponentResponse buildTestCreateComponentResponseSuccess(String componentId, String projectId) { CreateComponentResponse response = new CreateComponentResponse(); response.setErrorCode(HttpStatus.CREATED.value()); - response.setMessage(componentName + " component created successfully in project " + projectId); + response.setMessage(componentId + " component created successfully in project " + projectId); return response; } diff --git a/core/pom.xml b/core/pom.xml index c3aa14b9..252da506 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -109,6 +109,12 @@ ${project.version} + + org.opendevstack.apiservice + external-service-marketplace + ${project.version} + + org.opendevstack.apiservice api-project-users @@ -121,6 +127,12 @@ ${project.version} + + org.opendevstack.apiservice + api-project-component-v0 + ${project.version} + + org.opendevstack.apiservice api-project-platform diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml new file mode 100644 index 00000000..63585515 --- /dev/null +++ b/external-service-marketplace/pom.xml @@ -0,0 +1,129 @@ + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + external-service-marketplace + External Service Marketplace 2.0 + Service module for integrating with Marketplace 2.0 + + + + + org.opendevstack.apiservice + external-service-api + ${project.version} + + + + + org.springframework.boot + spring-boot-starter + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.projectlombok + lombok + provided + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.junit.jupiter + junit-jupiter + test + + + + + org.wiremock.integrations + wiremock-spring-boot + 3.10.0 + test + + + + + org.openapitools + jackson-databind-nullable + ${jackson-databind-nullable.version} + + + + + jakarta.annotation + jakarta.annotation-api + + + + + org.springframework.boot + spring-boot-starter-cache + + + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source + generate-sources + + add-source + + + + ${project.basedir}/target/generated-sources/openapi/src/main/java + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + true + + + + + diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java new file mode 100644 index 00000000..a4409c39 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.externalservice.marketplace.model; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class CreateComponentParameter { + + private String name; + private String type; + private String value; + +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java new file mode 100644 index 00000000..89b640b9 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java @@ -0,0 +1,18 @@ +package org.opendevstack.apiservice.externalservice.marketplace.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Data +public class ProjectComponent { + + private String componentId; + private String status; + private boolean canBeDeleted; + private String logoUrl; + private String componentUrl; + +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java new file mode 100644 index 00000000..8873f42e --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; +import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; + +import java.util.List; + +public interface MarketplaceService extends ExternalService { + + + ProjectComponent getProjectComponent(String projectId, String componentId); + + ProjectComponent createProjectComponent(String projectId, List params); +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java new file mode 100644 index 00000000..2301a76a --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java @@ -0,0 +1,74 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; +import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class MarketplaceServiceMockImpl implements MarketplaceService { + + @Override + public boolean isHealthy() { + return true; + } + + private Map mockComponentsCache = new HashMap<>(); + + public ProjectComponent getProjectComponent(String projectId, String componentId) { + log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); + ComposedId composedId = new ComposedId(projectId, componentId); + return mockComponentsCache.get(composedId); + } + + public ProjectComponent createProjectComponent(String projectId, List createComponentParams) { + log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentParams); + ProjectComponent mockComponent = new ProjectComponent(); + mockComponent.setComponentId(generateNextId()); + mockComponent.setCanBeDeleted(true); + mockComponent.setStatus("CREATING"); + ComposedId composedId = new ComposedId(projectId, mockComponent.getComponentId()); + mockComponentsCache.put(composedId, mockComponent); + + return mockComponent; + } + + private String generateNextId() { + return "mock-component-id-" + (mockComponentsCache.size() + 1); + } + + class ComposedId { + private String projectId; + private String componentId; + + public ComposedId(String projectId, String componentId) { + this.projectId = projectId; + this.componentId = componentId; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + ComposedId that = (ComposedId) o; + + if (!projectId.equals(that.projectId)) return false; + return componentId.equals(that.componentId); + } + + @Override + public int hashCode() { + int result = projectId.hashCode(); + result = 31 * result + componentId.hashCode(); + return result; + } + } +} diff --git a/pom.xml b/pom.xml index 4e30a284..b2c55126 100644 --- a/pom.xml +++ b/pom.xml @@ -54,6 +54,7 @@ external-service-ocp external-service-bitbucket external-service-jira + external-service-marketplace external-service-webhookproxy service-projects api-project-users From 28b7333092553e17517e0922cf5d06b35d354051 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Wed, 1 Apr 2026 17:15:41 +0200 Subject: [PATCH 09/27] Feature/security (#15) This pull request introduces a new modular security layer for the project by adding a `core-security` module. The changes implement a flexible policy-based authorization system integrated with Spring Security and Azure AD JWT authentication. The security configuration is now externalized, supporting public endpoints and dynamic policy evaluation for API requests. The main areas of change are the addition of new security components, policy evaluation infrastructure, and configuration updates. **Security configuration and integration:** - Added a new `core-security` module with its own `pom.xml`, including dependencies for Spring Security, OAuth2 resource server, and project contracts. - Introduced `SecurityConfig` and `SecurityProperties` to configure Spring Security, define public endpoints, and integrate JWT authentication with Azure AD. [[1]](diffhunk://#diff-89b518b29fa9ec1944351e69efbcdba71e422c3a60243639e380ba2b7fdf9969R1-R53) [[2]](diffhunk://#diff-a407cfaaee4141697d8cd945a003a242b17f4caf458d659f0f701b97cfdbb499R1-R20) - Updated `application.yaml` to configure OAuth2 resource server with Azure AD issuer and define public endpoints for health checks. **Policy-based authorization system:** - Implemented `PolicyAuthorizationManager`, `PolicyEngine`, and `PolicyService` to support dynamic, rule-based authorization decisions for API requests. These components resolve API definitions, retrieve relevant policies, and evaluate them to allow or deny access. [[1]](diffhunk://#diff-1330c45b35848910097a5c9083c330efbc5c978fdacbe80e768d6657573809beR1-R79) [[2]](diffhunk://#diff-81f467c5c53592ee3767530c780ef2142885ae1fcf0ddae78b68cebdc45b831bR1-R49) [[3]](diffhunk://#diff-c81749eb986f34aa144a64ef9d2e529ddb29c36d75a9c2f84d2815e038aa645dR1-R32) - Added `PolicyContextFactory` to build policy evaluation contexts from HTTP requests and JWT claims. **Policy evaluators:** - Added `AllowedClientsEvaluator` and `FlavorRestrictionEvaluator` as concrete policy evaluators, supporting client-based and request-body-based authorization rules, respectively. [[1]](diffhunk://#diff-abc0ee53c28626c12e7bebda566921b0cdf787cef038bed9d4fde7cfd0c1b923R1-R47) [[2]](diffhunk://#diff-512b30434a48bdd75e08d7bf935623cb31a354bb934066ad730cefd72735cd60R1-R54) **Authentication flow enforcement:** - Added `AuthTypeEnforcementFilter`, `AuthFlowResolver`, and `AuthFlowValidator` to ensure that only allowed authentication flows (e.g., OBO, client credentials) are accepted for each API, with flow-specific validation. [[1]](diffhunk://#diff-3cd26dad5f2149a5873f0b9e0a3dc21468cf1f84f949c18e353d6248185972e3R1-R75) [[2]](diffhunk://#diff-0b949f9f721cec94f4e91188177fa3b549be489ef1404e3530bd417aa638cbc0R1-R22) [[3]](diffhunk://#diff-34a386426c9c407d82e7e04c5dccbaaeec679c47d049077bedcae5d7d4e24fceR1-R11) --------- Co-authored-by: Angel Martinez --- ...MarketplaceExternalServicePlaceholder.java | 27 + .../project/controller/ProjectController.java | 6 +- .../advice/ProjectExceptionHandler.java | 12 + .../project/util/SecurityUtils.java | 33 ++ .../controller/ProjectControllerTest.java | 15 +- application.yaml | 13 + .../core/contracts/policy/PolicyTypes.java | 1 + core-security/pom.xml | 57 ++ .../PolicyAuthorizationManager.java | 111 ++++ .../authorization/PolicyContextFactory.java | 34 ++ .../security/authorization/PolicyEngine.java | 49 ++ .../security/authorization/PolicyService.java | 28 + .../evaluator/AllowedClientsEvaluator.java | 47 ++ .../evaluator/FlavorRestrictionEvaluator.java | 53 ++ .../core/security/config/SecurityConfig.java | 62 ++ .../security/config/SecurityProperties.java | 20 + .../filter/AuthTypeEnforcementFilter.java | 75 +++ .../filter/CachedBodyHttpServletRequest.java | 56 ++ .../filter/CachedBodyRequestFilter.java | 38 ++ .../core/security/flow/AuthFlowResolver.java | 22 + .../core/security/flow/AuthFlowValidator.java | 11 + .../ClientCredentialFlowValidator.java | 43 ++ .../flow/validator/OboFlowValidator.java | 40 ++ .../jwt/AzureJwtAuthenticationConverter.java | 62 ++ .../registry/ApiDefinitionResolver.java | 32 + .../security/registry/CoreApiRegistry.java | 79 +++ .../PolicyAuthorizationManagerTest.java | 160 +++++ .../PolicyContextFactoryTest.java | 90 +++ .../authorization/PolicyEngineTest.java | 118 ++++ .../authorization/PolicyServiceTest.java | 67 +++ .../security/config/SecurityConfigTest.java | 64 ++ .../filter/AuthTypeEnforcementFilterTest.java | 174 ++++++ .../security/flow/AuthFlowResolverTest.java | 54 ++ .../AzureJwtAuthenticationConverterTest.java | 147 +++++ .../registry/ApiDefinitionResolverTest.java | 100 ++++ .../registry/CoreApiRegistryTest.java | 144 +++++ core/pom.xml | 9 +- .../apiservice/core/config/AspectConfig.java | 10 - .../core/config/CustomRoleConverter.java | 78 --- .../core/config/FlowProperties.java | 47 -- .../core/config/OnBehalfOfConfig.java | 10 - .../core/config/OnBehalfOfProperties.java | 111 ---- .../apiservice/core/config/OpenApiConfig.java | 99 ---- .../core/config/SecurityConfig.java | 177 ------ .../core/config/SecurityProperties.java | 38 -- ...CustomMethodSecurityExpressionHandler.java | 21 - .../CustomSecurityExpressionRoot.java | 69 --- .../core/security/FlowEnforcementAspect.java | 165 ------ .../core/security/FlowValidator.java | 228 -------- .../apiservice/core/security/RequireFlow.java | 41 -- .../core/config/CustomRoleConverterTest.java | 264 --------- .../core/config/FlowPropertiesTest.java | 366 ------------ .../core/config/SecurityConfigTest.java | 67 --- .../security/FlowEnforcementAspectTest.java | 370 ------------ .../core/security/FlowValidatorTest.java | 345 ----------- .../security/SecurityExpressionRootTest.java | 327 ----------- doc/core-security/tests.md | 195 +++++++ pom.xml | 1 + security-tests.http | 547 ++++++++++++++++++ 59 files changed, 2891 insertions(+), 2838 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java create mode 100644 core-security/pom.xml create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java delete mode 100644 core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java delete mode 100644 core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java create mode 100644 doc/core-security/tests.md create mode 100644 security-tests.http diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java new file mode 100644 index 00000000..990374ce --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java @@ -0,0 +1,27 @@ +package org.opendevstack.apiservice.project.facade; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.api.ExternalService; +import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +class MarketplaceExternalServicePlaceholder implements ExternalService { + + @Override + public boolean isHealthy() { + return false; + } + + public Component getProjectComponent(String projectId, String componentId) { + log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); + return null; + } + + public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); + return null; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index b7141337..c9bc530e 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -7,6 +7,7 @@ import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.util.SecurityUtils; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -37,7 +38,8 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - UUID clientId = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UUID clientId = SecurityUtils.getClientId(); + CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); return ResponseEntity @@ -45,7 +47,7 @@ public ResponseEntity createProject(@Valid @RequestBody C .header(HTTP_HEADER_LOCATION, API_BASE_PATH) .body(projectResponse); } - + @GetMapping("/{projectKey}") @Override public ResponseEntity getProject(@PathVariable String projectKey) { diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java index 56308e1c..6f76d9ea 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -10,6 +10,7 @@ import org.opendevstack.apiservice.project.model.CreateProjectResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -108,6 +109,17 @@ public ResponseEntity handleAutomationPlatformException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + log.error("Unexpected error: {}", ex.getMessage(), ex); + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(ErrorKey.BAD_REQUEST_BODY.getMessage()); + response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); + response.setMessage("An error occurred while processing the request."); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException(Exception ex) { log.error("Unexpected error: {}", ex.getMessage(), ex); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java new file mode 100644 index 00000000..bb926a98 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java @@ -0,0 +1,33 @@ +package org.opendevstack.apiservice.project.util; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; + +import java.util.UUID; + +public class SecurityUtils { + + private SecurityUtils() { + // to avoid instantiation + } + + public static UUID getClientId() { + Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + + if (principal instanceof Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + + if (clientId == null || clientId.isBlank()) { + throw new InvalidBearerTokenException("Client ID not found in token claims"); + } + + return UUID.fromString(clientId); + } else { + throw new InvalidBearerTokenException("Invalid authentication token"); + } + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index fbd11231..d184ef9c 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -11,14 +11,17 @@ import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.util.UUID; - class ProjectControllerTest { @Mock @@ -34,6 +37,14 @@ class ProjectControllerTest { void setup() { mocks = MockitoAnnotations.openMocks(this); sut = new ProjectController(projectsFacade, projectRequestValidator); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); } @AfterEach diff --git a/application.yaml b/application.yaml index dc471553..4696ac45 100644 --- a/application.yaml +++ b/application.yaml @@ -40,6 +40,19 @@ spring: open-in-view: false show-sql: false +spring: + security: + oauth2: + resourceserver: + jwt: + issuer-uri: https://sts.windows.net/${AZURE_TENANT_ID}/ + +app: + security: + public-endpoints: + - /actuator/health + - /actuator/health/** + management: endpoints: web: diff --git a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java index 96577271..43e256b6 100644 --- a/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java +++ b/core-contracts/src/main/java/org/opendevstack/apiservice/core/contracts/policy/PolicyTypes.java @@ -9,6 +9,7 @@ public final class PolicyTypes { private PolicyTypes() {} + public static final String FLAVOR_RESTRICTION = "FLAVOR_RESTRICTION"; public static final String ALLOWED_CLIENTS = "ALLOWED_CLIENTS"; public static final String SCOPE_REQUIRED = "SCOPE_REQUIRED"; } diff --git a/core-security/pom.xml b/core-security/pom.xml new file mode 100644 index 00000000..04bc8fb6 --- /dev/null +++ b/core-security/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.opendevstack.apiservice + devstack-api-service + 0.0.3 + + + core-security + core-security + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-oauth2-resource-server + + + + + org.opendevstack.apiservice + core-contracts + ${project.version} + + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.security + spring-security-test + test + + + \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java new file mode 100644 index 00000000..0a660afd --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManager.java @@ -0,0 +1,111 @@ +package org.opendevstack.apiservice.core.security.authorization; + + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.filter.AuthTypeEnforcementFilter; +import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; +import org.springframework.security.authorization.AuthorizationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; + +/** + * Spring Security AuthorizationManager that delegates authorization decisions to the policy engine. + */ +@Component +public class PolicyAuthorizationManager implements AuthorizationManager { + + private final PolicyEngine policyEngine; + private final PolicyService policyService; + private final PolicyContextFactory contextFactory; + private final ApiDefinitionResolver resolver; + + private final ObjectMapper objectMapper; + + public PolicyAuthorizationManager(PolicyEngine policyEngine, + PolicyService policyService, + PolicyContextFactory contextFactory, + ApiDefinitionResolver resolver, + ObjectMapper objectMapper) { + this.policyEngine = policyEngine; + this.policyService = policyService; + this.contextFactory = contextFactory; + this.resolver = resolver; + this.objectMapper = objectMapper; + } + + @Override + public org.springframework.security.authorization.AuthorizationDecision check( + Supplier authentication, + RequestAuthorizationContext context) { + + HttpServletRequest request = context.getRequest(); + + Optional apiDef = this.resolver.resolve(request); + + if (apiDef.isPresent()) { + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef.get()); + } + + // Unknown routes are denied (fail-closed). Public API definitions are allowed. + if (apiDef.isEmpty()) { + return new org.springframework.security.authorization.AuthorizationDecision(false); + } + + if (apiDef.get().isPublic()) { + return new org.springframework.security.authorization.AuthorizationDecision(true); + } + + PolicyContext policyContext = contextFactory.create(apiDef.get(), request); + policyContext = policyContext.withRequestBody(extractRequestBody(request)); + + List rules = policyService.findPolicies(apiDef.get().getId(), policyContext.getClientId()); + + AuthorizationDecision decision = policyEngine.evaluate(policyContext, rules); + + return new org.springframework.security.authorization.AuthorizationDecision( + decision != AuthorizationDecision.DENY + ); + } + + private Map extractRequestBody(HttpServletRequest request) { + if (!isJsonWriteRequest(request)) { + return Map.of(); + } + + try { + byte[] bytes = request.getInputStream().readAllBytes(); + if (bytes.length == 0) { + return Map.of(); + } + return objectMapper.readValue(bytes, new TypeReference<>() { + }); + } catch (IOException ex) { + return Map.of(); + } + } + + private boolean isJsonWriteRequest(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) + && !"PUT".equalsIgnoreCase(method) + && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith("application/json"); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java new file mode 100644 index 00000000..120c1cd1 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactory.java @@ -0,0 +1,34 @@ +package org.opendevstack.apiservice.core.security.authorization; + + +import jakarta.servlet.http.HttpServletRequest; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Component +public class PolicyContextFactory { + + public PolicyContext create(ApiDefinition apiDefinition, HttpServletRequest request) { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + + String clientId = null; + String subject = null; + Map claims = Map.of(); + + if (authentication instanceof JwtAuthenticationToken jwtAuth) { + Jwt jwt = jwtAuth.getToken(); + clientId = AzureJwtAuthenticationConverter.extractClientId(jwt); + subject = jwt.getClaimAsString("sub"); + claims = jwt.getClaims(); + } + + return new PolicyContext(clientId, subject, claims, apiDefinition, request); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java new file mode 100644 index 00000000..e1cec33a --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngine.java @@ -0,0 +1,49 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class PolicyEngine { + + private final List evaluators; + + public PolicyEngine(List evaluators) { + this.evaluators = evaluators; + } + + public AuthorizationDecision evaluate(PolicyContext context, List rules) { + if (rules == null || rules.isEmpty()) { + return AuthorizationDecision.DENY; + } + + AuthorizationDecision finalDecision = AuthorizationDecision.ABSTAIN; + + for (PolicyRule rule : rules) { + PolicyEvaluator evaluator = evaluators.stream() + .filter(e -> e.supports(rule.getPolicyType())) + .findFirst() + .orElse(null); + + if (evaluator == null) { + continue; + } + + AuthorizationDecision decision = evaluator.evaluate(context.withRule(rule)); + finalDecision = AuthorizationDecision.combine(finalDecision, decision); + + if (finalDecision == AuthorizationDecision.DENY) { + return AuthorizationDecision.DENY; + } + } + + return finalDecision == AuthorizationDecision.ABSTAIN + ? AuthorizationDecision.PERMIT + : finalDecision; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java new file mode 100644 index 00000000..316f2c08 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/PolicyService.java @@ -0,0 +1,28 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class PolicyService { + + private final PolicyDao policyDao; + + public PolicyService(PolicyDao policyDao) { + this.policyDao = policyDao; + } + + public List findPolicies(String apiDefinitionId, String clientId) { + if (clientId != null) { + List rules = new ArrayList<>(); + rules.addAll(policyDao.findGlobalByApiDefinitionId(apiDefinitionId)); + rules.addAll(policyDao.findByApiDefinitionIdAndClientId(apiDefinitionId, clientId)); + return rules; + } + return policyDao.findByApiDefinitionId(apiDefinitionId); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java new file mode 100644 index 00000000..cd247680 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/AllowedClientsEvaluator.java @@ -0,0 +1,47 @@ +package org.opendevstack.apiservice.core.security.authorization.evaluator; + + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.policy.PolicyTypes; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Evaluates ALLOWED_CLIENTS policy rules. + * Verifies that the clientId from the JWT is in the list of authorized clients. + */ +@Component +public class AllowedClientsEvaluator implements PolicyEvaluator { + + @Override + public boolean supports(String policyType) { + return PolicyTypes.ALLOWED_CLIENTS.equals(policyType); + } + + @Override + @SuppressWarnings("unchecked") + public AuthorizationDecision evaluate(PolicyContext context) { + PolicyRule rule = context.getActiveRule(); + if (rule == null || rule.getConfig() == null) { + return AuthorizationDecision.ABSTAIN; + } + + String clientId = context.getClientId(); + if (clientId == null) { + return AuthorizationDecision.DENY; + } + + List allowedClients = (List) rule.getConfig().get("allowedClients"); + if (allowedClients == null || allowedClients.isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + return allowedClients.contains(clientId) + ? AuthorizationDecision.PERMIT + : AuthorizationDecision.DENY; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java new file mode 100644 index 00000000..9da0b9a0 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -0,0 +1,53 @@ +package org.opendevstack.apiservice.core.security.authorization.evaluator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.policy.PolicyTypes; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Evaluates FLAVOR_RESTRICTION policy rules. + * Verifies that the requested flavor in the request body is in the list + * of allowed flavors for the client. + */ +@Component +public class FlavorRestrictionEvaluator implements PolicyEvaluator { + + @Override + public boolean supports(String policyType) { + return PolicyTypes.FLAVOR_RESTRICTION.equals(policyType); + } + + @Override + @SuppressWarnings("unchecked") + public AuthorizationDecision evaluate(PolicyContext context) { + PolicyRule rule = context.getActiveRule(); + if (rule == null || rule.getConfig() == null) { + return AuthorizationDecision.ABSTAIN; + } + + Map body = context.getRequestBody(); + if (body == null) { + return AuthorizationDecision.ABSTAIN; + } + + String requestedFlavor = (String) body.get("projectFlavor"); + if (requestedFlavor == null) { + return AuthorizationDecision.ABSTAIN; + } + + List allowedFlavors = (List) rule.getConfig().get("allowedFlavors"); + if (allowedFlavors == null || allowedFlavors.isEmpty()) { + return AuthorizationDecision.ABSTAIN; + } + + return allowedFlavors.contains(requestedFlavor) + ? AuthorizationDecision.PERMIT + : AuthorizationDecision.DENY; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java new file mode 100644 index 00000000..149be549 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +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.web.access.intercept.AuthorizationFilter; +import org.springframework.security.web.SecurityFilterChain; +import lombok.extern.slf4j.Slf4j; +import java.util.Arrays; + +@Slf4j +@Configuration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final SecurityProperties securityProperties; + private final PolicyAuthorizationManager policyAuthorizationManager; + private final AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private final CachedBodyRequestFilter cachedBodyRequestFilter; + + + public SecurityConfig(SecurityProperties securityProperties, + PolicyAuthorizationManager policyAuthorizationManager, + AzureJwtAuthenticationConverter azureJwtAuthenticationConverter, + CachedBodyRequestFilter cachedBodyRequestFilter) { + this.securityProperties = securityProperties; + this.policyAuthorizationManager = policyAuthorizationManager; + this.azureJwtAuthenticationConverter = azureJwtAuthenticationConverter; + this.cachedBodyRequestFilter = cachedBodyRequestFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authz -> { + // Allow public endpoints from security properties + if (securityProperties.getPublicEndpoints() != null) { + Arrays.stream(securityProperties.getPublicEndpoints()) + .forEach(endpoint -> { + log.info("Public endpoint configured: {}", endpoint); + authz.requestMatchers(endpoint).permitAll(); + }); + } + authz.anyRequest().access(policyAuthorizationManager); + }) + .oauth2ResourceServer((oauth2) -> + oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(azureJwtAuthenticationConverter)) + ) + .headers(headers -> headers.frameOptions(frame -> frame.disable())) + .csrf(csrf -> csrf.disable()); + + http.addFilterBefore(cachedBodyRequestFilter, AuthorizationFilter.class); + + return http.build(); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java new file mode 100644 index 00000000..e9957240 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java @@ -0,0 +1,20 @@ +package org.opendevstack.apiservice.core.security.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.security") +public class SecurityProperties { + + private boolean enabled = true; + + /** + * Map of endpoints that don't require authentication + */ + private String[] publicEndpoints; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java new file mode 100644 index 00000000..0ac2ddcf --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilter.java @@ -0,0 +1,75 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.flow.AuthFlowResolver; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class AuthTypeEnforcementFilter extends OncePerRequestFilter { + + public static final String API_DEFINITION_ATTR = "oas.apiDefinition"; + + private final AuthFlowResolver flowResolver; + private final Map validators; + + public AuthTypeEnforcementFilter(AuthFlowResolver flowResolver, + List validatorList) { + this.flowResolver = flowResolver; + this.validators = validatorList.stream() + .collect(Collectors.toMap(AuthFlowValidator::getSupportedFlow, Function.identity())); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + ApiDefinition apiDef = (ApiDefinition) request.getAttribute(API_DEFINITION_ATTR); + + // If no API definition resolved or does not require auth, continue + if (apiDef == null || !apiDef.requiresAuth()) { + filterChain.doFilter(request, response); + return; + } + + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (!(authentication instanceof JwtAuthenticationToken jwtAuth)) { + throw new AuthenticationCredentialsNotFoundException("JWT required"); + } + + Jwt jwt = jwtAuth.getToken(); + AuthType detectedFlow = flowResolver.resolve(jwt); + + // Verify detected flow is in the set of allowed flows for this API + if (detectedFlow == null || !apiDef.getAuthTypes().contains(detectedFlow)) { + throw new AccessDeniedException( + "Flow '" + detectedFlow + "' is not allowed for this API. Allowed: " + apiDef.getAuthTypes()); + } + + // Validate the flow specifics + AuthFlowValidator validator = validators.get(detectedFlow); + if (validator != null && !validator.validate(jwt)) { + throw new AccessDeniedException("Token validation failed for flow: " + detectedFlow); + } + + filterChain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java new file mode 100644 index 00000000..3511ffd2 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyHttpServletRequest.java @@ -0,0 +1,56 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Request wrapper that caches the body so it can be read multiple times. + */ +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = request.getInputStream().readAllBytes(); + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(cachedBody); + return new ServletInputStream() { + @Override + public int read() { + return byteArrayInputStream.read(); + } + + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener readListener) { + // no-op for synchronous request handling + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java new file mode 100644 index 00000000..43ea1ea2 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/filter/CachedBodyRequestFilter.java @@ -0,0 +1,38 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class CachedBodyRequestFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (shouldWrap(request)) { + filterChain.doFilter(new CachedBodyHttpServletRequest(request), response); + return; + } + + filterChain.doFilter(request, response); + } + + private boolean shouldWrap(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) + && !"PUT".equalsIgnoreCase(method) + && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + + String contentType = request.getContentType(); + return contentType != null && contentType.toLowerCase().startsWith("application/json"); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java new file mode 100644 index 00000000..0d5d53de --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolver.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class AuthFlowResolver { + + public AuthType resolve(Jwt jwt) { + if (jwt == null) { + return AuthType.NONE; + } + // In Azure AD, OBO tokens have a "scp" claim (delegated permissions). + // Client credentials tokens have a "roles" claim but no "scp". + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + return AuthType.OBO; + } + return AuthType.CLIENT_CREDENTIALS; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java new file mode 100644 index 00000000..8036b1c2 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/AuthFlowValidator.java @@ -0,0 +1,11 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; + +public interface AuthFlowValidator { + + AuthType getSupportedFlow(); + + boolean validate(Jwt jwt); +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java new file mode 100644 index 00000000..531577ca --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java @@ -0,0 +1,43 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class ClientCredentialFlowValidator implements AuthFlowValidator { + + @Override + public AuthType getSupportedFlow() { + return AuthType.CLIENT_CREDENTIALS; + } + + @Override + public boolean validate(Jwt jwt) { + String appid = jwt.getClaimAsString("appid"); + if (appid == null || appid.isBlank()) { + return false; + } + + Object aud = jwt.getClaim("aud"); + if (aud == null) { + return false; + } + + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + return false; + } + + String upn = jwt.getClaimAsString("upn"); + String preferredUsername = jwt.getClaimAsString("preferred_username"); + if ((upn != null && !upn.isBlank()) || (preferredUsername != null && !preferredUsername.isBlank())) { + return false; + } + + String sub = jwt.getSubject(); + String oid = jwt.getClaimAsString("oid"); + return sub != null && oid != null && sub.equals(oid); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java new file mode 100644 index 00000000..9395f5e6 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/OboFlowValidator.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.stereotype.Component; + +@Component +public class OboFlowValidator implements AuthFlowValidator { + + @Override + public AuthType getSupportedFlow() { + return AuthType.OBO; + } + + @Override + public boolean validate(Jwt jwt) { + String scp = jwt.getClaimAsString("scp"); + if (scp == null || scp.isBlank()) { + return false; + } + + String roles = jwt.getClaimAsString("roles"); + if (roles != null && !roles.isBlank()) { + return false; + } + + String user = jwt.getClaimAsString("upn"); + if (user == null || user.isBlank()) { + user = jwt.getClaimAsString("preferred_username"); + } + + if (user == null || user.isBlank()) { + user = jwt.getSubject(); + } + + return user != null && !user.isBlank(); + } + +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java new file mode 100644 index 00000000..7bf20c89 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverter.java @@ -0,0 +1,62 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.springframework.core.convert.converter.Converter; +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.JwtAuthenticationToken; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +@Component +public class AzureJwtAuthenticationConverter implements Converter { + + @Override + public AbstractAuthenticationToken convert(Jwt jwt) { + Collection authorities = extractAuthorities(jwt); + return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("sub")); + } + + private Collection extractAuthorities(Jwt jwt) { + List authorities = new ArrayList<>(); + + // App Roles from "roles" claim (client-credentials flow) + List roles = jwt.getClaimAsStringList("roles"); + if (roles != null) { + for (String role : roles) { + if (role != null && !role.isBlank()) { + authorities.add(new SimpleGrantedAuthority("ROLE_" + role)); + } + } + } + + // Delegated scopes from "scp" claim (authorization-code / OBO flows) + String scp = jwt.getClaimAsString("scp"); + if (scp != null && !scp.isBlank()) { + for (String scope : scp.split(" ")) { + if (!scope.isBlank()) { + authorities.add(new SimpleGrantedAuthority("SCOPE_" + scope)); + } + } + } + + return Collections.unmodifiableList(authorities); + } + + /** + * Extracts the client application identifier from the JWT. + * Entra ID uses {@code azp} (v2 tokens) or {@code appid} (v1 tokens). + */ + public static String extractClientId(Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + return clientId; + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java new file mode 100644 index 00000000..5feb497e --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolver.java @@ -0,0 +1,32 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class ApiDefinitionResolver { + + private static final Pattern VERSION_PATH_PATTERN = Pattern.compile("^/(?:api/(?:pub/)?)?(v\\d+)/(.+)$"); + + private final CoreApiRegistry registry; + + public ApiDefinitionResolver(CoreApiRegistry registry) { + this.registry = registry; + } + + public Optional resolve(HttpServletRequest request) { + String uri = request.getRequestURI(); + Matcher matcher = VERSION_PATH_PATTERN.matcher(uri); + if (!matcher.matches()) { + return Optional.empty(); + } + String version = matcher.group(1); // e.g. "v0", "v1" + String pathAfterVersion = matcher.group(2); + return registry.resolveBestMatch(version, pathAfterVersion); + } +} \ No newline at end of file diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java new file mode 100644 index 00000000..7e22f3c6 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistry.java @@ -0,0 +1,79 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.stereotype.Service; +import org.springframework.util.AntPathMatcher; + +import jakarta.annotation.PostConstruct; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class CoreApiRegistry { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + + private final ApiDefinitionDao apiDefinitionDao; + + private final Map routeIndex = new ConcurrentHashMap<>(); + + public CoreApiRegistry(ApiDefinitionDao apiDefinitionDao) { + this.apiDefinitionDao = apiDefinitionDao; + } + + @PostConstruct + public void init() { + refreshIndex(); + } + + public void refreshIndex() { + routeIndex.clear(); + List definitions = apiDefinitionDao.findAllEnabled(); + for (ApiDefinition def : definitions) { + String key = buildKey(def.getVersion(), def.getBasePath()); + routeIndex.put(key, def); + } + } + + public Optional resolve(String version, String basePath) { + String key = buildKey(version, basePath); + return Optional.ofNullable(routeIndex.get(key)); + } + + public Optional resolveBestMatch(String version, String requestPathAfterVersion) { + String normalizedRequestPath = normalizePath(requestPathAfterVersion); + if (normalizedRequestPath.isBlank()) { + return Optional.empty(); + } + + return routeIndex.values().stream() + .filter(definition -> version.equals(definition.getVersion())) + .filter(definition -> matchesPattern(definition.getBasePath(), normalizedRequestPath)) + .max((left, right) -> Integer.compare(patternSpecificity(left.getBasePath()), patternSpecificity(right.getBasePath()))); + } + + private String buildKey(String version, String basePath) { + String normalizedPath = basePath.startsWith("/") ? basePath.substring(1) : basePath; + return version + "/" + normalizedPath; + } + + private boolean matchesPattern(String basePathPattern, String requestPath) { + String normalizedPattern = normalizePath(basePathPattern); + return pathMatcher.match(normalizedPattern, requestPath) + || pathMatcher.match(normalizedPattern + "/**", requestPath); + } + + private int patternSpecificity(String basePathPattern) { + return normalizePath(basePathPattern).length(); + } + + private String normalizePath(String rawPath) { + if (rawPath == null || rawPath.isBlank()) { + return ""; + } + return rawPath.startsWith("/") ? rawPath.substring(1) : rawPath; + } +} \ No newline at end of file diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java new file mode 100644 index 00000000..21fb51d6 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyAuthorizationManagerTest.java @@ -0,0 +1,160 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.registry.ApiDefinitionResolver; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.access.intercept.RequestAuthorizationContext; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.anyMap; +import static org.mockito.Mockito.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class PolicyAuthorizationManagerTest { + + private PolicyEngine policyEngine; + private PolicyService policyService; + private PolicyContextFactory contextFactory; + private ApiDefinitionResolver resolver; + private PolicyAuthorizationManager manager; + + @BeforeEach + void setUp() { + policyEngine = mock(PolicyEngine.class); + policyService = mock(PolicyService.class); + contextFactory = mock(PolicyContextFactory.class); + resolver = mock(ApiDefinitionResolver.class); + manager = new PolicyAuthorizationManager(policyEngine, policyService, contextFactory, resolver, new ObjectMapper()); + } + + @Test + void check_unknownRoute_denied() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/unknown"); + when(resolver.resolve(request)).thenReturn(Optional.empty()); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertFalse(decision.isGranted()); + } + + @Test + void check_publicApi_permitted() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/health"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Health", "/health", "v0", + Set.of(AuthType.NONE), true, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + verifyNoInteractions(policyEngine); + } + + @Test + void check_securedApi_policyPermits() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "ALLOWED_CLIENTS", Map.of())); + when(policyService.findPolicies("api-1", "client-a")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.PERMIT); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } + + @Test + void check_securedApi_policyDenies() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-b"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-b", "ALLOWED_CLIENTS", Map.of())); + when(policyService.findPolicies("api-1", "client-b")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.DENY); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertFalse(decision.isGranted()); + } + + @Test + void check_securedApi_abstainPermits() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + when(policyService.findPolicies(anyString(), anyString())).thenReturn(List.of()); + when(policyEngine.evaluate(any(), any())).thenReturn(AuthorizationDecision.ABSTAIN); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } + + @Test + void check_securedPostRequest_populatesRequestBodyInContext() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v0/projects"); + request.setContentType("application/json"); + String requestBody = "{" + + "\"projectFlavor\":\"DLSS\"," + + "\"location\":\"UNKNOWN_REGION\"" + + "}"; + request.setContent(requestBody.getBytes(StandardCharsets.UTF_8)); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(resolver.resolve(request)).thenReturn(Optional.of(apiDef)); + + PolicyContext ctx = mock(PolicyContext.class); + when(ctx.getClientId()).thenReturn("client-a"); + when(ctx.withRequestBody(anyMap())).thenReturn(ctx); + when(contextFactory.create(apiDef, request)).thenReturn(ctx); + + List rules = List.of(new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "FLAVOR_RESTRICTION", Map.of())); + when(policyService.findPolicies("api-1", "client-a")).thenReturn(rules); + when(policyEngine.evaluate(ctx, rules)).thenReturn(AuthorizationDecision.PERMIT); + + var decision = manager.check(() -> null, new RequestAuthorizationContext(request)); + + assertTrue(decision.isGranted()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java new file mode 100644 index 00000000..4f940575 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyContextFactoryTest.java @@ -0,0 +1,90 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PolicyContextFactoryTest { + + private final PolicyContextFactory factory = new PolicyContextFactory(); + + @Test + void create_withJwtAuthentication_extractsClientIdSubjectAndClaims() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "user-1") + .claim("azp", "client-app") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertEquals("client-app", ctx.getClientId()); + assertEquals("user-1", ctx.getSubject()); + assertSame(apiDef, ctx.getApiDefinition()); + assertSame(request, ctx.getRequest()); + assertFalse(ctx.getClaims().isEmpty()); + + SecurityContextHolder.clearContext(); + } + + @Test + void create_withNoAuthentication_returnsNullClientIdAndSubject() { + SecurityContextHolder.clearContext(); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertNull(ctx.getClientId()); + assertNull(ctx.getSubject()); + assertTrue(ctx.getClaims().isEmpty()); + } + + @Test + void create_withAzpBlank_fallsBackToAppid() { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "user-1") + .claim("azp", "") + .claim("appid", "v1-client") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + + ApiDefinition apiDef = new ApiDefinition("api-1", "Test API", "/test", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/test"); + + PolicyContext ctx = factory.create(apiDef, request); + + assertEquals("v1-client", ctx.getClientId()); + + SecurityContextHolder.clearContext(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java new file mode 100644 index 00000000..81657e16 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyEngineTest.java @@ -0,0 +1,118 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthorizationDecision; +import org.opendevstack.apiservice.core.contracts.policy.PolicyContext; +import org.opendevstack.apiservice.core.contracts.policy.PolicyEvaluator; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyEngineTest { + + private PolicyEvaluator evaluatorA; + private PolicyEvaluator evaluatorB; + private PolicyEngine engine; + private PolicyContext context; + + @BeforeEach + void setUp() { + evaluatorA = mock(PolicyEvaluator.class); + evaluatorB = mock(PolicyEvaluator.class); + when(evaluatorA.supports("TYPE_A")).thenReturn(true); + when(evaluatorB.supports("TYPE_B")).thenReturn(true); + engine = new PolicyEngine(List.of(evaluatorA, evaluatorB)); + context = mock(PolicyContext.class); + when(context.withRule(any())).thenReturn(context); + } + + @Test + void evaluate_emptyRules_returnsDeny() { + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, Collections.emptyList())); + } + + @Test + void evaluate_nullRules_returnsDeny() { + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, null)); + } + + @Test + void evaluate_singleRulePermit_returnsPermit() { + PolicyRule rule = rule("TYPE_A"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(rule))); + } + + @Test + void evaluate_singleRuleDeny_returnsDeny() { + PolicyRule rule = rule("TYPE_A"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, List.of(rule))); + } + + @Test + void evaluate_twoRules_permitThenDeny_returnsDeny() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + assertEquals(AuthorizationDecision.DENY, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_twoRules_permitThenAbstain_returnsPermit() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.PERMIT); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_allAbstain_returnsPermit() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + when(evaluatorB.evaluate(context)).thenReturn(AuthorizationDecision.ABSTAIN); + + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(ruleA, ruleB))); + } + + @Test + void evaluate_ruleWithNoMatchingEvaluator_isSkipped() { + PolicyRule unknownRule = rule("UNKNOWN_TYPE"); + + // No evaluator supports UNKNOWN_TYPE → skipped → finalDecision stays ABSTAIN → PERMIT + assertEquals(AuthorizationDecision.PERMIT, engine.evaluate(context, List.of(unknownRule))); + } + + @Test + void evaluate_denyShortCircuits_secondEvaluatorNotCalled() { + PolicyRule ruleA = rule("TYPE_A"); + PolicyRule ruleB = rule("TYPE_B"); + when(evaluatorA.evaluate(context)).thenReturn(AuthorizationDecision.DENY); + + engine.evaluate(context, List.of(ruleA, ruleB)); + + verify(evaluatorB, never()).evaluate(any()); + } + + private PolicyRule rule(String type) { + return new PolicyRule(UUID.randomUUID(), "api-1", "client-1", type, Map.of()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java new file mode 100644 index 00000000..635a30ef --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/authorization/PolicyServiceTest.java @@ -0,0 +1,67 @@ +package org.opendevstack.apiservice.core.security.authorization; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.persistence.PolicyDao; +import org.opendevstack.apiservice.core.contracts.policy.PolicyRule; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PolicyServiceTest { + + private PolicyDao policyDao; + private PolicyService policyService; + + @BeforeEach + void setUp() { + policyDao = mock(PolicyDao.class); + policyService = new PolicyService(policyDao); + } + + @Test + void findPolicies_withClientId_combinesGlobalAndClientSpecificRules() { + PolicyRule globalRule = new PolicyRule(UUID.randomUUID(), "api-1", null, "TYPE_A", Map.of()); + PolicyRule clientRule = new PolicyRule(UUID.randomUUID(), "api-1", "client-a", "TYPE_B", Map.of()); + + when(policyDao.findGlobalByApiDefinitionId("api-1")).thenReturn(List.of(globalRule)); + when(policyDao.findByApiDefinitionIdAndClientId("api-1", "client-a")).thenReturn(List.of(clientRule)); + + List result = policyService.findPolicies("api-1", "client-a"); + + assertEquals(2, result.size()); + assertTrue(result.contains(globalRule)); + assertTrue(result.contains(clientRule)); + } + + @Test + void findPolicies_withNullClientId_usesApiDefinitionOnlyLookup() { + PolicyRule rule = new PolicyRule(UUID.randomUUID(), "api-1", null, "TYPE_A", Map.of()); + when(policyDao.findByApiDefinitionId("api-1")).thenReturn(List.of(rule)); + + List result = policyService.findPolicies("api-1", null); + + assertEquals(1, result.size()); + verify(policyDao, never()).findGlobalByApiDefinitionId(any()); + verify(policyDao, never()).findByApiDefinitionIdAndClientId(any(), any()); + } + + @Test + void findPolicies_withClientId_noRulesFound_returnsEmpty() { + when(policyDao.findGlobalByApiDefinitionId("api-1")).thenReturn(List.of()); + when(policyDao.findByApiDefinitionIdAndClientId("api-1", "client-x")).thenReturn(List.of()); + + List result = policyService.findPolicies("api-1", "client-x"); + + assertTrue(result.isEmpty()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java new file mode 100644 index 00000000..5a49c23d --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -0,0 +1,64 @@ +package org.opendevstack.apiservice.core.security.config; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.security.authorization.PolicyAuthorizationManager; +import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; +import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SecurityConfigTest { + + private SecurityProperties securityProperties; + private PolicyAuthorizationManager policyAuthorizationManager; + private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; + private CachedBodyRequestFilter cachedBodyRequestFilter; + private SecurityConfig securityConfig; + + @BeforeEach + void setUp() { + securityProperties = mock(SecurityProperties.class); + policyAuthorizationManager = mock(PolicyAuthorizationManager.class); + azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); + cachedBodyRequestFilter = mock(CachedBodyRequestFilter.class); + securityConfig = new SecurityConfig( + securityProperties, + policyAuthorizationManager, + azureJwtAuthenticationConverter, + cachedBodyRequestFilter + ); + } + + @Test + void securityFilterChain_withPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{"/health", "/info"}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withNullPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(null); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } + + @Test + void securityFilterChain_withEmptyPublicEndpoints() throws Exception { + when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{}); + HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); + + SecurityFilterChain chain = securityConfig.securityFilterChain(http); + assertNotNull(chain); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java new file mode 100644 index 00000000..6a3ee8d0 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/filter/AuthTypeEnforcementFilterTest.java @@ -0,0 +1,174 @@ +package org.opendevstack.apiservice.core.security.filter; + +import jakarta.servlet.FilterChain; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.opendevstack.apiservice.core.security.flow.AuthFlowResolver; +import org.opendevstack.apiservice.core.security.flow.AuthFlowValidator; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AuthTypeEnforcementFilterTest { + + private AuthFlowResolver flowResolver; + private AuthFlowValidator ccValidator; + private AuthFlowValidator oboValidator; + private AuthTypeEnforcementFilter filter; + private FilterChain filterChain; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + flowResolver = new AuthFlowResolver(); + ccValidator = mock(AuthFlowValidator.class); + when(ccValidator.getSupportedFlow()).thenReturn(AuthType.CLIENT_CREDENTIALS); + when(ccValidator.validate(any())).thenReturn(true); + + oboValidator = mock(AuthFlowValidator.class); + when(oboValidator.getSupportedFlow()).thenReturn(AuthType.OBO); + when(oboValidator.validate(any())).thenReturn(true); + + filter = new AuthTypeEnforcementFilter(flowResolver, List.of(ccValidator, oboValidator)); + filterChain = mock(FilterChain.class); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void noApiDefinition_continuesChain() throws Exception { + // No API_DEFINITION_ATTR set + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void publicApi_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Public", "/pub", "v0", + Set.of(AuthType.NONE), true, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_noJwtAuthentication_throwsCredentialsNotFound() { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + assertThrows(AuthenticationCredentialsNotFoundException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_clientCredentials_allowedFlow_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); // no scp → CLIENT_CREDENTIALS + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_obo_allowedFlow_continuesChain() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("scp", "api.read")); // scp present → OBO + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_wrongFlow_throwsAccessDenied() { + // API only allows OBO, but token is CLIENT_CREDENTIALS + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); // no scp → CLIENT_CREDENTIALS + + assertThrows(AccessDeniedException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_flowValidatorFails_throwsAccessDenied() { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); + when(ccValidator.validate(any())).thenReturn(false); + + assertThrows(AccessDeniedException.class, + () -> filter.doFilterInternal(request, response, filterChain)); + } + + @Test + void securedApi_bothFlowsAllowed_clientCredentialsToken_passes() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS, AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("roles", List.of("ADMIN"))); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + @Test + void securedApi_bothFlowsAllowed_oboToken_passes() throws Exception { + ApiDefinition apiDef = new ApiDefinition("api-1", "Secured", "/sec", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS, AuthType.OBO), false, null, true); + request.setAttribute(AuthTypeEnforcementFilter.API_DEFINITION_ATTR, apiDef); + + setJwtAuth(Map.of("scp", "api.read")); + + filter.doFilterInternal(request, response, filterChain); + verify(filterChain).doFilter(request, response); + } + + private void setJwtAuth(Map claims) { + Jwt jwt = Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(claims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + JwtAuthenticationToken authToken = new JwtAuthenticationToken(jwt); + SecurityContextHolder.getContext().setAuthentication(authToken); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java new file mode 100644 index 00000000..d5821b79 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/AuthFlowResolverTest.java @@ -0,0 +1,54 @@ +package org.opendevstack.apiservice.core.security.flow; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AuthFlowResolverTest { + + private AuthFlowResolver resolver; + + @BeforeEach + void setUp() { + resolver = new AuthFlowResolver(); + } + + @Test + void resolve_withScpClaim_returnsObo() { + Jwt jwt = buildJwt(Map.of("scp", "api.read api.write")); + assertEquals(AuthType.OBO, resolver.resolve(jwt)); + } + + @Test + void resolve_withBlankScpClaim_returnsClientCredentials() { + Jwt jwt = buildJwt(Map.of("scp", " ")); + assertEquals(AuthType.CLIENT_CREDENTIALS, resolver.resolve(jwt)); + } + + @Test + void resolve_withoutScpClaim_returnsClientCredentials() { + Jwt jwt = buildJwt(Map.of("sub", "user-id")); + assertEquals(AuthType.CLIENT_CREDENTIALS, resolver.resolve(jwt)); + } + + @Test + void resolve_withNullJwt_returnsNone() { + assertEquals(AuthType.NONE, resolver.resolve(null)); + } + + private Jwt buildJwt(Map claims) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(claims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java new file mode 100644 index 00000000..1fd8eca9 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/AzureJwtAuthenticationConverterTest.java @@ -0,0 +1,147 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AzureJwtAuthenticationConverterTest { + + private AzureJwtAuthenticationConverter converter; + + @BeforeEach + void setUp() { + converter = new AzureJwtAuthenticationConverter(); + } + + // ── Authority extraction ── + + @Test + void convert_withRolesOnly_mapsToRolePrefixedAuthorities() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN", "READER"))); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("ROLE_READER")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withScpOnly_mapsToScopePrefixedAuthorities() { + Jwt jwt = buildJwt(Map.of("scp", "api.read api.write")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("SCOPE_api.read")); + assertTrue(authorities.contains("SCOPE_api.write")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withBothRolesAndScp_mapsBoth() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN"), "scp", "api.read")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertTrue(authorities.contains("ROLE_ADMIN")); + assertTrue(authorities.contains("SCOPE_api.read")); + assertEquals(2, authorities.size()); + } + + @Test + void convert_withNeitherClaim_returnsEmptyAuthorities() { + Jwt jwt = buildJwt(Map.of()); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertTrue(token.getAuthorities().isEmpty()); + } + + @Test + void convert_withBlankRole_isIgnored() { + Jwt jwt = buildJwt(Map.of("roles", List.of("ADMIN", " ", ""))); + + AbstractAuthenticationToken token = converter.convert(jwt); + + Collection authorities = authorityStrings(token); + assertEquals(1, authorities.size()); + assertTrue(authorities.contains("ROLE_ADMIN")); + } + + @Test + void convert_withBlankScp_returnsNoScopeAuthorities() { + Jwt jwt = buildJwt(Map.of("scp", " ")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertTrue(token.getAuthorities().isEmpty()); + } + + @Test + void convert_setsSubjectAsName() { + Jwt jwt = buildJwt(Map.of("sub", "user-123")); + + AbstractAuthenticationToken token = converter.convert(jwt); + + assertEquals("user-123", token.getName()); + } + + // ── extractClientId ── + + @Test + void extractClientId_withAzp_returnsAzp() { + Jwt jwt = buildJwt(Map.of("azp", "client-a")); + assertEquals("client-a", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withAzpBlank_fallsBackToAppid() { + Jwt jwt = buildJwt(Map.of("azp", "", "appid", "client-b")); + assertEquals("client-b", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withAzpNull_fallsBackToAppid() { + Jwt jwt = buildJwt(Map.of("appid", "client-b")); + assertEquals("client-b", AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + @Test + void extractClientId_withNeitherClaim_returnsNull() { + Jwt jwt = buildJwt(Map.of()); + assertNull(AzureJwtAuthenticationConverter.extractClientId(jwt)); + } + + // ── helpers ── + + private Collection authorityStrings(AbstractAuthenticationToken token) { + return token.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toSet()); + } + + private Jwt buildJwt(Map extraClaims) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", "test-subject") + .claims(c -> c.putAll(extraClaims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java new file mode 100644 index 00000000..a0f6c772 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/ApiDefinitionResolverTest.java @@ -0,0 +1,100 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; +import org.springframework.mock.web.MockHttpServletRequest; + +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class ApiDefinitionResolverTest { + + private CoreApiRegistry registry; + private ApiDefinitionResolver resolver; + + @BeforeEach + void setUp() { + registry = mock(CoreApiRegistry.class); + resolver = new ApiDefinitionResolver(registry); + } + + @Test + void resolve_standardVersionedPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(registry.resolveBestMatch("v0", "projects")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/projects"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_publicApiPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-pub", "Health", "health", "v0", + Set.of(AuthType.NONE), true, null, true); + when(registry.resolveBestMatch("v0", "health")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/pub/v0/health"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_directVersionPath_delegatesToRegistry() { + ApiDefinition expected = new ApiDefinition("api-1", "Projects", "projects", "v1", + Set.of(AuthType.OBO), false, null, true); + when(registry.resolveBestMatch("v1", "projects")).thenReturn(Optional.of(expected)); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/v1/projects"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isPresent()); + assertEquals(expected, result.get()); + } + + @Test + void resolve_noVersionInPath_returnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/health"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + verifyNoInteractions(registry); + } + + @Test + void resolve_rootPath_returnsEmpty() { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + } + + @Test + void resolve_registryReturnsEmpty_returnsEmpty() { + when(registry.resolveBestMatch("v0", "unknown")).thenReturn(Optional.empty()); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v0/unknown"); + + Optional result = resolver.resolve(request); + + assertTrue(result.isEmpty()); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java new file mode 100644 index 00000000..ed27df93 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/registry/CoreApiRegistryTest.java @@ -0,0 +1,144 @@ +package org.opendevstack.apiservice.core.security.registry; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.core.contracts.auth.AuthType; +import org.opendevstack.apiservice.core.contracts.persistence.ApiDefinitionDao; +import org.opendevstack.apiservice.core.contracts.registry.ApiDefinition; + +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class CoreApiRegistryTest { + + private ApiDefinitionDao apiDefinitionDao; + private CoreApiRegistry registry; + + @BeforeEach + void setUp() { + apiDefinitionDao = mock(ApiDefinitionDao.class); + registry = new CoreApiRegistry(apiDefinitionDao); + } + + @Test + void init_loadsDefinitionsFromDao() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + + registry.init(); + + Optional result = registry.resolve("v0", "projects"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolve_exactMatch_returnsDefinition() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolve("v0", "projects").isPresent()); + } + + @Test + void resolve_noMatch_returnsEmpty() { + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of()); + registry.init(); + + assertTrue(registry.resolve("v0", "nonexistent").isEmpty()); + } + + @Test + void resolveBestMatch_exactPath_returnsDefinition() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolveBestMatch_subPath_matchesViaAntPattern() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects/123/tasks"); + assertTrue(result.isPresent()); + assertEquals("api-1", result.get().getId()); + } + + @Test + void resolveBestMatch_wrongVersion_returnsEmpty() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolveBestMatch("v1", "projects").isEmpty()); + } + + @Test + void resolveBestMatch_picksMostSpecific() { + ApiDefinition broad = new ApiDefinition("api-broad", "Broad", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + ApiDefinition specific = new ApiDefinition("api-specific", "Specific", "projects/tasks", "v0", + Set.of(AuthType.OBO), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(broad, specific)); + registry.init(); + + Optional result = registry.resolveBestMatch("v0", "projects/tasks/42"); + assertTrue(result.isPresent()); + assertEquals("api-specific", result.get().getId()); + } + + @Test + void resolveBestMatch_blankPath_returnsEmpty() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolveBestMatch("v0", "").isEmpty()); + } + + @Test + void refreshIndex_clearsOldEntriesAndReloads() { + ApiDefinition old = new ApiDefinition("api-old", "Old", "old-path", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(old)); + registry.init(); + assertTrue(registry.resolve("v0", "old-path").isPresent()); + + ApiDefinition updated = new ApiDefinition("api-new", "New", "new-path", "v0", + Set.of(AuthType.OBO), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(updated)); + registry.refreshIndex(); + + assertTrue(registry.resolve("v0", "old-path").isEmpty()); + assertTrue(registry.resolve("v0", "new-path").isPresent()); + } + + @Test + void resolve_basePathWithLeadingSlash_matches() { + ApiDefinition def = new ApiDefinition("api-1", "Projects", "/projects", "v0", + Set.of(AuthType.CLIENT_CREDENTIALS), false, null, true); + when(apiDefinitionDao.findAllEnabled()).thenReturn(List.of(def)); + registry.init(); + + assertTrue(registry.resolve("v0", "projects").isPresent()); + } +} diff --git a/core/pom.xml b/core/pom.xml index 252da506..a0ba5310 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -30,6 +30,13 @@ spring-boot-starter-oauth2-resource-server + + + org.opendevstack.apiservice + core-security + ${project.version} + + org.springframework.boot spring-boot-starter-aop @@ -138,7 +145,7 @@ api-project-platform ${project.version} - + org.opendevstack.apiservice diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java deleted file mode 100644 index 90d5d5d9..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/AspectConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.EnableAspectJAutoProxy; - -@Configuration -@EnableAspectJAutoProxy -public class AspectConfig { - // Enables AspectJ auto-proxy for flow enforcement -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java b/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java deleted file mode 100644 index a705ffb0..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/CustomRoleConverter.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.springframework.core.convert.converter.Converter; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Collection; -import java.util.List; -import java.util.Map; - -public class CustomRoleConverter implements Converter> { - - private static final String ROLES_CLAIM = "roles"; - - @Override - @SuppressWarnings("nullness") - public Collection convert(Jwt jwt) { - if (jwt == null) { - return List.of(); - } - // Extract realm roles (Keycloak/Auth0 standard) - List realmRoles = getRealmRoles(jwt); - - // Extract resource roles (client-specific roles) - List resourceRoles = getResourceRoles(jwt); - - // Combine all roles - List allRoles = combineRoles(realmRoles, resourceRoles); - - // Convert to GrantedAuthority with ROLE_ prefix - return allRoles.stream() - .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) - .map(GrantedAuthority.class::cast) - .toList(); - } - - private List getRealmRoles(Jwt jwt) { - Map realmAccess = jwt.getClaimAsMap("realm_access"); - if (realmAccess != null && realmAccess.containsKey(ROLES_CLAIM)) { - @SuppressWarnings("unchecked") - List roles = (List) realmAccess.get(ROLES_CLAIM); - return roles != null ? roles : List.of(); - } - return List.of(); - } - - private List getResourceRoles(Jwt jwt) { - Map resourceAccess = jwt.getClaimAsMap("resource_access"); - if (resourceAccess == null) { - return List.of(); - } - - return resourceAccess.values().stream() - .map(this::extractRolesFromResource) - .flatMap(Collection::stream) - .toList(); - } - - @SuppressWarnings("unchecked") - private List extractRolesFromResource(Object resource) { - if (resource instanceof Map) { - Map resourceMap = (Map) resource; - Object roles = resourceMap.get(ROLES_CLAIM); - if (roles instanceof List) { - return (List) roles; - } - } - return List.of(); - } - - private List combineRoles(List realmRoles, List resourceRoles) { - return java.util.stream.Stream.concat( - realmRoles.stream(), - resourceRoles.stream() - ).toList(); - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java deleted file mode 100644 index e367e651..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/FlowProperties.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.security.flows") -public class FlowProperties { - - private Global global = new Global(); - - private Map apis; - - @Getter - @Setter - public static class Global { - private List enabledFlows; - private String defaultFlow = "client-credentials"; - } - - @Getter - @Setter - public static class ApiFlows { - private String defaultFlow; - private List endpoints; - } - - @Getter - @Setter - public static class EndpointFlow { - private String pattern; // Now as a property instead of map key - private List flows; - private List roles; - private boolean permitAll = false; - private boolean requireActor = false; - private boolean requireAuthentication = true; - private int requireDelegationDepth = 0; - private List requiredScopes; - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java deleted file mode 100644 index 0c72cbae..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfConfig.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.opendevstack.apiservice.core.config; -import org.springframework.boot.context.properties.EnableConfigurationProperties; -import org.springframework.context.annotation.Configuration; - -@Configuration -@EnableConfigurationProperties(OnBehalfOfProperties.class) -public class OnBehalfOfConfig { - // This class enables binding of OnBehalfOfProperties to configuration - // No additional code needed - just enables the configuration properties -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java deleted file mode 100644 index ea434594..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OnBehalfOfProperties.java +++ /dev/null @@ -1,111 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.on-behalf-of") -public class OnBehalfOfProperties { - - private boolean enabled = true; - - private TokenExchange tokenExchange = new TokenExchange(); - - private Flow flow = new Flow(); - - private Delegation delegation = new Delegation(); - - private Security security = new Security(); - - private Provider provider = new Provider(); - - @Getter - @Setter - public static class TokenExchange { - private String grantType = "urn:ietf:params:oauth:grant-type:token-exchange"; - private List supportedSubjectTokenTypes; - private String requestedTokenType = "urn:ietf:params:oauth:token-type:access_token"; - private String defaultAudience; - } - - @Getter - @Setter - public static class Flow { - private boolean validateActor = false; - private List allowedActorClients; - private List requiredScopes; - private int delegatedTokenLifetime = 3600; - } - - @Getter - @Setter - public static class Delegation { - private Map rules; - - @Getter - @Setter - public static class ServiceDelegation { - private List canDelegateTo; - private List allowedScopes; - private int maxDelegationDepth = 1; - } - } - - @Getter - @Setter - public static class Security { - private boolean requireActorClaim = false; - private String introspectionEndpoint; - private AuditLogging auditLogging = new AuditLogging(); - - @Getter - @Setter - public static class AuditLogging { - private boolean enabled = true; - private boolean logSuccessfulExchanges = true; - private boolean logFailedExchanges = true; - private boolean includeTokenScope = false; - } - } - - @Getter - @Setter - public static class Provider { - private String type = "keycloak"; - private Settings settings = new Settings(); - - @Getter - @Setter - public static class Settings { - private Keycloak keycloak = new Keycloak(); - private Auth0 auth0 = new Auth0(); - private Generic generic = new Generic(); - - @Getter - @Setter - public static class Keycloak { - private String tokenExchangePolicy = "on-behalf-of"; - } - - @Getter - @Setter - public static class Auth0 { - private String organization; - } - - @Getter - @Setter - public static class Generic { - private String tokenExchangeUrl; - private String clientAuthMethod = "client_secret_basic"; - } - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java deleted file mode 100644 index 40696161..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/OpenApiConfig.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.info.Contact; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.servers.Server; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.env.Environment; -import org.springframework.boot.context.properties.bind.Binder; -import org.springframework.boot.context.properties.bind.Bindable; - -import java.util.List; - -@Configuration -public class OpenApiConfig { - - @Value("${openapi.info.title:'API Documentation'}") - private String title; - - @Value("${openapi.info.description:'API description not provided'}") - private String description; - - @Value("${openapi.info.version:'1.0.0'}") - private String version; - - @Value("${openapi.info.contact.name:'API Support'}") - private String contactName; - - @Value("${openapi.info.contact.email:'support@example.com'}") - private String contactEmail; - - private final Environment environment; - - public OpenApiConfig(Environment environment) { - this.environment = environment; - } - - @Bean - public OpenAPI customOpenAPI() { - // bind the YAML list under openapi.servers to the nested ServerProperties class - List configured = Binder.get(environment) - .bind("openapi.servers", Bindable.listOf(ServerProperties.class)) - .orElse(List.of()); - - List servers = configured.stream() - .map(s -> new Server().url(s.getUrl()).description(s.getDescription())) - .toList(); - - // fallback to a sensible default if no servers configured - if (servers.isEmpty()) { - servers = List.of(new Server().url("http://localhost:8080").description("Development server")); - } - - final String securitySchemeName = "bearerAuth"; - return new OpenAPI() - .info(new Info() - .title(title) - .description(description) - .version(version) - .contact(new Contact().name(contactName).email(contactEmail))) - .servers(servers) - .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) - .components( - new Components() - .addSecuritySchemes(securitySchemeName, - new SecurityScheme() - .name(securitySchemeName) - .type(SecurityScheme.Type.HTTP) - .scheme("bearer") - .bearerFormat("JWT"))); - - } - - public static class ServerProperties { - private String url; - private String description; - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java b/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java deleted file mode 100644 index cd8b73ed..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityConfig.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.opendevstack.apiservice.core.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.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.web.SecurityFilterChain; -import lombok.extern.slf4j.Slf4j; -import java.util.Arrays; - -@Slf4j -@Configuration -@EnableWebSecurity -@EnableMethodSecurity(prePostEnabled = true) -public class SecurityConfig { - - private final SecurityProperties securityProperties; - private final FlowProperties flowProperties; - - public SecurityConfig(SecurityProperties securityProperties, FlowProperties flowProperties) { - this.securityProperties = securityProperties; - this.flowProperties = flowProperties; - } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authz -> { - // Allow public endpoints from security properties - if (securityProperties.getPublicEndpoints() != null) { - Arrays.stream(securityProperties.getPublicEndpoints()) - .forEach(endpoint -> authz.requestMatchers(endpoint).permitAll()); - } - - // Apply flow-based security rules - applyFlowBasedSecurity(authz); - - // All other requests require authentication - authz.anyRequest().authenticated(); - }) - .oauth2ResourceServer(oauth2 -> - oauth2.jwt(jwt -> jwt - .jwtAuthenticationConverter(jwtAuthenticationConverter()) - ) - ) - .headers(headers -> headers.frameOptions(frame -> frame.disable())) - .csrf(csrf -> csrf.disable()); - - return http.build(); - } - - private void applyFlowBasedSecurity(org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry authz) { - if (flowProperties.getApis() == null) { - return; - } - - // Apply security rules based on flow configuration - flowProperties.getApis().forEach((apiName, apiFlows) -> { - if (apiFlows.getEndpoints() != null) { - apiFlows.getEndpoints().forEach(endpointFlow -> { - String pattern = endpointFlow.getPattern(); - - // Debug logging to see what patterns we're receiving - log.info("Processing security pattern: '{}' for API: {}", pattern, apiName); - - // Permit all if specified - if (endpointFlow.isPermitAll()) { - authz.requestMatchers(pattern).permitAll(); - } - // Apply role-based security - else if (endpointFlow.getRoles() != null && !endpointFlow.getRoles().isEmpty()) { - String[] rolesArray = endpointFlow.getRoles().toArray(new String[0]); - authz.requestMatchers(pattern).hasAnyRole(rolesArray); - } - // Otherwise require authentication - else if (endpointFlow.isRequireAuthentication()) { - authz.requestMatchers(pattern).authenticated(); - } - }); - } - }); - } - - @Bean - public JwtAuthenticationConverter jwtAuthenticationConverter() { - JwtAuthenticationConverter authenticationConverter = new JwtAuthenticationConverter(); - authenticationConverter.setJwtGrantedAuthoritiesConverter(new CustomRoleConverter()); - return authenticationConverter; - } - - @Bean - public CustomRoleConverter customRoleConverter() { - return new CustomRoleConverter(); - } - - @Bean - public JwtDecoder jwtDecoder() { - // For development: use a lenient decoder when JWT validation is disabled - if (!securityProperties.isJwtValidationEnabled()) { - return createLenientJwtDecoder(); - } - - // For production: use proper JWT validation with JWK Set URI - return createValidatingJwtDecoder(); - } - - private JwtDecoder createLenientJwtDecoder() { - return token -> { - // Parse JWT without validation - String[] parts = token.split("\\."); - if (parts.length != 3) { - throw new org.springframework.security.oauth2.jwt.BadJwtException("Invalid JWT token"); - } - - // Decode payload - java.util.Base64.Decoder decoder = java.util.Base64.getUrlDecoder(); - String payload = new String(decoder.decode(parts[1])); - - return parseJwtPayload(token, payload); - }; - } - - private org.springframework.security.oauth2.jwt.Jwt parseJwtPayload(String token, String payload) { - org.springframework.security.oauth2.jwt.Jwt.Builder builder = - org.springframework.security.oauth2.jwt.Jwt.withTokenValue(token); - - try { - com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); - @SuppressWarnings("unchecked") - java.util.Map claims = mapper.readValue(payload, java.util.Map.class); - - builder.claims(c -> c.putAll(claims)); - - // Set standard claims if present - if (claims.containsKey("iss")) { - builder.issuer(claims.get("iss").toString()); - } - if (claims.containsKey("sub")) { - builder.subject(claims.get("sub").toString()); - } - if (claims.containsKey("exp")) { - Number exp = (Number) claims.get("exp"); - builder.expiresAt(java.time.Instant.ofEpochSecond(exp.longValue())); - } else { - // Set a far future expiration if not present - builder.expiresAt(java.time.Instant.now().plusSeconds(3600)); - } - if (claims.containsKey("iat")) { - Number iat = (Number) claims.get("iat"); - builder.issuedAt(java.time.Instant.ofEpochSecond(iat.longValue())); - } else { - builder.issuedAt(java.time.Instant.now()); - } - - builder.header("alg", "none"); - - return builder.build(); - } catch (Exception e) { - throw new org.springframework.security.oauth2.jwt.BadJwtException("Failed to parse JWT", e); - } - } - - private JwtDecoder createValidatingJwtDecoder() { - if (securityProperties.getJwkSetUri() != null && !securityProperties.getJwkSetUri().isEmpty()) { - return NimbusJwtDecoder.withJwkSetUri(securityProperties.getJwkSetUri()).build(); - } - - throw new IllegalStateException( - "JWT validation is enabled but no JWK Set URI is configured. " + - "Please set app.security.jwk-set-uri in your configuration." - ); - } -} \ No newline at end of file diff --git a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java b/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java deleted file mode 100644 index 5d21689c..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/config/SecurityProperties.java +++ /dev/null @@ -1,38 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -@Getter -@Setter -@Component -@ConfigurationProperties(prefix = "app.security") -public class SecurityProperties { - - private boolean enabled = true; - private boolean jwtValidationEnabled = false; - private String issuer; - private String audience; - private String jwkSetUri; - - /** - * Map of endpoint patterns to required roles - * Format: pattern -> list of roles - */ - private Map endpointRoles = new HashMap<>(); - - /** - * Map of endpoints that don't require authentication - */ - private String[] publicEndpoints = { - "/api/public/**", - "/actuator/health", - "/actuator/info", - "/h2-console/**" - }; -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java b/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java deleted file mode 100644 index 29a459ba..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomMethodSecurityExpressionHandler.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.springframework.security.access.expression.method.DefaultMethodSecurityExpressionHandler; - -/** - * Custom method security expression handler for custom security expressions. - * - * In Spring Security 6.x, custom methods in SecurityExpressionRoot are automatically - * recognized without needing to override createSecurityExpressionRoot(). - * - * This class extends DefaultMethodSecurityExpressionHandler to ensure our - * custom SecurityExpressionRoot is used for method security expressions. - * - * Note: In Spring Security 6.x, the createSecurityExpressionRoot() method signature - * changed and MethodInvocation was removed. Custom expression methods defined in - * SecurityExpressionRoot are automatically available in SpEL expressions. - */ -public class CustomMethodSecurityExpressionHandler extends DefaultMethodSecurityExpressionHandler { - // No overrides needed in Spring Security 6.x - custom methods in - // SecurityExpressionRoot are automatically recognized -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java b/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java deleted file mode 100644 index bc8679d4..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/CustomSecurityExpressionRoot.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.util.Optional; - -/** - * Custom security expression root with additional methods - * Provides custom security expressions like isAdmin(), isUser(), etc. - * In Spring Security 6.x, we extend Spring's SecurityExpressionRoot and add custom methods - */ -public class CustomSecurityExpressionRoot extends org.springframework.security.access.expression.SecurityExpressionRoot { - - private final Authentication authentication; - - public CustomSecurityExpressionRoot(Authentication authentication) { - super(authentication); - this.authentication = authentication; - } - - /** - * Check if user has all of the specified roles - * Uses the inherited hasRole() method from Spring Security 6.x - * Note: hasAnyRole is final in Spring Security 6.x and cannot be overridden - */ - public boolean hasAllRoles(String... roles) { - for (String role : roles) { - if (!hasRole(role)) { - return false; - } - } - return true; - } - - public boolean isOwner() { - // Simplified for example: owner if authenticated principal is a Jwt - return authentication != null && authentication.getPrincipal() instanceof Jwt; - } - - public Optional getCurrentUserEmail() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("email")); - } - return Optional.empty(); - } - - public Optional getCurrentUserId() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("sub")); - } - return Optional.empty(); - } - - public Optional getCurrentUserName() { - if (authentication != null && authentication.getPrincipal() instanceof Jwt jwt) { - return Optional.ofNullable(jwt.getClaimAsString("preferred_username")); - } - return Optional.empty(); - } - - public boolean isAdmin() { - return hasRole("admin") || hasRole("super-admin"); - } - - public boolean isUser() { - return hasRole("user") || isAdmin(); - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java b/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java deleted file mode 100644 index a1f48b8c..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspect.java +++ /dev/null @@ -1,165 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.core.annotation.Order; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Component; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -/** - * Aspect to enforce OAuth2 flow requirements - * Intercepts methods annotated with @RequireFlow - */ -@Aspect -@Component -@Order(100) // Run after Spring Security's authorization -public class FlowEnforcementAspect { - - @Around("@annotation(requireFlow)") - public Object enforceFlowRequirement(ProceedingJoinPoint joinPoint, RequireFlow requireFlow) throws Throwable { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - - if (authentication == null) { - throw new InsufficientFlowException("Authentication required"); - } - - if (!(authentication.getPrincipal() instanceof Jwt)) { - throw new InsufficientFlowException("JWT token required"); - } - - Jwt jwt = (Jwt) authentication.getPrincipal(); - FlowValidator.ValidationResult result = validateFlowRequirements(requireFlow, jwt); - - if (!result.isValid()) { - return ResponseEntity.status(403).body( - new org.opendevstack.apiservice.core.dto.ApiResponse<>( - false, null, "Flow validation failed: " + result.getErrorMessage() - ) - ); - } - - return joinPoint.proceed(); - } - - private FlowValidator.ValidationResult validateFlowRequirements(RequireFlow requireFlow, Jwt jwt) { - FlowValidator.ValidationResult result = new FlowValidator.ValidationResult(); - - validateRequiredFlows(requireFlow, jwt, result); - validateActorRequirement(requireFlow, jwt, result); - validateDelegationDepth(requireFlow, jwt, result); - validateRequiredScopes(requireFlow, jwt, result); - - return result; - } - - private void validateRequiredFlows(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.value().length > 0) { - boolean hasRequiredFlow = validateFlows(requireFlow.value(), jwt); - if (!hasRequiredFlow) { - result.addError("Token does not match required flows: " + - String.join(", ", requireFlow.value())); - } - } - } - - private void validateActorRequirement(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.requireActor()) { - String actor = jwt.getClaimAsString("actor"); - if (actor == null || actor.isEmpty()) { - result.addError("Actor claim required (On-Behalf-Of flow)"); - } - } - } - - private void validateDelegationDepth(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.requireDelegationDepth() > 0) { - Number depth = jwt.getClaim("delegation_depth"); - int currentDepth = depth != null ? depth.intValue() : 0; - if (currentDepth < requireFlow.requireDelegationDepth()) { - result.addError("Insufficient delegation depth. Required: " + - requireFlow.requireDelegationDepth() + ", Actual: " + currentDepth); - } - } - } - - private void validateRequiredScopes(RequireFlow requireFlow, Jwt jwt, FlowValidator.ValidationResult result) { - if (requireFlow.scopes().length > 0) { - String scope = jwt.getClaimAsString("scope"); - if (scope == null || scope.isEmpty()) { - result.addError("Token has no scopes"); - } else { - validateEachScope(requireFlow.scopes(), scope.split(" "), result); - } - } - } - - private void validateEachScope(String[] requiredScopes, String[] tokenScopes, FlowValidator.ValidationResult result) { - for (String requiredScope : requiredScopes) { - if (!isScopePresent(requiredScope, tokenScopes)) { - result.addError("Missing required scope: " + requiredScope); - } - } - } - - private boolean isScopePresent(String requiredScope, String[] tokenScopes) { - for (String tokenScope : tokenScopes) { - if (requiredScope.equals(tokenScope)) { - return true; - } - } - return false; - } - - private boolean validateFlows(String[] requiredFlows, Jwt jwt) { - for (String requiredFlow : requiredFlows) { - if (isFlowValid(requiredFlow, jwt)) { - return true; - } - } - return false; - } - - private boolean isFlowValid(String requiredFlow, Jwt jwt) { - switch (requiredFlow) { - case "authorization-code": - return isAuthorizationCodeFlow(jwt); - case "client-credentials": - return isClientCredentialsFlow(jwt); - case "on-behalf-of": - return isOnBehalfOfFlow(jwt); - default: - return false; - } - } - - private boolean isAuthorizationCodeFlow(Jwt jwt) { - return "Bearer".equals(jwt.getClaimAsString("token_type")); - } - - private boolean isClientCredentialsFlow(Jwt jwt) { - String grantType = jwt.getClaimAsString("grant_type"); - return "client_credentials".equals(grantType); - } - - private boolean isOnBehalfOfFlow(Jwt jwt) { - String actor = jwt.getClaimAsString("actor"); - if (actor != null && !actor.isEmpty()) { - return true; - } - String scope = jwt.getClaimAsString("scope"); - return scope != null && (scope.contains("on-behalf-of") || scope.contains("delegated_access")); - } - - /** - * Exception thrown when flow requirements are not met - */ - public static class InsufficientFlowException extends RuntimeException { - public InsufficientFlowException(String message) { - super(message); - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java b/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java deleted file mode 100644 index f4c24aa2..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/FlowValidator.java +++ /dev/null @@ -1,228 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.config.FlowProperties; -import org.opendevstack.apiservice.core.config.FlowProperties.EndpointFlow; -import lombok.Getter; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.Map; - -/** - * Validates OAuth2 flows for endpoints - * Checks if authenticated token meets the required flow criteria - */ -@Component -public class FlowValidator { - - private final FlowProperties flowProperties; - - public FlowValidator(FlowProperties flowProperties) { - this.flowProperties = flowProperties; - } - - /** - * Validate if current token meets flow requirements for an endpoint - */ - public ValidationResult validateEndpointFlow(String endpointPattern) { - EndpointFlow endpointFlow = findEndpointFlow(endpointPattern); - if (endpointFlow == null) { - // No specific flow configuration, use global defaults - return ValidationResult.success(); - } - - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication == null) { - return ValidationResult.failure("No authentication"); - } - - ValidationResult result = new ValidationResult(); - - // Check if authentication is required - if (!endpointFlow.isRequireAuthentication()) { - return ValidationResult.success(); - } - - // Check if token is present - if (authentication.getPrincipal() instanceof Jwt jwt) { - // Validate required flows - if (endpointFlow.getFlows() != null && !endpointFlow.getFlows().isEmpty()) { - boolean hasRequiredFlow = validateFlows(endpointFlow.getFlows(), jwt); - if (!hasRequiredFlow) { - result.addError("Token does not match required flows: " + endpointFlow.getFlows()); - } - } - - // Validate actor requirement (for On-Behalf-Of) - if (endpointFlow.isRequireActor()) { - boolean hasActor = validateActorClaim(jwt); - if (!hasActor) { - result.addError("Token must have actor claim (On-Behalf-Of flow required)"); - } - } - - // Validate delegation depth - if (endpointFlow.getRequireDelegationDepth() > 0) { - int currentDepth = getDelegationDepth(jwt); - if (currentDepth < endpointFlow.getRequireDelegationDepth()) { - result.addError("Insufficient delegation depth. Required: " + - endpointFlow.getRequireDelegationDepth() + ", Actual: " + currentDepth); - } - } - - // Validate required scopes - if (endpointFlow.getRequiredScopes() != null && !endpointFlow.getRequiredScopes().isEmpty()) { - boolean hasRequiredScopes = validateScopes(endpointFlow.getRequiredScopes(), jwt); - if (!hasRequiredScopes) { - result.addError("Token missing required scopes: " + endpointFlow.getRequiredScopes()); - } - } - } - - return result; - } - - /** - * Find flow configuration for an endpoint - */ - private EndpointFlow findEndpointFlow(String endpointPattern) { - if (flowProperties.getApis() == null) { - return null; - } - - // Check each API's endpoints - for (Map.Entry apiEntry : flowProperties.getApis().entrySet()) { - FlowProperties.ApiFlows apiFlows = apiEntry.getValue(); - if (apiFlows.getEndpoints() != null) { - // Check if pattern matches - for (EndpointFlow endpointFlow : apiFlows.getEndpoints()) { - String pattern = endpointFlow.getPattern(); - if (pattern != null && matchesPattern(endpointPattern, pattern)) { - return endpointFlow; - } - } - } - } - - return null; - } - - /** - * Simple pattern matching (supports wildcards like /**) - */ - private boolean matchesPattern(String endpoint, String pattern) { - if (pattern.endsWith("/**")) { - String basePattern = pattern.substring(0, pattern.length() - 3); - return endpoint.startsWith(basePattern); - } - return endpoint.equals(pattern); - } - - /** - * Validate if token has one of the required flows - */ - private boolean validateFlows(List requiredFlows, Jwt jwt) { - // Get flow from token claims or scopes - String scope = jwt.getClaimAsString("scope"); - String grantType = jwt.getClaimAsString("grant_type"); - - for (String requiredFlow : requiredFlows) { - // Check if flow matches - if ("authorization-code".equals(requiredFlow)) { - if ("Bearer".equals(jwt.getClaimAsString("token_type"))) { - return true; // Authorization code tokens appear as Bearer tokens - } - } else if ("client-credentials".equals(requiredFlow)) { - if ("client_credentials".equals(grantType)) { - return true; - } - } else if ("on-behalf-of".equals(requiredFlow)) { - // Check for actor claim (indicates OBO flow) - String actor = jwt.getClaimAsString("actor"); - if (actor != null && !actor.isEmpty()) { - return true; - } - // Or check scope - if (scope != null && (scope.contains("on-behalf-of") || scope.contains("delegated_access"))) { - return true; - } - } - } - - return false; - } - - /** - * Validate actor claim for On-Behalf-Of flow - */ - private boolean validateActorClaim(Jwt jwt) { - String actor = jwt.getClaimAsString("actor"); - return actor != null && !actor.isEmpty(); - } - - /** - * Get delegation depth from token - */ - private int getDelegationDepth(Jwt jwt) { - Number depth = jwt.getClaim("delegation_depth"); - return depth != null ? depth.intValue() : 0; - } - - /** - * Validate required scopes - */ - private boolean validateScopes(List requiredScopes, Jwt jwt) { - String scope = jwt.getClaimAsString("scope"); - if (scope == null || scope.isEmpty()) { - return false; - } - - String[] tokenScopes = scope.split(" "); - for (String requiredScope : requiredScopes) { - boolean found = false; - for (String tokenScope : tokenScopes) { - if (requiredScope.equals(tokenScope)) { - found = true; - break; - } - } - if (!found) { - return false; - } - } - - return true; - } - - /** - * Validation result for flow checks - */ - @Getter - public static class ValidationResult { - private boolean valid = true; - private java.util.List errors = new java.util.ArrayList<>(); - - public static ValidationResult success() { - return new ValidationResult(); - } - - public static ValidationResult failure(String error) { - ValidationResult result = new ValidationResult(); - result.valid = false; - result.errors.add(error); - return result; - } - - public void addError(String error) { - this.valid = false; - this.errors.add(error); - } - - public String getErrorMessage() { - return String.join("; ", errors); - } - } -} diff --git a/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java b/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java deleted file mode 100644 index f113e79e..00000000 --- a/core/src/main/java/org/opendevstack/apiservice/core/security/RequireFlow.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Annotation to specify required OAuth2 flow for an endpoint - * Can be used on classes or methods - */ -@Target({ElementType.METHOD, ElementType.TYPE}) -@Retention(RetentionPolicy.RUNTIME) -public @interface RequireFlow { - - /** - * Required OAuth2 flow(s) - * Values: authorization-code, client-credentials, on-behalf-of - */ - String[] value() default {}; - - /** - * Whether actor claim is required (for On-Behalf-Of flow) - */ - boolean requireActor() default false; - - /** - * Required delegation depth - */ - int requireDelegationDepth() default 0; - - /** - * Required scopes - */ - String[] scopes() default {}; - - /** - * Error message if validation fails - */ - String message() default "Insufficient OAuth2 flow permissions"; -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java deleted file mode 100644 index dccedafe..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/CustomRoleConverterTest.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; - -class CustomRoleConverterTest { - - private CustomRoleConverter converter; - - @BeforeEach - void setUp() { - converter = new CustomRoleConverter(); - } - - @Test - void testConvertWithRealmRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin", "user")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - assertTrue(containsAuthority(authorities, "ROLE_user")); - } - - @Test - void testConvertWithResourceRoles() { - // Given - Map clientRoles = new HashMap<>(); - clientRoles.put("roles", Arrays.asList("manager", "viewer")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientRoles); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_manager")); - assertTrue(containsAuthority(authorities, "ROLE_viewer")); - } - - @Test - void testConvertWithBothRealmAndResourceRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin")); - - Map clientRoles = new HashMap<>(); - clientRoles.put("roles", Arrays.asList("manager")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientRoles); - - Map claims = new HashMap<>(); - claims.put("realm_access", realmAccess); - claims.put("resource_access", resourceAccess); - - Jwt jwt = createJwtWithClaims(claims); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(2, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - assertTrue(containsAuthority(authorities, "ROLE_manager")); - } - - @Test - void testConvertWithMultipleResourceClients() { - // Given - Map client1Roles = new HashMap<>(); - client1Roles.put("roles", Arrays.asList("role1", "role2")); - - Map client2Roles = new HashMap<>(); - client2Roles.put("roles", Arrays.asList("role3")); - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("client1", client1Roles); - resourceAccess.put("client2", client2Roles); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(3, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_role1")); - assertTrue(containsAuthority(authorities, "ROLE_role2")); - assertTrue(containsAuthority(authorities, "ROLE_role3")); - } - - @Test - void testConvertWithNullRealmAccess() { - // Given - Jwt jwt = createJwtWithClaims(Map.of()); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithEmptyRealmRoles() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Collections.emptyList()); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithNullResourceAccess() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("admin")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(1, authorities.size()); - assertTrue(containsAuthority(authorities, "ROLE_admin")); - } - - @Test - void testConvertWithRealmAccessWithoutRoles() { - // Given - Map realmAccess = new HashMap<>(); - // No "roles" key - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithMalformedResourceAccess() { - // Given - resource_access without proper structure - Map resourceAccess = new HashMap<>(); - resourceAccess.put("client1", "not-a-map"); // Should be a Map - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithResourceAccessClientWithoutRoles() { - // Given - Map clientConfig = new HashMap<>(); - clientConfig.put("other-field", "value"); - // No "roles" key - - Map resourceAccess = new HashMap<>(); - resourceAccess.put("my-client", clientConfig); - - Jwt jwt = createJwtWithClaims(Map.of("resource_access", resourceAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testConvertWithNullRolesInRealmAccess() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", null); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertTrue(authorities.isEmpty()); - } - - @Test - void testRolePrefixIsAdded() { - // Given - Map realmAccess = new HashMap<>(); - realmAccess.put("roles", Arrays.asList("test-role")); - - Jwt jwt = createJwtWithClaims(Map.of("realm_access", realmAccess)); - - // When - Collection authorities = converter.convert(jwt); - - // Then - assertNotNull(authorities); - assertEquals(1, authorities.size()); - GrantedAuthority authority = authorities.iterator().next(); - assertTrue(authority.getAuthority().startsWith("ROLE_")); - assertEquals("ROLE_test-role", authority.getAuthority()); - } - - // Helper methods - - private Jwt createJwtWithClaims(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private boolean containsAuthority(Collection authorities, String authority) { - return authorities.stream() - .anyMatch(a -> a.getAuthority().equals(authority)); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java deleted file mode 100644 index 70b3b0e3..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/FlowPropertiesTest.java +++ /dev/null @@ -1,366 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class FlowPropertiesTest { - - private FlowProperties flowProperties; - - @BeforeEach - void setUp() { - flowProperties = new FlowProperties(); - } - - @Test - void testDefaultGlobalProperties() { - // When - FlowProperties.Global global = flowProperties.getGlobal(); - - // Then - assertNotNull(global); - assertEquals("client-credentials", global.getDefaultFlow()); - } - - @Test - void testSetAndGetGlobal() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - global.setDefaultFlow("authorization-code"); - global.setEnabledFlows(Arrays.asList("authorization-code", "client-credentials")); - - // When - flowProperties.setGlobal(global); - - // Then - assertEquals("authorization-code", flowProperties.getGlobal().getDefaultFlow()); - assertEquals(2, flowProperties.getGlobal().getEnabledFlows().size()); - assertTrue(flowProperties.getGlobal().getEnabledFlows().contains("authorization-code")); - } - - @Test - void testSetAndGetApis() { - // Given - Map apis = new HashMap<>(); - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setDefaultFlow("on-behalf-of"); - apis.put("test-api", apiFlows); - - // When - flowProperties.setApis(apis); - - // Then - assertNotNull(flowProperties.getApis()); - assertEquals(1, flowProperties.getApis().size()); - assertTrue(flowProperties.getApis().containsKey("test-api")); - assertEquals("on-behalf-of", flowProperties.getApis().get("test-api").getDefaultFlow()); - } - - @Test - void testGlobalEnabledFlows() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - List flows = Arrays.asList("authorization-code", "client-credentials", "on-behalf-of"); - - // When - global.setEnabledFlows(flows); - - // Then - assertEquals(3, global.getEnabledFlows().size()); - assertTrue(global.getEnabledFlows().contains("authorization-code")); - assertTrue(global.getEnabledFlows().contains("client-credentials")); - assertTrue(global.getEnabledFlows().contains("on-behalf-of")); - } - - @Test - void testGlobalDefaultFlow() { - // Given - FlowProperties.Global global = new FlowProperties.Global(); - - // When - global.setDefaultFlow("authorization-code"); - - // Then - assertEquals("authorization-code", global.getDefaultFlow()); - } - - @Test - void testApiFlowsDefaultFlow() { - // Given - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - - // When - apiFlows.setDefaultFlow("client-credentials"); - - // Then - assertEquals("client-credentials", apiFlows.getDefaultFlow()); - } - - @Test - void testApiFlowsEndpoints() { - // Given - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - FlowProperties.EndpointFlow endpoint1 = new FlowProperties.EndpointFlow(); - endpoint1.setPattern("/api/test"); - - FlowProperties.EndpointFlow endpoint2 = new FlowProperties.EndpointFlow(); - endpoint2.setPattern("/api/admin/**"); - - List endpoints = Arrays.asList(endpoint1, endpoint2); - - // When - apiFlows.setEndpoints(endpoints); - - // Then - assertNotNull(apiFlows.getEndpoints()); - assertEquals(2, apiFlows.getEndpoints().size()); - } - - @Test - void testEndpointFlowPattern() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPattern("/api/users/**"); - - // Then - assertEquals("/api/users/**", endpoint.getPattern()); - } - - @Test - void testEndpointFlowFlows() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List flows = Arrays.asList("authorization-code", "on-behalf-of"); - - // When - endpoint.setFlows(flows); - - // Then - assertEquals(2, endpoint.getFlows().size()); - assertTrue(endpoint.getFlows().contains("authorization-code")); - assertTrue(endpoint.getFlows().contains("on-behalf-of")); - } - - @Test - void testEndpointFlowRoles() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List roles = Arrays.asList("admin", "user"); - - // When - endpoint.setRoles(roles); - - // Then - assertEquals(2, endpoint.getRoles().size()); - assertTrue(endpoint.getRoles().contains("admin")); - assertTrue(endpoint.getRoles().contains("user")); - } - - @Test - void testEndpointFlowPermitAll() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPermitAll(true); - - // Then - assertTrue(endpoint.isPermitAll()); - } - - @Test - void testEndpointFlowPermitAllDefaultFalse() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertFalse(endpoint.isPermitAll()); - } - - @Test - void testEndpointFlowRequireActor() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireActor(true); - - // Then - assertTrue(endpoint.isRequireActor()); - } - - @Test - void testEndpointFlowRequireActorDefaultFalse() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertFalse(endpoint.isRequireActor()); - } - - @Test - void testEndpointFlowRequireAuthentication() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireAuthentication(false); - - // Then - assertFalse(endpoint.isRequireAuthentication()); - } - - @Test - void testEndpointFlowRequireAuthenticationDefaultTrue() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertTrue(endpoint.isRequireAuthentication()); - } - - @Test - void testEndpointFlowRequireDelegationDepth() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setRequireDelegationDepth(3); - - // Then - assertEquals(3, endpoint.getRequireDelegationDepth()); - } - - @Test - void testEndpointFlowRequireDelegationDepthDefaultZero() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // Then - assertEquals(0, endpoint.getRequireDelegationDepth()); - } - - @Test - void testEndpointFlowRequiredScopes() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - List scopes = Arrays.asList("read:data", "write:data"); - - // When - endpoint.setRequiredScopes(scopes); - - // Then - assertEquals(2, endpoint.getRequiredScopes().size()); - assertTrue(endpoint.getRequiredScopes().contains("read:data")); - assertTrue(endpoint.getRequiredScopes().contains("write:data")); - } - - @Test - void testCompleteConfigurationStructure() { - // Given - FlowProperties config = new FlowProperties(); - - // Global configuration - FlowProperties.Global global = new FlowProperties.Global(); - global.setDefaultFlow("client-credentials"); - global.setEnabledFlows(Arrays.asList("authorization-code", "client-credentials")); - config.setGlobal(global); - - // API configuration - FlowProperties.ApiFlows userApi = new FlowProperties.ApiFlows(); - userApi.setDefaultFlow("authorization-code"); - - FlowProperties.EndpointFlow userEndpoint = new FlowProperties.EndpointFlow(); - userEndpoint.setPattern("/api/users/**"); - userEndpoint.setFlows(Arrays.asList("authorization-code")); - userEndpoint.setRoles(Arrays.asList("user", "admin")); - userEndpoint.setRequireAuthentication(true); - - userApi.setEndpoints(Arrays.asList(userEndpoint)); - - Map apis = new HashMap<>(); - apis.put("user-api", userApi); - config.setApis(apis); - - // Then - assertNotNull(config.getGlobal()); - assertEquals("client-credentials", config.getGlobal().getDefaultFlow()); - assertEquals(2, config.getGlobal().getEnabledFlows().size()); - - assertNotNull(config.getApis()); - assertEquals(1, config.getApis().size()); - - FlowProperties.ApiFlows retrievedApi = config.getApis().get("user-api"); - assertNotNull(retrievedApi); - assertEquals("authorization-code", retrievedApi.getDefaultFlow()); - assertEquals(1, retrievedApi.getEndpoints().size()); - - FlowProperties.EndpointFlow retrievedEndpoint = retrievedApi.getEndpoints().get(0); - assertEquals("/api/users/**", retrievedEndpoint.getPattern()); - assertEquals(1, retrievedEndpoint.getFlows().size()); - assertEquals(2, retrievedEndpoint.getRoles().size()); - assertTrue(retrievedEndpoint.isRequireAuthentication()); - } - - @Test - void testMultipleApisConfiguration() { - // Given - FlowProperties config = new FlowProperties(); - - FlowProperties.ApiFlows api1 = new FlowProperties.ApiFlows(); - api1.setDefaultFlow("authorization-code"); - - FlowProperties.ApiFlows api2 = new FlowProperties.ApiFlows(); - api2.setDefaultFlow("client-credentials"); - - Map apis = new HashMap<>(); - apis.put("api1", api1); - apis.put("api2", api2); - - // When - config.setApis(apis); - - // Then - assertEquals(2, config.getApis().size()); - assertEquals("authorization-code", config.getApis().get("api1").getDefaultFlow()); - assertEquals("client-credentials", config.getApis().get("api2").getDefaultFlow()); - } - - @Test - void testEndpointFlowWithAllProperties() { - // Given - FlowProperties.EndpointFlow endpoint = new FlowProperties.EndpointFlow(); - - // When - endpoint.setPattern("/api/secure/**"); - endpoint.setFlows(Arrays.asList("on-behalf-of")); - endpoint.setRoles(Arrays.asList("admin")); - endpoint.setPermitAll(false); - endpoint.setRequireActor(true); - endpoint.setRequireAuthentication(true); - endpoint.setRequireDelegationDepth(2); - endpoint.setRequiredScopes(Arrays.asList("admin:all")); - - // Then - assertEquals("/api/secure/**", endpoint.getPattern()); - assertEquals(1, endpoint.getFlows().size()); - assertEquals("on-behalf-of", endpoint.getFlows().get(0)); - assertEquals(1, endpoint.getRoles().size()); - assertEquals("admin", endpoint.getRoles().get(0)); - assertFalse(endpoint.isPermitAll()); - assertTrue(endpoint.isRequireActor()); - assertTrue(endpoint.isRequireAuthentication()); - assertEquals(2, endpoint.getRequireDelegationDepth()); - assertEquals(1, endpoint.getRequiredScopes().size()); - assertEquals("admin:all", endpoint.getRequiredScopes().get(0)); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java b/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java deleted file mode 100644 index 88634c27..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/config/SecurityConfigTest.java +++ /dev/null @@ -1,67 +0,0 @@ -package org.opendevstack.apiservice.core.config; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; -import org.springframework.security.web.SecurityFilterChain; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class SecurityConfigTest { - private SecurityProperties securityProperties; - private FlowProperties flowProperties; - private SecurityConfig securityConfig; - - @BeforeEach - void setUp() { - securityProperties = mock(SecurityProperties.class); - flowProperties = mock(FlowProperties.class); - securityConfig = new SecurityConfig(securityProperties, flowProperties); - } - - @Test - void testJwtAuthenticationConverterBean() { - JwtAuthenticationConverter converter = securityConfig.jwtAuthenticationConverter(); - assertNotNull(converter); - } - - @Test - void testCustomRoleConverterBean() { - CustomRoleConverter converter = securityConfig.customRoleConverter(); - assertNotNull(converter); - } - - @Test - void testJwtDecoderLenient() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(false); - JwtDecoder decoder = securityConfig.jwtDecoder(); - assertNotNull(decoder); - } - - @Test - void testJwtDecoderValidating() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(true); - when(securityProperties.getJwkSetUri()).thenReturn("http://localhost/jwk"); - JwtDecoder decoder = securityConfig.jwtDecoder(); - assertNotNull(decoder); - } - - @Test - void testJwtDecoderThrowsIfNoJwkSetUri() { - when(securityProperties.isJwtValidationEnabled()).thenReturn(true); - when(securityProperties.getJwkSetUri()).thenReturn(""); - Exception exception = assertThrows(IllegalStateException.class, () -> securityConfig.jwtDecoder()); - assertTrue(exception.getMessage().contains("no JWK Set URI")); - } - - @Test - void testSecurityFilterChainBean() throws Exception { - HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - // Just verify bean creation does not throw - SecurityFilterChain chain = securityConfig.securityFilterChain(http); - assertNotNull(chain); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java deleted file mode 100644 index a46475eb..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowEnforcementAspectTest.java +++ /dev/null @@ -1,370 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.dto.ApiResponse; -import org.aspectj.lang.ProceedingJoinPoint; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Map; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -class FlowEnforcementAspectTest { - - @Mock - private FlowValidator flowValidator; - - @Mock - private ProceedingJoinPoint joinPoint; - - @Mock - private SecurityContext securityContext; - - @Mock - private Authentication authentication; - - private FlowEnforcementAspect aspect; - - @BeforeEach - void setUp() { - aspect = new FlowEnforcementAspect(); - SecurityContextHolder.setContext(securityContext); - } - - // Parameterized test for successful flow validations - static Stream successfulFlowScenarios() { - return Stream.of( - Arguments.of("client-credentials", Map.of("grant_type", "client_credentials"), "client-credentials flow"), - Arguments.of("authorization-code", Map.of("token_type", "Bearer"), "authorization-code flow"), - Arguments.of("on-behalf-of", Map.of("actor", "service-account"), "on-behalf-of with actor"), - Arguments.of("on-behalf-of", Map.of("scope", "on-behalf-of delegated_access"), "on-behalf-of with scope") - ); - } - - @ParameterizedTest(name = "{2}") - @MethodSource("successfulFlowScenarios") - void testSuccessfulFlowValidation(String flowType, Map claims, String description) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{flowType}, false, 0, new String[]{}); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testEnforceFlowRequirementWithNoAuthentication() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - when(securityContext.getAuthentication()).thenReturn(null); - - // When & Then - assertThrows(FlowEnforcementAspect.InsufficientFlowException.class, - () -> aspect.enforceFlowRequirement(joinPoint, requireFlow)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testEnforceFlowRequirementWithNonJwtAuthentication() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn("not-a-jwt"); - - // When & Then - assertThrows(FlowEnforcementAspect.InsufficientFlowException.class, - () -> aspect.enforceFlowRequirement(joinPoint, requireFlow)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testEnforceFlowRequirementFailureReturns403() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{"client-credentials"}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of("grant_type", "authorization_code")); // Wrong flow - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertNotNull(result); - assertTrue(result instanceof ResponseEntity); - ResponseEntity response = (ResponseEntity) result; - assertEquals(403, response.getStatusCode().value()); - - ApiResponse apiResponse = (ApiResponse) response.getBody(); - assertNotNull(apiResponse); - assertFalse(apiResponse.isSuccess()); - assertTrue(apiResponse.getMessage().contains("Flow validation failed")); - - verify(joinPoint, never()).proceed(); - } - - // Parameterized test for successful actor validations - static Stream successfulActorScenarios() { - return Stream.of( - Arguments.of(Map.of("actor", "service-principal"), true, 0, "actor present"), - Arguments.of(Map.of("delegation_depth", 3), false, 2, "delegation depth sufficient") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("successfulActorScenarios") - void testSuccessfulActorValidation(Map claims, boolean requireActor, int delegationDepth, String description) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, requireActor, delegationDepth, new String[]{}); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - // Parameterized test for 403 failure scenarios - static Stream failureScenarios() { - return Stream.of( - Arguments.of(new String[]{}, true, 0, new String[]{}, Map.of(), "Actor claim required"), - Arguments.of(new String[]{}, false, 2, new String[]{}, Map.of("delegation_depth", 1), "Insufficient delegation depth"), - Arguments.of(new String[]{}, false, 0, new String[]{"read:data", "write:data"}, Map.of("scope", "read:data"), "Missing required scope"), - Arguments.of(new String[]{}, false, 0, new String[]{"read:data"}, Map.of(), "Token has no scopes") - ); - } - - @ParameterizedTest(name = "{5}") - @MethodSource("failureScenarios") - void testFlowValidationFailures(String[] flows, boolean requireActor, int delegationDepth, - String[] scopes, Map claims, String expectedError) throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(flows, requireActor, delegationDepth, scopes); - Jwt jwt = createJwt(claims); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertTrue(result instanceof ResponseEntity); - ResponseEntity response = (ResponseEntity) result; - assertEquals(403, response.getStatusCode().value()); - - ApiResponse apiResponse = (ApiResponse) response.getBody(); - assertTrue(apiResponse.getMessage().contains(expectedError)); - - verify(joinPoint, never()).proceed(); - } - - @Test - void testRequiredScopesSuccess() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, false, 0, new String[]{"read:data", "write:data"}); - Jwt jwt = createJwt(Map.of("scope", "read:data write:data admin:all")); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testMultipleFlowsOneMatches() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow( - new String[]{"authorization-code", "client-credentials"}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of("grant_type", "client_credentials")); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testEmptyFlowRequirementAllowsAnyFlow() throws Throwable { - // Given - RequireFlow requireFlow = createRequireFlow(new String[]{}, false, 0, new String[]{}); - Jwt jwt = createJwt(Map.of()); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - when(joinPoint.proceed()).thenReturn("success"); - - // When - Object result = aspect.enforceFlowRequirement(joinPoint, requireFlow); - - // Then - assertEquals("success", result); - verify(joinPoint).proceed(); - } - - @Test - void testInsufficientFlowExceptionMessage() { - // When - FlowEnforcementAspect.InsufficientFlowException exception = - new FlowEnforcementAspect.InsufficientFlowException("Custom error message"); - - // Then - assertEquals("Custom error message", exception.getMessage()); - } - - // Helper methods - - private Jwt createJwt(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private RequireFlow createRequireFlow(String[] flows, boolean requireActor, - int requireDelegationDepth, String[] scopes) { - return new RequireFlow() { - @Override - public Class annotationType() { - return RequireFlow.class; - } - - @Override - public String[] value() { - return flows; - } - - @Override - public boolean requireActor() { - return requireActor; - } - - @Override - public int requireDelegationDepth() { - return requireDelegationDepth; - } - - @Override - public String[] scopes() { - return scopes; - } - - @Override - public String message() { - return "Insufficient OAuth2 flow permissions"; - } - }; - } - @Test - void testValidateFlowsWithSingleValidFlow() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("grant_type", "client_credentials")); - String[] requiredFlows = {"client-credentials"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertTrue(result); - } - - @Test - void testValidateFlowsWithMultipleFlowsOneValid() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("token_type", "Bearer")); - String[] requiredFlows = {"client-credentials", "authorization-code"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertTrue(result); - } - - @Test - void testValidateFlowsWithNoValidFlow() { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(Map.of("grant_type", "other")); - String[] requiredFlows = {"client-credentials"}; - boolean result = invokeValidateFlows(testAspect, requiredFlows, jwt); - assertFalse(result); - } - - static Stream isFlowValidScenarios() { - return Stream.of( - Arguments.of("authorization-code", Map.of("token_type", "Bearer"), true, "authorization-code with Bearer token"), - Arguments.of("client-credentials", Map.of("grant_type", "client_credentials"), true, "client-credentials flow"), - Arguments.of("on-behalf-of", Map.of("actor", "service-account"), true, "on-behalf-of with actor"), - Arguments.of("on-behalf-of", Map.of("scope", "on-behalf-of delegated_access"), true, "on-behalf-of with scope"), - Arguments.of("unknown-flow", Map.of(), false, "unknown flow type") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("isFlowValidScenarios") - void testIsFlowValid(String flow, Map claims, boolean expected, String description) { - FlowEnforcementAspect testAspect = new FlowEnforcementAspect(); - Jwt jwt = createJwt(claims); - boolean result = invokeIsFlowValid(testAspect, flow, jwt); - assertEquals(expected, result); - } - - // Reflection helpers to access private methods - private boolean invokeValidateFlows(FlowEnforcementAspect aspect, String[] flows, Jwt jwt) { - try { - java.lang.reflect.Method m = FlowEnforcementAspect.class.getDeclaredMethod("validateFlows", String[].class, Jwt.class); - m.setAccessible(true); - return (boolean) m.invoke(aspect, flows, jwt); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private boolean invokeIsFlowValid(FlowEnforcementAspect aspect, String flow, Jwt jwt) { - try { - java.lang.reflect.Method m = FlowEnforcementAspect.class.getDeclaredMethod("isFlowValid", String.class, Jwt.class); - m.setAccessible(true); - return (boolean) m.invoke(aspect, flow, jwt); - } catch (Exception e) { - throw new RuntimeException(e); - } - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java deleted file mode 100644 index f07e3ba5..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/FlowValidatorTest.java +++ /dev/null @@ -1,345 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.opendevstack.apiservice.core.config.FlowProperties; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.*; -import java.util.stream.Stream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class FlowValidatorTest { - - private FlowValidator flowValidator; - private FlowProperties flowProperties; - - @BeforeEach - void setUp() { - flowProperties = new FlowProperties(); - flowValidator = new FlowValidator(flowProperties); - - // Clear security context before each test - SecurityContextHolder.clearContext(); - } - - @Test - void testValidateEndpointFlowWithNoConfiguration() { - // Given - no flow configuration - setupAuthentication(createJwt(Map.of())); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/test"); - - // Then - assertTrue(result.isValid()); - assertTrue(result.getErrors().isEmpty()); - } - - @Test - void testValidateEndpointFlowWithNoAuthenticationAndConfiguration() { - // Given - no authentication but with endpoint configuration requiring it - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/test"); - endpointFlow.setRequireAuthentication(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - SecurityContextHolder.clearContext(); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/test"); - - // Then - assertFalse(result.isValid()); - assertEquals("No authentication", result.getErrorMessage()); - } - - @Test - void testValidateEndpointFlowWithAuthenticationNotRequired() { - // Given - endpoint that doesn't require authentication - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/public/**"); - endpointFlow.setRequireAuthentication(false); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - setupAuthentication(createJwt(Map.of())); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/public/test"); - - // Then - assertTrue(result.isValid()); - } - - // Parameterized test for successful flow validations - static Stream successfulFlowScenarios() { - return Stream.of( - Arguments.of("authorization-code", "/api/user/**", "/api/user/profile", Map.of("token_type", "Bearer")), - Arguments.of("client-credentials", "/api/service/**", "/api/service/data", Map.of("grant_type", "client_credentials")), - Arguments.of("on-behalf-of", "/api/delegated/**", "/api/delegated/action", Map.of("actor", "service-account")) - ); - } - - @ParameterizedTest(name = "{0} flow validation") - @MethodSource("successfulFlowScenarios") - void testSuccessfulFlowValidation(String flowType, String pattern, String endpoint, Map claims) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern(pattern); - endpointFlow.setFlows(Arrays.asList(flowType)); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(claims); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow(endpoint); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testValidateFlowFailureWhenFlowDoesNotMatch() { - // Given - endpoint requiring client-credentials but token is authorization-code - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/service/**"); - endpointFlow.setFlows(Arrays.asList("client-credentials")); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("token_type", "Bearer")); // Not client-credentials - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/service/data"); - - // Then - assertFalse(result.isValid()); - assertTrue(result.getErrorMessage().contains("does not match required flows")); - } - - // Parameterized test for actor and delegation depth validations - static Stream actorAndDepthScenarios() { - return Stream.of( - Arguments.of(true, 0, Map.of("actor", "service-principal"), "/api/obo/**", "/api/obo/action", true, "actor present"), - Arguments.of(true, 0, Map.of(), "/api/obo/**", "/api/obo/action", false, "actor missing"), - Arguments.of(false, 2, Map.of("delegation_depth", 3), "/api/deep/**", "/api/deep/action", true, "delegation depth sufficient"), - Arguments.of(false, 2, Map.of("delegation_depth", 1), "/api/deep/**", "/api/deep/action", false, "delegation depth insufficient") - ); - } - - @ParameterizedTest(name = "{6}") - @MethodSource("actorAndDepthScenarios") - void testActorAndDelegationDepthValidation(boolean requireActor, int delegationDepth, Map claims, - String pattern, String endpoint, boolean shouldBeValid, String description) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern(pattern); - endpointFlow.setRequireActor(requireActor); - endpointFlow.setRequireDelegationDepth(delegationDepth); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(claims); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow(endpoint); - - // Then - assertEquals(shouldBeValid, result.isValid()); - if (!shouldBeValid) { - assertFalse(result.getErrorMessage().isEmpty()); - } - } - - // Parameterized test for scope validations - static Stream scopeValidationScenarios() { - return Stream.of( - Arguments.of(Arrays.asList("read:data", "write:data"), "read:data write:data admin:all", true, "all scopes present"), - Arguments.of(Arrays.asList("read:data", "write:data"), "read:data", false, "missing write:data scope") - ); - } - - @ParameterizedTest(name = "{3}") - @MethodSource("scopeValidationScenarios") - void testScopeValidation(List requiredScopes, String tokenScope, boolean shouldBeValid, String description) { - // Given - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/scoped/**"); - endpointFlow.setRequiredScopes(requiredScopes); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("scope", tokenScope)); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/scoped/action"); - - // Then - assertEquals(shouldBeValid, result.isValid()); - if (!shouldBeValid) { - assertTrue(result.getErrorMessage().contains("missing required scopes")); - } - } - - @Test - void testPatternMatchingExact() { - // Given - exact pattern match - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/exact"); - endpointFlow.setRequireActor(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("actor", "service")); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/exact"); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testPatternMatchingWildcard() { - // Given - wildcard pattern match - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/wildcard/**"); - endpointFlow.setRequireActor(true); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("actor", "service")); - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/wildcard/sub/path"); - - // Then - assertTrue(result.isValid()); - } - - @Test - void testValidationResultSuccess() { - // When - FlowValidator.ValidationResult result = FlowValidator.ValidationResult.success(); - - // Then - assertTrue(result.isValid()); - assertTrue(result.getErrors().isEmpty()); - } - - @Test - void testValidationResultFailure() { - // When - FlowValidator.ValidationResult result = FlowValidator.ValidationResult.failure("Error occurred"); - - // Then - assertFalse(result.isValid()); - assertEquals(1, result.getErrors().size()); - assertEquals("Error occurred", result.getErrorMessage()); - } - - @Test - void testValidationResultAddError() { - // Given - FlowValidator.ValidationResult result = new FlowValidator.ValidationResult(); - - // When - result.addError("First error"); - result.addError("Second error"); - - // Then - assertFalse(result.isValid()); - assertEquals(2, result.getErrors().size()); - assertTrue(result.getErrorMessage().contains("First error")); - assertTrue(result.getErrorMessage().contains("Second error")); - } - - @Test - void testMultipleValidationErrors() { - // Given - endpoint with multiple requirements - FlowProperties.EndpointFlow endpointFlow = new FlowProperties.EndpointFlow(); - endpointFlow.setPattern("/api/strict/**"); - endpointFlow.setFlows(Arrays.asList("on-behalf-of")); - endpointFlow.setRequireActor(true); - endpointFlow.setRequiredScopes(Arrays.asList("admin:all")); - - FlowProperties.ApiFlows apiFlows = new FlowProperties.ApiFlows(); - apiFlows.setEndpoints(Arrays.asList(endpointFlow)); - - flowProperties.setApis(Map.of("test-api", apiFlows)); - - Jwt jwt = createJwt(Map.of("scope", "read:data")); // Missing actor and wrong flow - setupAuthentication(jwt); - - // When - FlowValidator.ValidationResult result = flowValidator.validateEndpointFlow("/api/strict/action"); - - // Then - assertFalse(result.isValid()); - assertTrue(result.getErrors().size() >= 2); - } - - // Helper methods - - private Jwt createJwt(Map claims) { - return Jwt.withTokenValue("token") - .header("alg", "none") - .subject("test-user") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)) - .claims(c -> c.putAll(claims)) - .build(); - } - - private void setupAuthentication(Jwt jwt) { - SecurityContext securityContext = mock(SecurityContext.class); - Authentication authentication = mock(Authentication.class); - - when(securityContext.getAuthentication()).thenReturn(authentication); - when(authentication.getPrincipal()).thenReturn(jwt); - - SecurityContextHolder.setContext(securityContext); - } -} diff --git a/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java b/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java deleted file mode 100644 index 87607093..00000000 --- a/core/src/test/java/org/opendevstack/apiservice/core/security/SecurityExpressionRootTest.java +++ /dev/null @@ -1,327 +0,0 @@ -package org.opendevstack.apiservice.core.security; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.security.authentication.TestingAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; - -import java.time.Instant; -import java.util.Arrays; -import java.util.Map; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - -class SecurityExpressionRootTest { - - private CustomSecurityExpressionRoot expressionRoot; - private Authentication authentication; - - @BeforeEach - void setUp() { - // Default setup with basic authentication - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList( - new SimpleGrantedAuthority("ROLE_user"), - new SimpleGrantedAuthority("ROLE_admin") - )); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - } - - @Test - void testHasAllRolesWithAllRolesPresent() { - // Given - authentication has both ROLE_user and ROLE_admin - - // When - boolean result = expressionRoot.hasAllRoles("user", "admin"); - - // Then - assertTrue(result); - } - - @Test - void testHasAllRolesWithSomeRolesMissing() { - // Given - authentication has ROLE_user and ROLE_admin but not ROLE_super-admin - - // When - boolean result = expressionRoot.hasAllRoles("user", "super-admin"); - - // Then - assertFalse(result); - } - - @Test - void testHasAllRolesWithSingleRole() { - // Given - authentication has ROLE_user - - // When - boolean result = expressionRoot.hasAllRoles("user"); - - // Then - assertTrue(result); - } - - @Test - void testHasAllRolesWithNoRoles() { - // Given - authentication with no roles - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.hasAllRoles("user"); - - // Then - assertFalse(result); - } - - @Test - void testIsAdminWithAdminRole() { - // Given - authentication has ROLE_admin - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertTrue(result); - } - - @Test - void testIsAdminWithSuperAdminRole() { - // Given - authentication has ROLE_super-admin - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_super-admin"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertTrue(result); - } - - @Test - void testIsAdminWithoutAdminRole() { - // Given - authentication without admin role - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_user"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isAdmin(); - - // Then - assertFalse(result); - } - - @Test - void testIsUserWithUserRole() { - // Given - authentication has ROLE_user - authentication = new TestingAuthenticationToken("user", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_user"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertTrue(result); - } - - @Test - void testIsUserWithAdminRole() { - // Given - authentication has ROLE_admin (admin is also a user) - authentication = new TestingAuthenticationToken("admin", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_admin"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertTrue(result); - } - - @Test - void testIsUserWithoutUserOrAdminRole() { - // Given - authentication without user or admin role - authentication = new TestingAuthenticationToken("guest", "password", - Arrays.asList(new SimpleGrantedAuthority("ROLE_guest"))); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isUser(); - - // Then - assertFalse(result); - } - - @Test - void testGetCurrentUserEmailFromJwt() { - // Given - JWT with email claim - Jwt jwt = createJwtWithClaims(Map.of("email", "test@example.com")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertTrue(email.isPresent()); - assertEquals("test@example.com", email.get()); - } - - @Test - void testGetCurrentUserEmailWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - @Test - void testGetCurrentUserEmailWhenNullAuthentication() { - // Given - null authentication - expressionRoot = new CustomSecurityExpressionRoot(null); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - @Test - void testGetCurrentUserIdFromJwt() { - // Given - JWT with subject - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional userId = expressionRoot.getCurrentUserId(); - - // Then - assertTrue(userId.isPresent()); - assertEquals("user-123", userId.get()); - } - - @Test - void testGetCurrentUserIdWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional userId = expressionRoot.getCurrentUserId(); - - // Then - assertFalse(userId.isPresent()); - } - - @Test - void testGetCurrentUserNameFromJwt() { - // Given - JWT with preferred_username claim - Jwt jwt = createJwtWithClaims(Map.of("preferred_username", "john.doe")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional username = expressionRoot.getCurrentUserName(); - - // Then - assertTrue(username.isPresent()); - assertEquals("john.doe", username.get()); - } - - @Test - void testGetCurrentUserNameWithoutJwt() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional username = expressionRoot.getCurrentUserName(); - - // Then - assertFalse(username.isPresent()); - } - - @Test - void testIsOwnerReturnsTrue() { - // Given - JWT authentication - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - // Note: Current implementation is simplified and returns true - assertTrue(result); - } - - @Test - void testIsOwnerWithNullAuthentication() { - // Given - null authentication - expressionRoot = new CustomSecurityExpressionRoot(null); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - assertFalse(result); - } - - @Test - void testIsOwnerWithNonJwtAuthentication() { - // Given - non-JWT authentication - authentication = new TestingAuthenticationToken("user", "password"); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - boolean result = expressionRoot.isOwner(); - - // Then - assertFalse(result); - } - - @Test - void testGetCurrentUserEmailWithMissingEmailClaim() { - // Given - JWT without email claim - Jwt jwt = createJwtWithClaims(Map.of("sub", "user-123")); - authentication = new TestingAuthenticationToken(jwt, null); - expressionRoot = new CustomSecurityExpressionRoot(authentication); - - // When - Optional email = expressionRoot.getCurrentUserEmail(); - - // Then - assertFalse(email.isPresent()); - } - - // Helper methods - - private Jwt createJwtWithClaims(Map claims) { - Jwt.Builder builder = Jwt.withTokenValue("token") - .header("alg", "none") - .issuedAt(Instant.now()) - .expiresAt(Instant.now().plusSeconds(3600)); - - // Add subject if present in claims - if (claims.containsKey("sub")) { - builder.subject((String) claims.get("sub")); - } else { - builder.subject("test-user"); - } - - return builder.claims(c -> c.putAll(claims)).build(); - } -} diff --git a/doc/core-security/tests.md b/doc/core-security/tests.md new file mode 100644 index 00000000..2117fe78 --- /dev/null +++ b/doc/core-security/tests.md @@ -0,0 +1,195 @@ +# core-security — Test Combinations + +This document enumerates the test scenarios for the `core-security` module. +Each scenario is defined by the combination of: **API visibility** × **Auth flow** × **Client type** × **Policy outcome**. + +--- + +## Dimensions + +| Dimension | Values | +|---|---| +| API visibility | `PUBLIC`, `SECURED` | +| Auth flow | `CLIENT_CREDENTIALS`, `OBO`, `NONE` (no token) | +| Client type | Internal service, External / third-party app, UI SPA (on behalf of a user) | +| Policy | No rules, `ALLOWED_CLIENTS`, `SCOPE_REQUIRED`, multiple rules | +| Token state | Valid, Expired, Malformed, Missing | + +--- + +## 1. Public API + +Public APIs (`ApiDefinition.isPublic = true`) must be accessible regardless of authentication state. + +| # | Flow | Token | Client type | Expected | +|---|---|---|---|---| +| P-01 | NONE | Missing | Any | `200 OK` — no token required | +| P-02 | CLIENT_CREDENTIALS | Valid | Internal service | `200 OK` — token accepted but not required | +| P-03 | OBO | Valid | UI SPA | `200 OK` — token accepted but not required | +| P-04 | NONE | Malformed / expired | Any | `200 OK` — public path bypasses JWT validation | + +> `PolicyAuthorizationManager` returns `PERMIT` immediately when `apiDef.isPublic() == true`. +> `AuthTypeEnforcementFilter` skips the flow check when `!apiDef.requiresAuth()`. + +--- + +## 2. Secured API — No Token + +| # | API auth types | Token | Expected | +|---|---|---|---| +| S-00-01 | `[CLIENT_CREDENTIALS]` | Missing | `401 Unauthorized` | +| S-00-02 | `[OBO]` | Missing | `401 Unauthorized` | +| S-00-03 | `[CLIENT_CREDENTIALS, OBO]` | Missing | `401 Unauthorized` | +| S-00-04 | `[CLIENT_CREDENTIALS]` | Malformed JWT | `401 Unauthorized` | +| S-00-05 | `[CLIENT_CREDENTIALS]` | Expired JWT | `401 Unauthorized` | + +--- + +## 3. Secured API — CLIENT_CREDENTIALS flow + +Token characteristics: has `roles` claim, **no** `scp` claim. +`AuthFlowResolver` resolves to `CLIENT_CREDENTIALS`. + +### 3.1 Flow enforcement (`AuthTypeEnforcementFilter`) + +| # | API auth types | Token flow | Expected | +|---|---|---|---| +| CC-F-01 | `[CLIENT_CREDENTIALS]` | CLIENT_CREDENTIALS | passes filter | +| CC-F-02 | `[OBO]` | CLIENT_CREDENTIALS | `403 Forbidden` — wrong flow | +| CC-F-03 | `[CLIENT_CREDENTIALS, OBO]` | CLIENT_CREDENTIALS | passes filter | +| CC-F-04 | `[NONE]` | CLIENT_CREDENTIALS | passes filter (no auth required) | + +### 3.2 Policy — ALLOWED_CLIENTS + +Config: `{ "clientIds": ["app-a", "app-b"] }` + +| # | Client (azp/appid) | Policy | Expected | +|---|---|---|---| +| CC-P-01 | `app-a` (internal service) | ALLOWED_CLIENTS: [app-a, app-b] | `PERMIT` | +| CC-P-02 | `app-c` (external / unknown app) | ALLOWED_CLIENTS: [app-a, app-b] | `DENY` → `403` | +| CC-P-03 | `app-a` | No rules configured | `PERMIT` (empty rules → `ABSTAIN` → `PERMIT`) | +| CC-P-04 | `app-a` | Rules empty list | `DENY` (rules == null or empty → explicit `DENY`) | + +### 3.3 Policy — SCOPE_REQUIRED + +Config: `{ "scopes": ["api.read"] }` +CLIENT_CREDENTIALS tokens carry app-level roles, not delegated scopes. + +| # | Token scopes (scp) | Policy | Expected | +|---|---|---|---| +| CC-S-01 | none (no scp claim) | SCOPE_REQUIRED: api.read | `DENY` → `403` | +| CC-S-02 | `api.read api.write` | SCOPE_REQUIRED: api.read | `PERMIT` | + +### 3.4 Multiple rules (ALLOWED_CLIENTS + SCOPE_REQUIRED) + +| # | Client | Scopes | Rules | Expected combined decision | +|---|---|---|---|---| +| CC-M-01 | allowed | required scope present | both PERMIT | `PERMIT` | +| CC-M-02 | allowed | scope missing | PERMIT + DENY | `DENY` → `403` | +| CC-M-03 | not allowed | required scope present | DENY + PERMIT | `DENY` → `403` | +| CC-M-04 | not allowed | scope missing | both DENY | `DENY` → `403` | + +--- + +## 4. Secured API — OBO flow (On-Behalf-Of / delegated) + +Token characteristics: has `scp` claim with delegated scopes, identifies an end user via `sub`. +`AuthFlowResolver` resolves to `OBO`. + +### 4.1 Flow enforcement (`AuthTypeEnforcementFilter`) + +| # | API auth types | Token flow | Expected | +|---|---|---|---| +| OBO-F-01 | `[OBO]` | OBO | passes filter | +| OBO-F-02 | `[CLIENT_CREDENTIALS]` | OBO | `403 Forbidden` — wrong flow | +| OBO-F-03 | `[CLIENT_CREDENTIALS, OBO]` | OBO | passes filter | + +### 4.2 Policy — ALLOWED_CLIENTS + +| # | Client (azp) | Policy | Expected | +|---|---|---|---| +| OBO-P-01 | `spa-frontend` (UI SPA) | ALLOWED_CLIENTS: [spa-frontend] | `PERMIT` | +| OBO-P-02 | `mobile-app` (external app) | ALLOWED_CLIENTS: [spa-frontend] | `DENY` → `403` | +| OBO-P-03 | `spa-frontend` | No rules | `PERMIT` | + +### 4.3 Policy — SCOPE_REQUIRED + +| # | Token scp | Policy | Expected | +|---|---|---|---| +| OBO-S-01 | `api.read api.write` | SCOPE_REQUIRED: api.read | `PERMIT` | +| OBO-S-02 | `api.write` | SCOPE_REQUIRED: api.read | `DENY` → `403` | +| OBO-S-03 | empty / blank scp | SCOPE_REQUIRED: api.read | `DENY` → `403` | + +### 4.4 Multiple rules + +| # | Client | Scopes | Rules | Expected | +|---|---|---|---|---| +| OBO-M-01 | allowed | required scope present | both PERMIT | `PERMIT` | +| OBO-M-02 | allowed | scope missing | PERMIT + DENY | `DENY` | +| OBO-M-03 | not allowed | required scope present | DENY + PERMIT | `DENY` | + +--- + +## 5. Route not registered (unknown API definition) + +`ApiDefinitionResolver` returns empty — `PolicyAuthorizationManager` denies immediately (fail-closed). + +| # | Token | Expected | +|---|---|---| +| U-01 | Missing | `403 Forbidden` | +| U-02 | Valid CLIENT_CREDENTIALS | `403 Forbidden` | +| U-03 | Valid OBO | `403 Forbidden` | + +--- + +## 6. JWT claim extraction (`AzureJwtAuthenticationConverter`) + +Unit tests for authority and client-id extraction — no HTTP stack needed. + +| # | Token claims | Expected authorities | Expected clientId | +|---|---|---|---| +| J-01 | `roles: [ADMIN]`, no scp | `ROLE_ADMIN` | `azp` value | +| J-02 | `scp: "api.read api.write"`, no roles | `SCOPE_api.read`, `SCOPE_api.write` | `azp` value | +| J-03 | both `roles` and `scp` | `ROLE_*` + `SCOPE_*` | `azp` value | +| J-04 | neither claim | empty collection | `azp` value | +| J-05 | `azp` absent, `appid` present | — | `appid` value (v1 token fallback) | +| J-06 | both `azp` and `appid` absent | — | `null` | + +--- + +## 7. `AuthFlowResolver` unit tests + +| # | JWT claims | Expected `AuthType` | +|---|---|---| +| R-01 | `scp` present and non-blank | `OBO` | +| R-02 | `scp` blank string | `CLIENT_CREDENTIALS` | +| R-03 | `scp` null / absent | `CLIENT_CREDENTIALS` | +| R-04 | JWT is null | `NONE` | + +--- + +## 8. `PolicyEngine` unit tests + +| # | Rules | Evaluator outcomes | Expected decision | +|---|---|---|---| +| E-01 | empty list | — | `DENY` | +| E-02 | one rule | `PERMIT` | `PERMIT` | +| E-03 | one rule | `DENY` | `DENY` | +| E-04 | two rules | `PERMIT`, `DENY` | `DENY` (short-circuit) | +| E-05 | two rules | `PERMIT`, `ABSTAIN` | `PERMIT` | +| E-06 | two rules | `ABSTAIN`, `ABSTAIN` | `PERMIT` (all-abstain fallback) | +| E-07 | rule with no matching evaluator | — | skipped → `PERMIT` (falls through to abstain default) | + +--- + +## Summary matrix + +``` + │ PUBLIC │ SECURED / CC │ SECURED / OBO │ UNKNOWN ROUTE +─────────────────────┼────────┼──────────────┼───────────────┼────────────── +No token │ 200 │ 401 │ 401 │ 403 +Valid, allowed │ 200 │ 200 │ 200 │ 403 +Valid, wrong flow │ 200 │ 403 │ 403 │ 403 +Valid, not in policy │ 200 │ 403 │ 403 │ 403 +Expired / malformed │ 200 │ 401 │ 401 │ 401/403 +``` diff --git a/pom.xml b/pom.xml index b2c55126..f14bc6df 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ persistence core core-contracts + core-security external-service-aap external-service-projects-info-service external-service-uipath diff --git a/security-tests.http b/security-tests.http new file mode 100644 index 00000000..e8f0a6e1 --- /dev/null +++ b/security-tests.http @@ -0,0 +1,547 @@ +### =========================================================================== +### core-security — Security Test Matrix (HTTP Client) +### Reference: doc/core-security/tests.md +### =========================================================================== +### +### SETUP — create a .env file at project root: +### +### AZURE_TENANT_ID= +### AZURE_CLIENT_ID= # app in ALLOWED_CLIENTS policy +### AZURE_CLIENT_SECRET= +### AZURE_CLIENT_ID_OTHER= # app NOT in ALLOWED_CLIENTS policy +### AZURE_CLIENT_SECRET_OTHER= +### AZURE_SCOPE= # e.g. 5dcea642-a10e-4bc8-9e7e-f758d28def0a +### +### OBO TOKENS — cannot be obtained automatically (require browser/user login). +### Obtain via MSAL device-code or auth-code flow: +### scope: api:///user_impersonation +### Paste the access_token into @obo_token and @obo_token_wrong_scope below. +### +### FEASIBILITY KEY +### ✅ Fully automated — token is retrieved via client_credentials grant +### ⚠ Partially manual — OBO token must be pasted in @obo_token +### 🔧 Config required — needs specific DB api_definition or policy setup +### =========================================================================== + +@host = http://localhost:8080 + +### ── Azure AD – client credentials (ALLOWED client) ────────────────────────── +@azure_tenant_id = {{$dotenv AZURE_TENANT_ID}} +@azure_client_id = {{$dotenv AZURE_CLIENT_ID}} +@azure_client_secret = {{$dotenv AZURE_CLIENT_SECRET}} +@azure_scope = api://{{$dotenv AZURE_SCOPE}}/.default + +### ── Azure AD – client credentials (DISALLOWED client) ─────────────────────── +@azure_client_id_other = {{$dotenv AZURE_CLIENT_ID_OTHER}} +@azure_client_secret_other = {{$dotenv AZURE_CLIENT_SECRET_OTHER}} + +### ── Test data ──────────────────────────────────────────────────────────────── +@project_key = {{$dotenv PROJECT_KEY}} +@user_id = {{$dotenv USER_ID}} + +### ── Bad tokens (static, for error-case tests) ────────────────────────────── +### S-00-04: malformed — not a valid JWT structure at all +@malformed_token = not.a.valid.jwt.token + +### S-00-05: expired — well-formed JWT but exp=2020-01-01, signature is invalid → +### BearerTokenAuthenticationFilter returns 401 before any policy runs +@expired_token = {{$dotenv EXPIRED_TOKEN}} + +### ── OBO tokens (MANUAL — paste after browser / MSAL device-code login) ────── +### Required for: §4 all OBO tests, P-03, flow-enforcement cross-flow tests +@obo_token = {{$dotenv OBO_TOKEN}} + +### OBO token from a client NOT in ALLOWED_CLIENTS (different azp claim) +### Required for: OBO-P-02, OBO-M-03 + @obo_token_other_client = {{$dotenv OBO_TOKEN_OTHER_CLIENT}} + +### OBO token whose scp does NOT include the required scope +### Required for: OBO-S-02, OBO-M-02 +@obo_token_wrong_scope = {{$dotenv OBO_TOKEN_WRONG_SCOPE}} + + +### =========================================================================== +### TOKEN ACQUISITION — automated via client_credentials grant +### =========================================================================== + +### ── Allowed client ────────────────────────────────────────────────────────── +# @name getToken +POST https://login.microsoftonline.com/{{azure_tenant_id}}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{azure_client_id}} +&client_secret={{azure_client_secret}} +&scope={{azure_scope}} + +### + +@cc_token = {{getToken.response.body.access_token}} + +### ── Disallowed client ─────────────────────────────────────────────────────── +# @name getTokenOther +POST https://login.microsoftonline.com/{{azure_tenant_id}}/oauth2/v2.0/token +Content-Type: application/x-www-form-urlencoded + +grant_type=client_credentials +&client_id={{azure_client_id_other}} +&client_secret={{azure_client_secret_other}} +&scope={{azure_scope}} + +### + +@cc_token_other = {{getTokenOther.response.body.access_token}} + + +### =========================================================================== +### §1 PUBLIC API +### Endpoint: GET /actuator/health (configured via app.security.public-endpoints) +### All cases → 200 — Spring Security permitAll() bypasses JWT validation entirely +### =========================================================================== + +### P-01 ✅ · No token → 200 +GET {{host}}/actuator/health +Accept: application/json + +### + +### P-02 ✅ · Valid CC token → 200 (token accepted but not required) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### P-03 ⚠ · Valid OBO token → 200 (token accepted but not required) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + +### P-04a ✅ · Malformed token → 401 (permitAll short-circuits before JWT validation) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{malformed_token}} + +### + +### P-04b ✅ · Expired token → 401 (permitAll short-circuits before JWT validation) +GET {{host}}/actuator/health +Accept: application/json +Authorization: Bearer {{expired_token}} + +### + + +### =========================================================================== +### §2 SECURED API — NO TOKEN +### Endpoint: GET /api/pub/v0/projects/{key} (CLIENT_CREDENTIALS, not public) +### Expected: 401 — BearerTokenAuthenticationFilter rejects before policy runs +### =========================================================================== + +### S-00-01 ✅ · CLIENT_CREDENTIALS endpoint, no token → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json + +### + +### S-00-02 ✅ · OBO-typed endpoint (project-users-v1), no token → 401 +GET {{host}}/api/v1/projects/{{project_key}}/users/{{user_id}}/status +Accept: application/json + +### + +### S-00-03 ✅ · Multiple-auth-types endpoint, no token → 401 +### (use any secured endpoint; both CC and OBO require a bearer token) +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json + +### + +### S-00-04 ✅ · Malformed JWT → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{malformed_token}} + +### + +### S-00-05 ✅ · Expired JWT → 401 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{expired_token}} + +### + + +### =========================================================================== +### §3 CLIENT_CREDENTIALS FLOW +### =========================================================================== + +### ── §3.1 Flow enforcement (AuthTypeEnforcementFilter) ───────────────────── +### +### Note: flow enforcement at the filter level requires an upstream component +### to set the API_DEFINITION_ATTR request attribute. Without that, flow type +### is enforced implicitly via policy rules (ALLOWED_CLIENTS / SCOPE_REQUIRED). +### Tests CC-F-02 and CC-F-04 therefore depend on DB policy configuration. + +### CC-F-01 ✅ · CC endpoint + CC token → passes filter (200 or policy result) +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-F-02 🔧 · OBO-only endpoint + CC token → 403 (wrong flow) +### Requires: api_definition with auth_types = ['OBO'] in DB +### As currently seeded: project-users-v1 uses CLIENT_CREDENTIALS, so a CC +### token here may pass flow but fail ALLOWED_CLIENTS policy. +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "CC-F-02: CC token on OBO-only endpoint — expected 403" +} + +### + +### CC-F-03 ✅ · Both-flows endpoint + CC token → passes filter +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-F-04 🔧 · No-auth endpoint (AuthType.NONE) + CC token → passes (token ignored) +### Seeded: project-platforms-v1 has auth_types=['NONE'] — requiresAuth()=false +### Note: PolicyAuthorizationManager will still run; result depends on policy rules. +GET {{host}}/api/v1/projects/{{project_key}}/platforms +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### ── §3.2 Policy — ALLOWED_CLIENTS ───────────────────────────────────────── + +### CC-P-01 ✅ · Allowed client (azp in ALLOWED_CLIENTS policy) → 200 / PERMIT +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-P-02 ✅ · Disallowed client (azp NOT in ALLOWED_CLIENTS) → 403 +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token_other}} + +### + +### CC-P-03 🔧 · No policy rules configured for this API → ABSTAIN → 200 +### Requires: api_definition with zero rows in authorization_policies table +### Adjust path to an endpoint whose api_definition has no policy rows. +GET {{host}}/api/pub/v0/projects/{{project_key}}/components/anything +Authorization: Bearer {{cc_token}} + +### + +### CC-P-04 🔧 · Empty rules list → PolicyEngine.evaluate([]) → DENY → 403 +### Same condition as CC-P-03: covered by unit test PolicyEngineTest.evaluate_emptyRules_returnsDeny +### Integration path: any secured endpoint with zero policy rows in DB. + +### + +### ── §3.3 Policy — SCOPE_REQUIRED ────────────────────────────────────────── + +### CC-S-01 ✅ · CC token (no scp claim) against SCOPE_REQUIRED policy → 403 +### A standard CC token has roles[] but no scp claim. +### If the api_definition has SCOPE_REQUIRED policy, evaluator returns DENY. +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### CC-S-02 🔧 · CC token WITH scp + SCOPE_REQUIRED matching scope → 200 +### Non-standard scenario: requires either a custom CC token with scp, or +### a resource server that issues CC tokens with delegated scopes. +### Best validated via SecurityIntegrationTest.ccToken_scopeRequired_withScp_returns200. + +### + +### ── §3.4 Multiple rules (ALLOWED_CLIENTS + SCOPE_REQUIRED) ───────────────── + +### CC-M-01 ✅ · Allowed client + required scope present → 200 +### Standard CC token from allowed client; if SCOPE_REQUIRED is active, +### add scp manually (see CC-S-02 note). Without SCOPE_REQUIRED policy, passes via ALLOWED_CLIENTS. +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "projectName": "Security Test CC-M-01", + "projectKey": "{{project_key}}-m01", + "flavor": "DLSS", + "projectDescription": "CC-M-01: allowed client + scope — expected 200" +} + +### + +### CC-M-02 ✅ · Allowed client + scope missing → 403 +### CC token has no scp; if SCOPE_REQUIRED policy is active → DENY +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token}} + +{ + "projectName": "Security Test CC-M-02", + "projectKey": "{{project_key}}-m02", + "flavor": "DLSS", + "projectDescription": "CC-M-02: scope missing — expected 403 if SCOPE_REQUIRED is active" +} + +### + +### CC-M-03 ✅ · Disallowed client + scope present → 403 +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "projectName": "Security Test CC-M-03", + "projectKey": "{{project_key}}-m03", + "flavor": "DLSS", + "projectDescription": "CC-M-03: disallowed client — expected 403" +} + +### + +### CC-M-04 ✅ · Disallowed client + scope missing → 403 +POST {{host}}/api/pub/v0/projects +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "projectName": "Security Test CC-M-04", + "projectKey": "{{project_key}}-m04", + "flavor": "DLSS", + "projectDescription": "CC-M-04: disallowed client + no scope — expected 403" +} + +### + + +### =========================================================================== +### §4 OBO FLOW (On-Behalf-Of / delegated) +### ⚠ ALL TESTS BELOW require @obo_token (and variants) to be set manually. +### See SETUP INSTRUCTIONS at the top of this file. +### =========================================================================== + +### ── §4.1 Flow enforcement ─────────────────────────────────────────────────── + +### OBO-F-01 ⚠ · OBO-only endpoint + OBO token → passes (200 or policy result) +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-F-01: correct OBO flow" +} + +### + +### OBO-F-02 ✅ · OBO-configured endpoint + CC token → 403 (wrong flow) +### CC token sent to project-users endpoint; if ALLOWED_CLIENTS policy is active +### and cc_token_other's azp is not in the allowed list → 403. +### To test pure flow enforcement, configure auth_types=['OBO'] for this api_definition. +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{cc_token_other}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-F-02: CC token on OBO endpoint — expected 403" +} + +### + +### OBO-F-03 ⚠ · Both-flows endpoint + OBO token → passes +GET {{host}}/api/pub/v0/projects/{{project_key}} +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + +### ── §4.2 Policy — ALLOWED_CLIENTS ────────────────────────────────────────── + +### OBO-P-01 ⚠ · Allowed client OBO token (azp in ALLOWED_CLIENTS) → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-P-01: allowed SPA client" +} + +### + +### OBO-P-02 ⚠ · Disallowed client OBO token → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_other_client}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-P-02: disallowed client — expected 403" +} + +### + +### OBO-P-03 🔧 · No policy rules for OBO api_definition → ABSTAIN → 200 +### Requires: api_definition entry with zero authorization_policies rows in DB + +### + +### ── §4.3 Policy — SCOPE_REQUIRED ─────────────────────────────────────────── + +### OBO-S-01 ⚠ · scp contains required scope → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-S-01: scp has required scope" +} + +### + +### OBO-S-02 ⚠ · scp does NOT contain required scope → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_wrong_scope}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-S-02: scp missing required scope — expected 403" +} + +### + +### OBO-S-03 · Blank scp claim → AuthFlowResolver treats as CLIENT_CREDENTIALS +### Cannot be crafted easily in .http client (requires custom JWT with scp=" "). +### Covered by: SecurityIntegrationTest › OboTests › blankScp_returns403 + +### + +### ── §4.4 Multiple rules ────────────────────────────────────────────────────── + +### OBO-M-01 ⚠ · Allowed client + required scope → 200 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-01: both PERMIT" +} + +### + +### OBO-M-02 ⚠ · Allowed client + scope missing → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_wrong_scope}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-02: PERMIT + DENY — expected 403" +} + +### + +### OBO-M-03 ⚠ · Disallowed client + scope present → 403 +POST {{host}}/api/v1/projects/{{project_key}}/users +Content-Type: application/json +Authorization: Bearer {{obo_token_other_client}} + +{ + "environment": "PRODUCTIVE", + "user": "{{user_id}}", + "account": "{{user_id}}", + "role": "TEAM", + "comments": "OBO-M-03: DENY + PERMIT — expected 403" +} + +### + + +### =========================================================================== +### §5 UNKNOWN ROUTE (no matching api_definition) +### PolicyAuthorizationManager.check() → resolver returns empty → fail-closed +### Expected: 403 (or 401 when no token and ExceptionTranslationFilter fires first) +### =========================================================================== + +### U-01 ✅ · No token → 401 (no JWT) or 403 (anonymous → fail-closed) +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json + +### + +### U-02 ✅ · Valid CC token, unknown route → 403 +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json +Authorization: Bearer {{cc_token}} + +### + +### U-03 ⚠ · Valid OBO token, unknown route → 403 +GET {{host}}/api/v0/nonexistent-endpoint +Accept: application/json +Authorization: Bearer {{obo_token}} + +### + + +### =========================================================================== +### SUMMARY MATRIX (reference) +### +### │ PUBLIC │ SECURED/CC │ SECURED/OBO │ UNKNOWN ROUTE +### ───────────────────┼────────┼────────────┼─────────────┼────────────── +### No token │ 200 │ 401 │ 401 │ 401/403 +### Valid, allowed │ 200 │ 200 │ 200 │ 403 +### Valid, wrong flow │ 200 │ 403 │ 403 │ 403 +### Valid, denied │ 200 │ 403 │ 403 │ 403 +### Expired/malformed │ 200 │ 401 │ 401 │ 401 +### =========================================================================== From aee0c8f52c8477a1931307154952430c78749e7c Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Tue, 7 Apr 2026 10:10:11 +0200 Subject: [PATCH 10/27] =?UTF-8?q?Update=20LDAP=20group=20pattern=20propert?= =?UTF-8?q?y=20in=20ProjectServiceImpl=20and=20add=20info=20to=20applicati?= =?UTF-8?q?on=20yaml=E2=80=A6=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request makes significant improvements to the application's configuration, focusing on environment variable management, documentation, and flexibility for deployment in different environments. The main changes involve restructuring the `application.yaml` file to better organize configuration sections, enhance documentation, and ensure that sensitive or environment-specific values are supplied via environment variables. Additionally, there is a small fix in the `ProjectServiceImpl` class to align a configuration property key. **Configuration and Environment Management Improvements:** * Refactored `application.yaml` to consistently use environment variables for sensitive and environment-specific settings (e.g., database credentials, OAuth2 endpoints, external service URLs), improving security and deployment flexibility. * Enhanced inline documentation throughout `application.yaml` to clarify the purpose and usage of each configuration option, making onboarding and troubleshooting easier. [[1]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadR1-R103) [[2]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadL79-R163) [[3]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadL159-R228) [[4]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadR244-R246) [[5]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadR270-R279) * Added or expanded configuration sections for logging, Spring Boot infrastructure, security, actuator endpoints, OpenTelemetry, and external integrations (Ansible, OpenShift, Bitbucket, Projects Info Service), with detailed comments and improved defaulting via environment variables. [[1]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadR1-R103) [[2]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadL79-R163) [[3]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadL159-R228) [[4]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadR244-R246) [[5]](diffhunk://#diff-7f1ba62cb5d27efca72927e6323654ce62284153382d96af57c77893160b3fadR270-R279) **Code Consistency and Bug Fixes:** * Updated the property key in `ProjectServiceImpl.java` from `ldap.group.pattern` to `services.project.ldap.group.pattern` to match the new configuration structure, ensuring the correct value is injected. --- application.yaml | 204 +++++++++++------- .../service/impl/ProjectServiceImpl.java | 2 +- 2 files changed, 131 insertions(+), 75 deletions(-) diff --git a/application.yaml b/application.yaml index 4696ac45..bfaa024c 100644 --- a/application.yaml +++ b/application.yaml @@ -1,71 +1,106 @@ +# Base HTTP server configuration. +# Override these values with environment variables when deploying to another environment. +server: + # TCP port used by the Spring Boot application. + port: 8080 + +# Application logging configuration. +# Raise levels temporarily for troubleshooting; keep them lower in shared environments. logging: level: + # Core Spring framework logs. org.springframework: INFO + # Security filter chain and JWT validation logs. org.springframework.security: TRACE + # Custom external service integration logs. org.opendevstack.apiservice.externalservice: DEBUG -# ────────────────────────────────────────────────────────────────────────────── -# Persistence — PostgreSQL datasource + JPA / Hibernate -# -# Schema is managed externally via Liquibase (database module / Makefile). -# Hibernate is set to `validate` so it only checks that entities match the -# existing schema at boot — it never creates or alters tables. -# -# Required env vars (no defaults — must be explicitly set per environment): -# DB_HOST, DB_PORT, DB_NAME, DB_USERNAME, DB_PASSWORD -# ────────────────────────────────────────────────────────────────────────────── +# Spring Boot infrastructure configuration. +# Most values here should be supplied from environment variables or a secret store. spring: + security: + oauth2: + resourceserver: + jwt: + # URL of the identity provider JWK set used to validate JWT signatures. + jwk-set-uri: ${OAUTH2_JWK_SET_URI:} + # Expected issuer claim of incoming JWTs. + issuer-uri: ${OAUTH2_ISSUER:} + audiences: + # Allowed audience values for access tokens accepted by this API. + - ${OAUTH2_AUDIENCE:} + - ${OAUTH2_AUDIENCE2:99999} datasource: - url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:devstack} - username: ${DB_USERNAME:devstack} - password: ${DB_PASSWORD:devstack} + # JDBC connection string for the PostgreSQL database. + # Example: jdbc:postgresql://localhost:5432/ods_api_service + url: ${ODS_API_SERVICE_DB_DATASOURCE_URL} + # Database user used by the application. + username: ${ODS_API_SERVICE_DB_USER:opendevstack} + # Database password. Use a secret manager or injected environment variable in non-local setups. + password: ${ODS_API_SERVICE_DB_PASSWORD:opendevstack} + # JDBC driver class. Keep this aligned with the database engine in use. driver-class-name: org.postgresql.Driver hikari: - # Pool sizing — tune per environment - maximum-pool-size: ${DB_POOL_MAX_SIZE:10} - minimum-idle: ${DB_POOL_MIN_IDLE:2} - connection-timeout: 30000 - idle-timeout: 600000 - max-lifetime: 1800000 + # Maximum number of open connections in the pool. + maximum-pool-size: ${HIKARI_POOL_MAX_SIZE:10} + # Minimum number of idle connections kept ready. + minimum-idle: ${HIKARI_MIN_IDLE:2} + # Time to wait for a free connection before failing, in milliseconds. + connection-timeout: ${HIKARI_CONNECTION_TIMEOUT:30000} + # How long an idle connection may stay in the pool, in milliseconds. + idle-timeout: ${HIKARI_IDLE_TIMEOUT:600000} + # Maximum lifetime of a pooled connection, in milliseconds. + max-lifetime: ${HIKARI_MAX_LIFETIME:1800000} jpa: hibernate: - # NEVER auto-create/alter — Liquibase owns the schema - ddl-auto: validate + # Schema management mode. Use validate/update locally, avoid create/create-drop in shared environments. + ddl-auto: ${JPA_HIBERNATE_DDL_AUTO:validate} properties: hibernate: - dialect: org.hibernate.dialect.PostgreSQLDialect - # Log slow queries (> 500 ms) via Hibernate statistics - generate_statistics: false - # Avoid lazy-loading pitfalls: keep Session scoped to Service, not Request - open-in-view: false - show-sql: false - -spring: - security: - oauth2: - resourceserver: - jwt: - issuer-uri: https://sts.windows.net/${AZURE_TENANT_ID}/ + # Enable Hibernate statistics only while investigating performance issues. + generate_statistics: ${JPA_HIBERNATE_GENERATE_STATISTICS:false} + # Disable the Open Session in View pattern by default. + open-in-view: ${JPA_OPEN_IN_VIEW:false} + # Log SQL statements only for debugging. + show-sql: ${JPA_SHOW_SQL:false} + management: + endpoints: + web: + exposure: + # Minimal actuator exposure under spring.*; the top-level management block below extends this further. + include: ${MANAGEMENT_ENDPOINTS_INCLUDE:health} +# Custom application-level security switches. app: security: + # Master switch for application authentication and authorization. + enabled: true public-endpoints: + # Endpoints listed here remain reachable without authentication. - /actuator/health - - /actuator/health/** + - /actuator/info + - /api/v1/projects/*/platforms +# Spring Boot Actuator configuration. +# Restrict these endpoints in production if they expose operational details. management: endpoints: web: exposure: + # Explicit list of actuator endpoints exposed over HTTP. include: openapi, swagger-ui, beans, caches, configprops, env, health, httpexchanges, info, loggers, mappings endpoint: configprops: + # Shows bound configuration values in actuator output. show-values: always env: + # Shows environment-derived values in actuator output. show-values: always loggers: + # Allows runtime log level inspection and updates. access: unrestricted health: + # Exposes full health details and individual contributors. show-details: always show-components: always info: @@ -76,52 +111,56 @@ management: recording: # Show all available info in /actuator/httpexchanges and also in Swagger include: request-headers, response-headers, authorization_header, cookie_headers, principal, remote_address, session_id, time_taken -springdoc: - show-actuator: true - swagger-ui: - doc-expansion: none - try-it-out-enabled: true - filter: true - tags-sorter: alpha - operations-sorter: alpha - -openapi: - servers: - - url: "https://localhost:8080" - description: "Development environment" + +# OpenTelemetry settings. +# Configure OTLP endpoint and sampling according to your observability platform. otel: - service: - name: devstack-api-service-dev - version: 0.0.3 - exporter: - otlp: - endpoint: http://opentelemetry.example.com - traces: - exporter: logging,otlp - sampler: parentbased_traceidratio - sampler_arg: 1.0 - metrics: - exporter: none - resource: - attributes: service.name=devstack-api-service,service.version=0.0.3,deployment.environment=development - instrumentation: - jdbc: - enabled: false - logback-appender: - enabled: true + service: + # Logical service name and version attached to telemetry data. + name: devstack-api-service-dev + version: 0.0.3 + exporter: + otlp: + # Endpoint of the OpenTelemetry collector. + endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT} + traces: + # Send traces to both application logs and the OTLP collector. + exporter: logging,otlp + # Parent-based ratio sampling. sampler_arg=1.0 means sample all traces. + sampler: parentbased_traceidratio + sampler_arg: 1.0 + metrics: + # Metrics export is disabled here. + exporter: none + resource: + # Resource attributes attached to every exported span. + attributes: service.name=devstack-api-service,service.version=0.0.3,deployment.environment=development + instrumentation: + jdbc: + # JDBC instrumentation is disabled, likely to reduce noise or overhead. + enabled: false + logback-appender: + # Enables trace correlation through logback. + enabled: true # External Service Configuration automation: platform: ansible: + # Toggle the Ansible automation integration. enabled: true + # Base URL of the Ansible Automation Platform / AWX API. base-url: ${ANSIBLE_BASE_URL:http://localhost:8080/api/v2} + # Credentials used to authenticate against Ansible. username: ${ANSIBLE_USERNAME:admin} password: ${ANSIBLE_PASSWORD:password} + # Request timeout in milliseconds. timeout: ${ANSIBLE_TIMEOUT:30000} ssl: + # When false, TLS certificates are not validated. Keep true outside local development. verify-certificates: ${ANSIBLE_SSL_VERIFY:true} + # Optional custom trust store settings for private CA certificates. trust-store-path: ${ANSIBLE_SSL_TRUSTSTORE_PATH:} trust-store-password: ${ANSIBLE_SSL_TRUSTSTORE_PASSWORD:} trust-store-type: ${ANSIBLE_SSL_TRUSTSTORE_TYPE:JKS} @@ -129,11 +168,11 @@ automation: uipath: # Base URL of the UIPath Orchestrator instance host: ${UIPATH_HOST:https://orchestrator.example.com} - + # Authentication credentials clientId: ${UIPATH_CLIENT_ID:your-client-id} clientSecret: ${UIPATH_CLIENT_SECRET:your-client-secret} - + # Tenancy name (default: "default") tenancy-name: ${UIPATH_TENANCY_NAME:default} @@ -143,10 +182,10 @@ automation: # API endpoints (defaults shown, can be overridden) login-endpoint: /api/Account/Authenticate queue-items-endpoint: /odata/QueueItems - + # Request timeout in milliseconds timeout: 30000 - + # SSL Configuration ssl: # Set to false to disable certificate verification (DEV ONLY!) @@ -156,25 +195,37 @@ automation: trust-store-password: ${TRUSTSTORE_PASSWORD:changeit} trust-store-type: ${UIPATH_SSL_TRUST_STORE_TYPE:JKS} - apis: project-users: + # Workflow name triggered for project user automation tasks. ansible-workflow-name: ${API_PROJECT_USERS_WORKFLOW_NAME:ansible++workflow} token: + # Secret used to sign internal tokens. Replace the default in every non-local environment. secret: ${API_PROJECT_USERS_TOKEN_SECRET:devstack-api-service-jwt-secret-key-256bit-change-in-production} + # Token lifetime in hours. expiration-hours: ${API_PROJECT_USERS_TOKEN_EXPIRATION_HOURS:24} + projects: + # Workflow name used for project provisioning automation. + ansible-workflow-name: ${API_PROJECTS_MINIEDP_PROVISION_WORKFLOW_NAME} + # Supported project locations, typically provided as a comma-separated environment variable. + locations: ${API_PROJECTS_LOCATIONS} externalservices: openshift: instances: # Development OpenShift instance dev: + # API URL of the target cluster. api-url: ${OPENSHIFT_US_TEST_API_URL:https://api.dev.ocp.example.com:6443} + # Service account or user token used to access the cluster API. token: ${OPENSHIFT_US_TEST_TOKEN:your-dev-token-here} + # Default namespace/project to operate in. namespace: ${OPENSHIFT_US_TEST_NAMESPACE:devstack-dev} + # HTTP client timeouts in milliseconds. connection-timeout: 30000 read-timeout: 30000 + # When true, the client accepts untrusted certificates. trust-all-certificates: ${OPENSHIFT_US_TEST_TRUST_ALL:true} # Test OpenShift instance @@ -190,7 +241,9 @@ externalservices: instances: # Development Bitbucket instance dev: + # Base REST URL of the Bitbucket server. base-url: ${BITBUCKET_DEV_BASE_REST_URL:https://bitbucket.dev.example.com} + # Preferred authentication method: bearer token. bearer-token: ${BITBUCKET_DEV_BEARER_TOKEN:} # OR use basic auth if bearer token is not available: # username: ${BITBUCKET_DEV_USERNAME:admin} @@ -198,7 +251,7 @@ externalservices: connection-timeout: 30000 read-timeout: 30000 trust-all-certificates: ${BITBUCKET_DEV_TRUST_ALL:true} - + # Production Bitbucket instance prod: base-url: ${BITBUCKET_PROD_BASE_REST_URL:https://bitbucket.prod.example.com} @@ -214,11 +267,14 @@ externalservices: clusters: # Test Cluster test: + # Base cluster domain used to derive webhook proxy routes. cluster-base: ${WEBHOOK_PROXY_TEST_CLUSTER_BASE:apps.cluster.ocp.com} connection-timeout: ${WEBHOOK_PROXY_TEST_CONNECTION_TIMEOUT:30000} read-timeout: ${WEBHOOK_PROXY_TEST_READ_TIMEOUT:30000} trust-all-certificates: ${WEBHOOK_PROXY_TEST_TRUST_ALL:false} + # Relative path to the Jenkinsfile used when none is supplied. default-jenkinsfile-path: ${WEBHOOK_PROXY_TEST_JENKINSFILE_PATH:Jenkinsfile} projects-info-service: - base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} \ No newline at end of file + # Base URL of the downstream Projects Info Service consumed by this application. + base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index ccd3be4c..cdbddddd 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -20,7 +20,7 @@ public class ProjectServiceImpl implements ProjectService { private static final String TEAM_ROLE = "TEAM"; private static final String STAKEHOLDER_ROLE = "STAKEHOLDER"; - @Value("${ldap.group.pattern}") + @Value("${services.project.ldap.group.pattern}") private String ldapGroupPattern; private final ProjectRepository projectRepository; From 60c5067dc3baff268450381823b1d685b7f2eb5c Mon Sep 17 00:00:00 2001 From: Angel MP Date: Tue, 21 Apr 2026 09:12:46 +0200 Subject: [PATCH 11/27] =?UTF-8?q?Refactor=20component=20creation=20and=20r?= =?UTF-8?q?etrieval=20logic;=20enhance=20error=20handli=E2=80=A6=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …ng and response mapping --------- Co-authored-by: Jorge Romero --- .../openapi/api-project-component-v0.yaml | 97 ++++++++-- .../project/config/JsonNullableConfig.java | 26 +++ .../controller/ComponentsResponseFactory.java | 44 ++++- .../ProjectComponentsController.java | 47 ++--- .../ProjectComponentsExceptionHandler.java | 156 ++++++++++++++++ .../exception/ComponentCreationException.java | 8 + .../project/exception/ComponentErrorKey.java | 30 ++++ .../exception/ComponentErrorMessage.java | 14 ++ .../exception/ComponentNotFoundException.java | 8 + .../project/facade/ComponentsFacade.java | 10 +- ...MarketplaceExternalServicePlaceholder.java | 27 --- .../mapper/ComponentResponseMapper.java | 3 +- .../project/mapper/MarketplaceMapper.java | 65 +++++-- .../ProjectComponentsControllerTest.java | 110 +++++++----- ...ProjectComponentsExceptionHandlerTest.java | 167 ++++++++++++++++++ .../project/facade/ComponentsFacadeTest.java | 78 +++++--- .../project/util/TestObjectsBuilder.java | 40 ++--- .../advice/ProjectExceptionHandler.java | 2 +- .../project/exception/ErrorKey.java | 30 ++-- .../project/exception/ErrorMessage.java | 15 ++ .../exception/ProjectValidationException.java | 5 + .../validation/ProjectRequestValidator.java | 3 +- .../advice/ProjectExceptionHandlerTest.java | 20 +++ .../evaluator/FlavorRestrictionEvaluator.java | 2 +- .../marketplace/model/ProjectComponent.java | 12 +- .../impl/MarketplaceServiceMockImpl.java | 20 ++- .../impl/ProjectExistenceServiceImpl.java | 5 +- 27 files changed, 830 insertions(+), 214 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java delete mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java diff --git a/api-project-component-v0/openapi/api-project-component-v0.yaml b/api-project-component-v0/openapi/api-project-component-v0.yaml index 868e72e4..c85d72a4 100644 --- a/api-project-component-v0/openapi/api-project-component-v0.yaml +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -27,7 +27,7 @@ paths: - name: projectId in: path required: true - description: Project id + description: Project key schema: type: string requestBody: @@ -37,8 +37,8 @@ paths: schema: $ref: '#/components/schemas/CreateComponentRequest' responses: - '201': - description: Created + '200': + description: OK headers: Location: schema: @@ -49,14 +49,26 @@ paths: $ref: '#/components/schemas/CreateComponentResponse' '400': description: Bad Request + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' '401': $ref: '#/components/responses/UnauthorizedError' '403': description: Forbidden '404': description: Endpoint not found / Project not found / Product not found + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' '500': description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/CreateComponentResponse' /projects/{projectId}/components/{componentId}: get: @@ -69,7 +81,7 @@ paths: - name: projectId in: path required: true - description: Project id + description: Project key schema: type: string - name: componentId @@ -78,6 +90,7 @@ paths: description: Component id schema: type: string + format: uuid responses: '200': description: Component information @@ -105,6 +118,10 @@ components: schemas: Component: type: object + x-class-extra-annotation: | + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) + required: + - params properties: id: type: string @@ -117,50 +134,94 @@ components: description: Description of the product productName: type: string - description: Name of the product (e.g. Docker plain) + description: Name of the product productId: type: string description: Product ID environment: - type: string - description: Environment (e.g. DEV) + $ref: '#/components/schemas/EnvironmentsDTO' + description: Environment status: - type: string - description: Status of the component (e.g. READY, NOT_READY) + $ref: '#/components/schemas/ComponentsStatusDTO' + description: Status of the component resultTraceback: - type: string + type: object + nullable: true description: Traceback information in case of error + x-field-extra-annotation: | + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY) repositoryURL: type: string description: URL of the repository (for ODS products) params: type: object description: Additional parameters (key-value pairs) + additionalProperties: true component-type: type: string description: Type of component (ods|awx) CreateComponentResponse: type: object + required: + - timestamp + - httpStatus + - errorKey + - message + - path properties: - errorCode: + timestamp: type: integer - field: + format: int64 + httpStatus: + type: string + errorKey: + type: string + error: type: string message: type: string - location: + path: type: string - format: url CreateComponentRequest: type: object + required: + - name + - productId + - params properties: name: type: string - description: component name + description: Component name + minLength: 2 + maxLength: 52 + pattern: "^[a-z]+(-?[a-z0-9]+)*$" + x-field-extra-annotation: | + @Size(min=2, message = "The component name must contain more than 1 character.") + @Size(max=52, message = "The component name must contain less than 53 characters.") + @Pattern(regexp = "^[a-z]+(-?[a-z0-9]+)*$", message = "Only lowercase letters and numbers are allowed (example: componentname8), with optional dashes in between (example: my-component). The first character must be a letter.") productId: type: string - description: product id + description: Product id + registerOnly: + type: boolean + description: Register only flag (no provisioning) + default: false params: type: object - additionalProperties: - type: string \ No newline at end of file + description: Additional parameters (key-value pairs) + additionalProperties: true + EnvironmentsDTO: + type: string + enum: + - DEV + - QA + - PROD + ComponentsStatusDTO: + type: string + enum: + - READY + - RUNNING + - FAILED + - DELETING + - DELETED + - UNKNOWN diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java new file mode 100644 index 00000000..52308039 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/JsonNullableConfig.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.openapitools.jackson.nullable.JsonNullable; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JsonNullableConfig { + + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonNullableCustomizer() { + return builder -> { + builder.modulesToInstall(JsonNullableModule.class); + builder.postConfigurer(this::configureJsonNullableInclusion); + }; + } + + private void configureJsonNullableInclusion(ObjectMapper objectMapper) { + objectMapper.configOverride(JsonNullable.class) + .setInclude(JsonInclude.Value.construct(JsonInclude.Include.NON_ABSENT, JsonInclude.Include.NON_ABSENT)); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java index c3f876a8..28fa4a02 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java @@ -1,24 +1,54 @@ package org.opendevstack.apiservice.project.controller; +import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; -public class ComponentsResponseFactory { +public final class ComponentsResponseFactory { private ComponentsResponseFactory() { } - public static CreateComponentResponse error(String projectId) { + public static CreateComponentResponse entityCreated(String projectId, String componentId) { + String path = String.format("/api/pub/v0/projects/%s/components/%s", projectId, componentId); + return ok(path, "Component created"); + } + + public static CreateComponentResponse badRequest(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.BAD_REQUEST, errorKey, path, message); + } + + public static CreateComponentResponse forbidden(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.FORBIDDEN, errorKey, path, message); + } + + public static CreateComponentResponse notFound(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.NOT_FOUND, errorKey, path, message); + } + + public static CreateComponentResponse internalError(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorKey, path, message); + } + + private static CreateComponentResponse buildResponse(HttpStatus httpStatus, ComponentErrorKey errorKey, String path, + String message) { CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); - response.setMessage("Failed to create component for project '" + projectId + "'"); + response.setTimestamp(System.currentTimeMillis()); + response.setHttpStatus(httpStatus.name()); + response.setErrorKey(errorKey.getKey()); + response.setError(errorKey.getMessage()); + response.setMessage(message); + response.setPath(path); return response; } - public static CreateComponentResponse entityCreated(String projectId, String componentId) { + public static CreateComponentResponse ok(String path, String message) { CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.CREATED.value()); - response.setMessage(componentId + " component created successfully in project " + projectId); + response.setTimestamp(System.currentTimeMillis()); + response.setHttpStatus(HttpStatus.OK.name()); + response.setErrorKey(ComponentErrorKey.OK.getKey()); + response.setMessage(message); + response.setPath(path); return response; } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java index f8b0ae88..d288cae6 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -3,6 +3,8 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectComponentsApi; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; import org.opendevstack.apiservice.project.model.Component; @@ -13,44 +15,43 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.UUID; + @RestController @AllArgsConstructor @Slf4j -@RequestMapping("/api/pub/v0") +@RequestMapping(ProjectComponentsController.API_BASE_PATH) public class ProjectComponentsController implements ProjectComponentsApi { + public static final String API_BASE_PATH = "/api/pub/v0"; + private final ComponentsFacade componentsFacade; private final ComponentResponseMapper componentResponseMapper; @Override public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - try { - Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); - log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); - if (component == null) { - log.error("Failed to create component for project '{}'", projectId); - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); - } - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.entityCreated(projectId, component.getId())); - } catch (Exception e) { - log.error("Error while trying to create component for project '" + projectId + "': " + e.getMessage(), e); - return componentResponseMapper.toResponseEntity(ComponentsResponseFactory.error(projectId)); + Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); + if (component == null) { + throw new ComponentCreationException(String.format("Failed to create component for project '%s'", projectId)); } + + log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); + return componentResponseMapper.toResponseEntity( + ComponentsResponseFactory.entityCreated(projectId, component.getId()) + ); } @Override - public ResponseEntity getProjectComponent(String projectId, String componentId) { - try { - Component component = componentsFacade.getProjectComponent(projectId, componentId); - log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); - if (component == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } - return ResponseEntity.status(HttpStatus.OK).body(component); - } catch (Exception e) { - log.error("Error retrieving component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + public ResponseEntity getProjectComponent(String projectId, UUID componentId) { + Component component = componentsFacade.getProjectComponent(projectId, componentId.toString()); + if (component == null) { + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); } + + log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); + return ResponseEntity.status(HttpStatus.OK).body(component); } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java new file mode 100644 index 00000000..f561d3df --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -0,0 +1,156 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ComponentsResponseFactory; +import org.opendevstack.apiservice.project.controller.ProjectComponentsController; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentErrorKey; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.Map; + +@RestControllerAdvice(assignableTypes = ProjectComponentsController.class) +@Slf4j +public class ProjectComponentsExceptionHandler { + + private static final Map FIELD_ERROR_MAP = Map.of( + "name", ComponentErrorKey.COMPONENT_PARAM_NOT_MEET_REGEX, + "productId", ComponentErrorKey.INVALID_PARAMETERS, + "params", ComponentErrorKey.INVALID_PARAMETERS + ); + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException( + MethodArgumentNotValidException ex, + HttpServletRequest request) { + + log.warn("Request body validation error: {}", ex.getMessage()); + + FieldError fieldError = ex.getBindingResult().getFieldErrors().stream() + .findFirst() + .orElse(null); + + ComponentErrorKey errorKey = ComponentErrorKey.INVALID_PARAMETERS; + String message = ComponentErrorKey.INVALID_PARAMETERS.getMessage(); + + if (fieldError != null) { + errorKey = FIELD_ERROR_MAP.getOrDefault(fieldError.getField(), ComponentErrorKey.INVALID_PARAMETERS); + message = String.format("Field: %s %s", fieldError.getField(), fieldError.getDefaultMessage()); + } + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + message, + errorKey + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException ex, + HttpServletRequest request) { + + log.warn("Request parameter validation error: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ComponentNotFoundException.class) + public ResponseEntity handleComponentNotFoundException( + ComponentNotFoundException ex, + HttpServletRequest request) { + + log.warn("Component not found: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.notFound( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.COMPONENT_NOT_FOUND + ); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException( + AccessDeniedException ex, + HttpServletRequest request) { + + log.warn("Access denied: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.forbidden( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.ACCESS_DENIED + ); + + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException( + HttpMessageNotReadableException ex, + HttpServletRequest request) { + + log.warn("Request body format error: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + "Params property should be a valid json.", + ComponentErrorKey.COMPONENT_PARAM_INVALID_FORMAT + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ComponentCreationException.class) + public ResponseEntity handleComponentCreationException( + ComponentCreationException ex, + HttpServletRequest request) { + + log.error("Component creation failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex, + HttpServletRequest request) { + + log.error("Unexpected error: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + "An error occurred while processing the request.", + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java new file mode 100644 index 00000000..f0359189 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentCreationException extends RuntimeException { + + public ComponentCreationException(String message) { + super(message); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java new file mode 100644 index 00000000..9af565bf --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorKey.java @@ -0,0 +1,30 @@ +package org.opendevstack.apiservice.project.exception; + +public enum ComponentErrorKey { + + OK("000", ComponentErrorMessage.SUCCESS), + ACCESS_DENIED("002", ComponentErrorMessage.FORBIDDEN), + INTERNAL_ERROR("003", ComponentErrorMessage.INTERNAL_ERROR), + INVALID_PARAMETERS("006", ComponentErrorMessage.BAD_REQUEST), + COMPONENT_PARAM_NOT_MEET_REGEX("010", ComponentErrorMessage.BAD_REQUEST), + PROJECT_NOT_FOUND("012", ComponentErrorMessage.NOT_FOUND), + COMPONENT_NOT_FOUND("013", ComponentErrorMessage.NOT_FOUND), + BAD_REQUEST_BODY("014", ComponentErrorMessage.BAD_REQUEST), + COMPONENT_PARAM_INVALID_FORMAT("017", ComponentErrorMessage.BAD_REQUEST); + + private final String key; + private final String message; + + ComponentErrorKey(String key, String message) { + this.key = key; + this.message = message; + } + + public String getKey() { + return key; + } + + public String getMessage() { + return message; + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java new file mode 100644 index 00000000..116d4391 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentErrorMessage.java @@ -0,0 +1,14 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentErrorMessage { + + public static final String BAD_REQUEST = "Bad Request"; + public static final String NOT_FOUND = "Not Found"; + public static final String FORBIDDEN = "Forbidden"; + public static final String INTERNAL_ERROR = "Internal error"; + public static final String SUCCESS = "Success"; + + private ComponentErrorMessage() { + // prevent instantiation + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java new file mode 100644 index 00000000..8ee5ffc7 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentNotFoundException extends RuntimeException { + + public ComponentNotFoundException(String message) { + super(message); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index f36476f8..42bab0e3 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -5,6 +5,8 @@ import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; @@ -25,7 +27,9 @@ public Component getProjectComponent(String projectId, String componentId) { ProjectComponent marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); if (marketplaceComponent == null) { log.info("Marketplace component with id {} not found", componentId); - return null; + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); } return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } @@ -35,7 +39,9 @@ public Component createProjectComponent(String projectId, CreateComponentRequest ProjectComponent marketplaceComponent = marketplaceExternalService.createProjectComponent(projectId, createComponentParameterList); if (marketplaceComponent == null) { log.error("Failed to create component in marketplace for project with id {}", projectId); - return null; + throw new ComponentCreationException( + String.format("Failed to create component for project '%s'", projectId) + ); } return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java deleted file mode 100644 index 990374ce..00000000 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/MarketplaceExternalServicePlaceholder.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.opendevstack.apiservice.project.facade; - -import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.api.ExternalService; -import org.opendevstack.apiservice.project.model.Component; -import org.opendevstack.apiservice.project.model.CreateComponentRequest; -import org.springframework.stereotype.Service; - -@Service -@Slf4j -class MarketplaceExternalServicePlaceholder implements ExternalService { - - @Override - public boolean isHealthy() { - return false; - } - - public Component getProjectComponent(String projectId, String componentId) { - log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); - return null; - } - - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentRequest); - return null; - } -} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java index 4c3198b5..846f2991 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/ComponentResponseMapper.java @@ -9,7 +9,6 @@ public interface ComponentResponseMapper { default ResponseEntity toResponseEntity(CreateComponentResponse response) { - return new ResponseEntity<>(response, HttpStatus.valueOf(response.getErrorCode())); + return new ResponseEntity<>(response, HttpStatus.valueOf(response.getHttpStatus())); } - } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java index f1420643..8e845003 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -1,30 +1,73 @@ package org.opendevstack.apiservice.project.mapper; - +import org.mapstruct.IterableMapping; import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; +import org.opendevstack.apiservice.project.model.EnvironmentsDTO; import java.util.List; +import java.util.Map; +import java.util.UUID; @Mapper(componentModel = "spring") public interface MarketplaceMapper { - default Component mapMarketplaceComponentToV0Component(ProjectComponent source) { - if (source == null) { + @Mapping(target = "id", source = "componentId", qualifiedByName = "uuidToString") + @Mapping(target = "environment", source = "environment", qualifiedByName = "toEnvironment") + @Mapping(target = "status", source = "status", qualifiedByName = "toComponentStatus") + @Mapping(target = "params", expression = "java(java.util.Collections.emptyMap())") + @Mapping(target = "resultTraceback", ignore = true) + Component mapMarketplaceComponentToV0Component(ProjectComponent source); + + default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getParams() == null) { + return List.of(); + } + + return mapEntriesToCreateComponentParameterList(createComponentRequest.getParams().entrySet().stream().toList()); + } + + @IterableMapping(qualifiedByName = "toCreateComponentParameter") + List mapEntriesToCreateComponentParameterList(List> entries); + + @Named("toCreateComponentParameter") + @Mapping(target = "name", source = "key") + @Mapping(target = "type", constant = "string") + @Mapping(target = "value", expression = "java(String.valueOf(entry.getValue()))") + CreateComponentParameter toCreateComponentParameter(Map.Entry entry); + + @Named("uuidToString") + default String uuidToString(UUID sourceId) { + return sourceId != null ? sourceId.toString() : null; + } + + @Named("toComponentStatus") + default ComponentsStatusDTO toComponentStatus(String sourceStatus) { + if (sourceStatus == null || sourceStatus.isBlank()) { + return null; + } + try { + return ComponentsStatusDTO.fromValue(sourceStatus); + } catch (IllegalArgumentException ex) { return null; } - Component target = new Component(); - target.setId(source.getComponentId()); - target.setStatus(source.getStatus()); - return target; } - default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { - return createComponentRequest.getParams().entrySet().stream() - .map(entry -> new CreateComponentParameter(entry.getKey(), "string", entry.getValue())) - .toList(); + @Named("toEnvironment") + default EnvironmentsDTO toEnvironment(String sourceEnv) { + if (sourceEnv == null || sourceEnv.isBlank()) { + return null; + } + try { + return EnvironmentsDTO.fromValue(sourceEnv); + } catch (IllegalArgumentException ex) { + return null; + } } } diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java index a98c778a..0b67225a 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -1,26 +1,32 @@ package org.opendevstack.apiservice.project.controller; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.opendevstack.apiservice.project.model.CreateComponentResponse; -import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.*; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; -@ExtendWith(MockitoExtension.class) class ProjectComponentsControllerTest { @Mock @@ -30,71 +36,93 @@ class ProjectComponentsControllerTest { private ProjectComponentsController projectComponentsController; + private AutoCloseable openMocks; + @BeforeEach void setup() { + openMocks = MockitoAnnotations.openMocks(this); projectComponentsController = new ProjectComponentsController(componentsFacade, componentResponseMapper); } + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + @Test - void testCreateProjectComponent_whenSuccess_thenReturnOk() throws Exception { - Component testComponent = buildTestComponent(); - String testProjectId = "testProjectId"; - CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); - CreateComponentResponse testServiceResponseSuccess = buildTestCreateComponentResponseSuccess(testComponent.getId(), - testProjectId); + void create_project_component_returns_ok_when_component_is_created() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + Component createdComponent = buildTestComponent(); + createdComponent.setId("component-123"); - when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) - .thenReturn(testComponent); + when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) + .thenReturn(createdComponent); - ResponseEntity response = projectComponentsController.createProjectComponent(testProjectId, - testCreateComponentRequest); + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody()).isEqualTo(testServiceResponseSuccess); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.OK.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("000"); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/component-123"); + verify(componentsFacade).createProjectComponent(projectId, request); } @Test - void testCreateProjectComponent_whenFailure_thenReturnErrorResponse() throws Exception { - CreateComponentRequest testCreateComponentRequest = buildTestCreateComponentRequest(); - String testProjectId = "testProjectId"; - CreateComponentResponse testServiceResponseFailure = buildTestCreateComponentResponseFailure(testProjectId); + void create_project_component_returns_internal_error_when_component_creation_returns_null() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); - when(componentsFacade.createProjectComponent(anyString(), any(CreateComponentRequest.class))) + when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) .thenReturn(null); - ResponseEntity response = projectComponentsController.createProjectComponent(testProjectId, - testCreateComponentRequest); + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(ComponentCreationException.class) + .hasMessage("Failed to create component for project 'testProjectId'"); + verify(componentsFacade).createProjectComponent(projectId, request); + } - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); - assertThat(response.getBody()).isNotNull(); - assertThat(response.getBody()).isEqualTo(testServiceResponseFailure); + @Test + void create_project_component_propagates_exception_when_facade_throws_exception() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + + when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) + .thenThrow(new RuntimeException("boom")); + + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("boom"); + verify(componentsFacade).createProjectComponent(projectId, request); } @Test - void testGetProjectComponent_whenSuccess_thenReturnOk() throws Exception { + void get_project_component_returns_ok_when_component_exists() { + String projectId = "projectId"; + UUID componentId = UUID.randomUUID(); Component testComponent = buildTestComponent(); - when(componentsFacade.getProjectComponent(anyString(), anyString())) - .thenReturn(testComponent); + when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(testComponent); - ResponseEntity response = projectComponentsController.getProjectComponent("projectId", - "testId"); + ResponseEntity response = projectComponentsController.getProjectComponent(projectId, componentId); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - assertThat(response.getBody()).isNotNull(); assertThat(response.getBody()).isEqualTo(testComponent); + verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); } @Test - void testGetProjectComponent_whenFailure_thenReturnErrorResponse() throws Exception { - when(componentsFacade.getProjectComponent(anyString(), anyString())) - .thenReturn(null); + void get_project_component_throws_not_found_when_component_does_not_exist() { + String projectId = "projectId"; + UUID componentId = UUID.randomUUID(); - ResponseEntity response = projectComponentsController.getProjectComponent("projectId", - "testId"); + when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(null); - assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); - assertThat(response.getBody()).isNull(); + assertThatThrownBy(() -> projectComponentsController.getProjectComponent(projectId, componentId)) + .isInstanceOf(ComponentNotFoundException.class) + .hasMessage("Component '" + componentId + "' not found for project 'projectId'"); + verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); } } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java new file mode 100644 index 00000000..e6bced24 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -0,0 +1,167 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.model.CreateComponentResponse; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +class ProjectComponentsExceptionHandlerTest { + + @Mock + private HttpServletRequest request; + + private ProjectComponentsExceptionHandler handler; + + private AutoCloseable openMocks; + + @BeforeEach + void setup() { + openMocks = MockitoAnnotations.openMocks(this); + handler = new ProjectComponentsExceptionHandler(); + when(request.getRequestURI()).thenReturn("/api/pub/v0/projects/test-project/components/"); + } + + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + + @Test + void handle_method_argument_not_valid_exception_returns_bad_request_with_regex_error_key() throws Exception { + DummyRequest dummyRequest = new DummyRequest(); + BindingResult bindingResult = new BeanPropertyBindingResult(dummyRequest, "createComponentRequest"); + bindingResult.rejectValue("name", "Pattern", "has invalid format"); + + Method method = DummyController.class.getDeclaredMethod("dummyMethod", DummyRequest.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentNotValidException exception = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity response = handler.handleMethodArgumentNotValidException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("010"); + assertThat(response.getBody().getMessage()).isEqualTo("Field: name has invalid format"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @Test + void handle_method_argument_type_mismatch_exception_returns_bad_request() throws Exception { + Method method = DummyController.class.getDeclaredMethod("dummyMethod", DummyRequest.class); + MethodParameter methodParameter = new MethodParameter(method, 0); + MethodArgumentTypeMismatchException exception = new MethodArgumentTypeMismatchException( + "not-a-uuid", + String.class, + "componentId", + methodParameter, + new IllegalArgumentException("Invalid UUID") + ); + + ResponseEntity response = handler.handleMethodArgumentTypeMismatchException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + + @Test + void handle_component_not_found_exception_returns_not_found() { + ComponentNotFoundException exception = new ComponentNotFoundException("Component not found"); + + ResponseEntity response = handler.handleComponentNotFoundException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("013"); + assertThat(response.getBody().getMessage()).isEqualTo("Component not found"); + } + + @Test + void handle_access_denied_exception_returns_forbidden() { + AccessDeniedException exception = new AccessDeniedException("Forbidden"); + + ResponseEntity response = handler.handleAccessDeniedException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("002"); + assertThat(response.getBody().getMessage()).isEqualTo("Forbidden"); + } + + @Test + void handle_http_message_not_readable_exception_returns_bad_request() { + HttpMessageNotReadableException exception = new HttpMessageNotReadableException("Malformed JSON"); + + ResponseEntity response = handler.handleHttpMessageNotReadableException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("017"); + assertThat(response.getBody().getMessage()).isEqualTo("Params property should be a valid json."); + } + + @Test + void handle_component_creation_exception_returns_internal_server_error() { + ComponentCreationException exception = new ComponentCreationException("Creation failed"); + + ResponseEntity response = handler.handleComponentCreationException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("Creation failed"); + } + + @Test + void handle_generic_exception_returns_internal_server_error() { + RuntimeException exception = new RuntimeException("boom"); + + ResponseEntity response = handler.handleGenericException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("An error occurred while processing the request."); + } + + private static class DummyRequest { + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + } + + @SuppressWarnings("unused") + private static class DummyController { + public void dummyMethod(@ModelAttribute DummyRequest request) { + // no implementation needed for testing purposes + } + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 3935839c..978dae81 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -1,28 +1,31 @@ package org.opendevstack.apiservice.project.facade; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.MockitoAnnotations; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import java.util.List; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestMarketplaceComponent; -@ExtendWith(MockitoExtension.class) class ComponentsFacadeTest { private final MarketplaceMapper marketplaceMapper = Mappers.getMapper(MarketplaceMapper.class); @@ -32,54 +35,71 @@ class ComponentsFacadeTest { private ComponentsFacade componentsFacade; + private AutoCloseable openMocks; + @BeforeEach void setup() { + openMocks = MockitoAnnotations.openMocks(this); componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); } + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + @Test - void testGetProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { - ProjectComponent testComponent = buildTestMarketplaceComponent(); + void get_project_component_returns_mapped_component_when_marketplace_returns_data() { + ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); - when(marketplaceExternalService.getProjectComponent(anyString(), eq("testId"))) - .thenReturn(testComponent); + when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) + .thenReturn(marketplaceComponent); - Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); - assertThat(retrievedComponent.getId()).isEqualTo(testComponent.getComponentId()); - assertThat(retrievedComponent.getStatus()).isEqualTo(testComponent.getStatus()); + Component retrievedComponent = componentsFacade.getProjectComponent("testProject", "testComponent"); + + assertThat(retrievedComponent).isNotNull(); + assertThat(retrievedComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); + assertThat(retrievedComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); + verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); } @Test - void testGetProjectComponent_whenNoComponentFound_thenReturnNull() throws Exception { - when(marketplaceExternalService.getProjectComponent(anyString(), eq("testId"))) + void get_project_component_throws_not_found_when_marketplace_returns_null() { + when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(null); - Component retrievedComponent = componentsFacade.getProjectComponent("testId", "testId"); - assertThat(retrievedComponent).isNull(); + assertThatThrownBy(() -> componentsFacade.getProjectComponent("testProject", "testComponent")) + .isInstanceOf(ComponentNotFoundException.class) + .hasMessage("Component 'testComponent' not found for project 'testProject'"); + verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); } @Test - void testCreateProjectComponent_whenSuccess_thenReturnCorrectComponent() throws Exception { - ProjectComponent testComponent = buildTestMarketplaceComponent(); - CreateComponentRequest testRequest = buildTestCreateComponentRequest(); + void create_project_component_returns_mapped_component_when_marketplace_creates_component() { + ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); + CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(anyString(), any(List.class))) - .thenReturn(testComponent); + when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) + .thenReturn(marketplaceComponent); - Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); - assertThat(retrievedComponent.getId()).isEqualTo(testComponent.getComponentId()); - assertThat(retrievedComponent.getStatus()).isEqualTo(testComponent.getStatus()); - } + Component createdComponent = componentsFacade.createProjectComponent("testProject", request); + assertThat(createdComponent).isNotNull(); + assertThat(createdComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); + assertThat(createdComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); + verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); + } @Test - void testCreateProjectComponent_whenFailure_thenReturnNull() throws Exception { - CreateComponentRequest testRequest = buildTestCreateComponentRequest(); + void create_project_component_throws_creation_exception_when_marketplace_returns_null() { + CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(anyString(), any(List.class))) + when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) .thenReturn(null); - Component retrievedComponent = componentsFacade.createProjectComponent("testId", testRequest); - assertThat(retrievedComponent).isNull(); + assertThatThrownBy(() -> componentsFacade.createProjectComponent("testProject", request)) + .isInstanceOf(ComponentCreationException.class) + .hasMessage("Failed to create component for project 'testProject'"); + verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); } } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java index 14cc84f7..0b81f7bd 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -1,14 +1,13 @@ package org.opendevstack.apiservice.project.util; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; import org.opendevstack.apiservice.project.model.Component; +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; -import org.opendevstack.apiservice.project.model.CreateComponentResponse; -import org.springframework.http.HttpStatus; +import org.opendevstack.apiservice.project.model.EnvironmentsDTO; -import java.util.ArrayList; -import java.util.List; +import java.util.HashMap; +import java.util.UUID; public class TestObjectsBuilder { @@ -19,14 +18,17 @@ public static Component buildTestComponent() { Component component = new Component(); component.setId("testId"); component.setName("testComponentName"); - component.environment("testEnv"); + component.setEnvironment(EnvironmentsDTO.DEV); + component.setStatus(ComponentsStatusDTO.RUNNING); component.setComponentType("testComponentType"); + component.setParams(new HashMap<>()); return component; } public static ProjectComponent buildTestMarketplaceComponent() { ProjectComponent component = new ProjectComponent(); - component.setComponentId("testComponentId"); + component.setComponentId(UUID.randomUUID()); + component.setStatus("RUNNING"); component.setCanBeDeleted(false); component.setComponentUrl("http://test.component.url"); return component; @@ -34,29 +36,9 @@ public static ProjectComponent buildTestMarketplaceComponent() { public static CreateComponentRequest buildTestCreateComponentRequest() { CreateComponentRequest request = new CreateComponentRequest(); - request.setName("testComponentName"); + request.setName("testcomponent"); request.setProductId("testProductId"); + request.setParams(new HashMap<>()); return request; } - - public static List buildTestMarketplaceCreateComponentParameters() { - List parameters = new ArrayList<>(); - parameters.add(new CreateComponentParameter("name", "string", "testComponentName")); - parameters.add(new CreateComponentParameter("productId", "string", "testProductId")); - return parameters; - } - - public static CreateComponentResponse buildTestCreateComponentResponseSuccess(String componentId, String projectId) { - CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.CREATED.value()); - response.setMessage(componentId + " component created successfully in project " + projectId); - return response; - } - - public static CreateComponentResponse buildTestCreateComponentResponseFailure(String projectId) { - CreateComponentResponse response = new CreateComponentResponse(); - response.setErrorCode(HttpStatus.INTERNAL_SERVER_ERROR.value()); - response.setMessage("Failed to create component for project '" + projectId + "'"); - return response; - } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java index 6f76d9ea..e866b9b4 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -81,7 +81,7 @@ public ResponseEntity handleValidationException(ProjectVa response.setLocation(ProjectController.API_BASE_PATH); response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); response.setErrorKey(errorKey.getKey()); - response.setMessage(errorKey.getMessage()); + response.setMessage(ex.getMessage()); // Needs to get the full message with additional info provided. return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java index fd7904b8..2b1718a0 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -2,10 +2,10 @@ public enum ErrorKey { - OK("000", "Success"), + OK("000", ErrorMessage.SUCCESS), PRODUCT_NOT_FOUND("001", ErrorMessage.NOT_FOUND), ACCESS_DENIED("002", ErrorMessage.FORBIDDEN), - INTERNAL_ERROR("003", "Internal error"), + INTERNAL_ERROR("003", ErrorMessage.INTERNAL_ERROR), INVALID_AUTH_HEADER("004", ErrorMessage.BAD_REQUEST), MISSING_AUTH_HEADER("005", ErrorMessage.BAD_REQUEST), INVALID_PARAMETERS("006", ErrorMessage.BAD_REQUEST), @@ -13,26 +13,26 @@ public enum ErrorKey { ONLY_INVITED_PROJECT("008", ErrorMessage.FORBIDDEN), ONCE_PER_PROJECT("009", ErrorMessage.FORBIDDEN), COMPONENT_PARAM_NOT_MEET_REGEX("010", ErrorMessage.BAD_REQUEST), - INVALID_LOCATION("011", ErrorMessage.BAD_REQUEST), + INVALID_LOCATION("011", ErrorMessage.INVALID_LOCATION), PROJECT_NOT_FOUND("012", ErrorMessage.NOT_FOUND), COMPONENT_NOT_FOUND("013", ErrorMessage.NOT_FOUND), BAD_REQUEST_BODY("014", ErrorMessage.BAD_REQUEST), FORBIDDEN("015", ErrorMessage.FORBIDDEN), - DUPLICATE_RECORD("016", "Record already exists"), + DUPLICATE_RECORD("016", ErrorMessage.RECORD_ALREADY_EXISTS), COMPONENT_PARAM_INVALID_FORMAT("017", ErrorMessage.BAD_REQUEST), - PROJECT_KEY_INVALID_FORMAT("018", "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"), - PROJECT_NAME_INVALID_FORMAT("019", "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"), - PROJECT_DESCRIPTION_INVALID_FORMAT("020", "projectDescription not met the pattern ^.{0,255}$"), - PROJECT_OWNER_INVALID_FORMAT("021", "projectOwner not met the pattern ^[a-z]{1,10}$"), - PROJECT_X2ACCOUNT_INVALID_FORMAT("022", "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"), - BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", "Project flavour and config item cannot be both null"), - MANDATORY_OWNER("024", "Owner must be present if the X2 account is present"), - PROJECT_ALREADY_EXISTS("025", "Project already exists"), - PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", "Project with same project name already exists"), - CLIENT_APP_NOT_REGISTERED("027", "ClientApp not registered, manual registration required"), + PROJECT_KEY_INVALID_FORMAT("018", ErrorMessage.PROJECT_KEY_NOT_MET_THE_PATTERN), + PROJECT_NAME_INVALID_FORMAT("019", ErrorMessage.PROJECT_NAME_NOT_MET_THE_PATTERN), + PROJECT_DESCRIPTION_INVALID_FORMAT("020", ErrorMessage.PROJECT_DESCRIPTION_NOT_MET_THE_PATTERN), + PROJECT_OWNER_INVALID_FORMAT("021", ErrorMessage.PROJECT_OWNER_NOT_MET_THE_PATTERN), + PROJECT_X2ACCOUNT_INVALID_FORMAT("022", ErrorMessage.PROJECT_X_2_ACCOUNT_NOT_MET_THE_PATTERN), + BAD_REQUEST_FLAVOR_CONFIG_ITEM("023", ErrorMessage.PROJECT_FLAVOUR_AND_CONFIG_ITEM_CANNOT_BE_BOTH_NULL), + MANDATORY_OWNER("024", ErrorMessage.OWNER_MUST_BE_PRESENT_IF_THE_X_2_ACCOUNT_IS_PRESENT), + PROJECT_ALREADY_EXISTS("025", ErrorMessage.PROJECT_ALREADY_EXISTS), + PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", ErrorMessage.PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS), + CLIENT_APP_NOT_REGISTERED("027", ErrorMessage.CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED), INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); - + private String key; private String message; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java index 767ab2e2..5547bbdd 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -5,6 +5,21 @@ public class ErrorMessage { public static final String NOT_FOUND = "Not Found"; public static final String FORBIDDEN = "Forbidden"; public static final String BAD_REQUEST = "Bad Request"; + public static final String INTERNAL_ERROR = "Internal error"; + public static final String SUCCESS = "Success"; + + public static final String INVALID_LOCATION = "Incorrect location. Valid locations are:"; + public static final String RECORD_ALREADY_EXISTS = "Record already exists"; + public static final String PROJECT_KEY_NOT_MET_THE_PATTERN = "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"; + public static final String PROJECT_NAME_NOT_MET_THE_PATTERN = "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"; + public static final String PROJECT_DESCRIPTION_NOT_MET_THE_PATTERN = "projectDescription not met the pattern ^.{0,255}$"; + public static final String PROJECT_OWNER_NOT_MET_THE_PATTERN = "projectOwner not met the pattern ^[a-z]{1,10}$"; + public static final String PROJECT_X_2_ACCOUNT_NOT_MET_THE_PATTERN = "projectX2Account not met the pattern ^x2[a-zA-Z0-9]{0,13}$"; + public static final String PROJECT_FLAVOUR_AND_CONFIG_ITEM_CANNOT_BE_BOTH_NULL = "Project flavour and config item cannot be both null"; + public static final String OWNER_MUST_BE_PRESENT_IF_THE_X_2_ACCOUNT_IS_PRESENT = "Owner must be present if the X2 account is present"; + public static final String PROJECT_ALREADY_EXISTS = "Project already exists"; + public static final String PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS = "Project with same project name already exists"; + public static final String CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED = "ClientApp not registered, manual registration required"; private ErrorMessage() { // prevent instantiation diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java index 8b656e5b..2694cfa3 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectValidationException.java @@ -11,5 +11,10 @@ public ProjectValidationException(ErrorKey errorKey) { super(errorKey.getMessage()); this.errorKey = errorKey; } + + public ProjectValidationException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java index effe5729..f3ea98e7 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -56,7 +56,8 @@ private void validateLocation(CreateProjectRequest request) { } if (!locations.contains(location)) { - throw new ProjectValidationException(ErrorKey.INVALID_LOCATION); + throw new ProjectValidationException(ErrorKey.INVALID_LOCATION, + String.join(", ", locations)); } } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java index 45435a01..83942c6c 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -22,6 +22,7 @@ import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; +import java.util.List; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -96,6 +97,25 @@ void handle_validation_exception_returns_bad_request_response_for_missing_flavor assertNull(result.getBody().getErrorDescription()); } + @Test + void handle_validation_exception_preserves_additional_message_content() { + List validLocations = List.of("MADRID", "BARCELONA", "SANT_CUGAT"); + ProjectValidationException exception = new ProjectValidationException( + ErrorKey.INVALID_LOCATION, + String.join(",", validLocations) + ); + + ResponseEntity result = sut.handleValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals("011", result.getBody().getErrorKey()); + assertEquals( + "Incorrect location. Valid locations are: MADRID,BARCELONA,SANT_CUGAT", + result.getBody().getMessage() + ); + } + @Test void handle_method_argument_not_valid_exception_returns_bad_request_response_for_request_body_validation_errors() { CreateProjectRequest target = new CreateProjectRequest(); diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java index 9da0b9a0..9fd93c79 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/authorization/evaluator/FlavorRestrictionEvaluator.java @@ -37,7 +37,7 @@ public AuthorizationDecision evaluate(PolicyContext context) { } String requestedFlavor = (String) body.get("projectFlavor"); - if (requestedFlavor == null) { + if (requestedFlavor == null || requestedFlavor.trim().isEmpty()) { return AuthorizationDecision.ABSTAIN; } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java index 89b640b9..5ca9c009 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/ProjectComponent.java @@ -4,13 +4,23 @@ import lombok.Data; import lombok.NoArgsConstructor; +import java.util.UUID; + @NoArgsConstructor @AllArgsConstructor @Data public class ProjectComponent { - private String componentId; + private UUID componentId; + private String name; + private String productDescription; + private String productName; + private String productId; + private String environment; private String status; + private String resultTraceback; + private String repositoryURL; + private String componentType; private boolean canBeDeleted; private String logoUrl; private String componentUrl; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java index 2301a76a..ec1007c0 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java @@ -6,10 +6,10 @@ import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.springframework.stereotype.Service; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; @Service @Slf4j @@ -31,17 +31,27 @@ public ProjectComponent getProjectComponent(String projectId, String componentId public ProjectComponent createProjectComponent(String projectId, List createComponentParams) { log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentParams); ProjectComponent mockComponent = new ProjectComponent(); - mockComponent.setComponentId(generateNextId()); + mockComponent.setComponentId(UUID.randomUUID()); mockComponent.setCanBeDeleted(true); mockComponent.setStatus("CREATING"); - ComposedId composedId = new ComposedId(projectId, mockComponent.getComponentId()); + mockComponent.setName(extractParam(createComponentParams, "component_id")); + mockComponent.setProductId(extractParam(createComponentParams, "component_type")); + mockComponent.setProductName("Mock Product"); + mockComponent.setProductDescription("Mock product description"); + mockComponent.setEnvironment("DEV"); + mockComponent.setComponentType("ODS"); + ComposedId composedId = new ComposedId(projectId, mockComponent.getComponentId().toString()); mockComponentsCache.put(composedId, mockComponent); return mockComponent; } - private String generateNextId() { - return "mock-component-id-" + (mockComponentsCache.size() + 1); + private String extractParam(List params, String key) { + return params.stream() + .filter(p -> key.equals(p.getName())) + .map(CreateComponentParameter::getValue) + .findFirst() + .orElse(null); } class ComposedId { diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java index a4483767..02149251 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -12,9 +12,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; + import java.util.Comparator; import java.util.Set; +@Slf4j @Service public class ProjectExistenceServiceImpl implements ProjectExistenceService { @@ -31,7 +34,6 @@ public ProjectExistenceServiceImpl(BitbucketService bitbucketService, JiraServic this.openshiftService = openshiftService; this.projectService = projectService; } - @Override public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException { try { @@ -46,6 +48,7 @@ public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceE } catch (OpenshiftException e) { throw new ProjectExistenceServiceException("Failed to check project in Openshift", e); } catch (RuntimeException e) { + log.error("Unexpected error while checking project existence for key '{}'", projectKey, e); throw new ProjectExistenceServiceException("Unexpected error while checking project existence", e); } } From bcbbb1162a3d89aeb94a9af39f846b9f81d82340 Mon Sep 17 00:00:00 2001 From: Angel MP Date: Mon, 27 Apr 2026 15:46:41 +0200 Subject: [PATCH 12/27] Add ProjectAlreadyExistsException and enhance project creation validations (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …tion --- .../controller/ProjectResponseFactory.java | 30 ------------ .../advice/ProjectExceptionHandler.java | 23 ++++++++-- .../ProjectAlreadyExistsException.java | 19 ++++++++ .../impl/ProjectCreationCommandBuilder.java | 26 +++++++++-- .../advice/ProjectExceptionHandlerTest.java | 30 ++++++++++++ .../ProjectCreationCommandBuilderTest.java | 32 ++++++++++++- .../repository/ProjectRepository.java | 2 + .../mapper/ProjectResponseMapper.java | 7 +++ .../service/ProjectExistenceService.java | 2 + .../service/ProjectService.java | 4 ++ .../impl/ProjectExistenceServiceImpl.java | 5 ++ .../service/impl/ProjectServiceImpl.java | 7 +++ .../impl/ProjectExistenceServiceImplTest.java | 25 +++++++++- .../service/impl/ProjectServiceImplTest.java | 46 ++++++++++++++++++- 14 files changed, 216 insertions(+), 42 deletions(-) create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java index caf14c9d..b28ca793 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectResponseFactory.java @@ -8,20 +8,6 @@ public final class ProjectResponseFactory { private ProjectResponseFactory() { } - public static CreateProjectResponse conflict(String message, String location) { - return error( - ErrorKey.PROJECT_ALREADY_EXISTS.getMessage(), - ErrorKey.PROJECT_ALREADY_EXISTS.getKey(), - message, location); - } - - public static CreateProjectResponse projectKeyGenerationFailed(String location) { - return error(ErrorKey.INTERNAL_ERROR.getMessage(), - "PROJECT_KEY_GENERATION_FAILED", - "Failed to generate a unique project key.", - location); - } - public static CreateProjectResponse notFound(String projectKey, String location) { return error( ErrorKey.PROJECT_NOT_FOUND.getMessage(), @@ -31,22 +17,6 @@ public static CreateProjectResponse notFound(String projectKey, String location) ); } - public static CreateProjectResponse internalError(String location) { - return error( - ErrorKey.INTERNAL_ERROR.getMessage(), - ErrorKey.INTERNAL_ERROR.getKey(), - "An error occurred while processing the request.", - location); - } - - public static CreateProjectResponse internalError(String location, String message) { - return error( - ErrorKey.INTERNAL_ERROR.getMessage(), - ErrorKey.INTERNAL_ERROR.getKey(), - message, - location); - } - private static CreateProjectResponse error(String error, String errorKey, String message, String location) { CreateProjectResponse response = new CreateProjectResponse(); response.setError(error); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java index e866b9b4..e9170065 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandler.java @@ -5,6 +5,7 @@ import org.opendevstack.apiservice.project.controller.ProjectController; import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectResponse; @@ -81,10 +82,24 @@ public ResponseEntity handleValidationException(ProjectVa response.setLocation(ProjectController.API_BASE_PATH); response.setError(HttpStatus.BAD_REQUEST.getReasonPhrase()); response.setErrorKey(errorKey.getKey()); - response.setMessage(ex.getMessage()); // Needs to get the full message with additional info provided. + response.setMessage(ex.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } + @ExceptionHandler(ProjectAlreadyExistsException.class) + public ResponseEntity handleProjectAlreadyExistsException(ProjectAlreadyExistsException ex) { + log.warn("Validation error: {}", ex.getMessage()); + ErrorKey errorKey = ex.getErrorKey() != null + ? ex.getErrorKey() + : ErrorKey.PROJECT_ALREADY_EXISTS; + CreateProjectResponse response = new CreateProjectResponse(); + response.setLocation(ProjectController.API_BASE_PATH); + response.setError(HttpStatus.CONFLICT.getReasonPhrase()); + response.setErrorKey(errorKey.getKey()); + response.setMessage(errorKey.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + @ExceptionHandler(ProjectCreationException.class) public ResponseEntity handleProjectCreationException( ProjectCreationException ex) { @@ -114,9 +129,9 @@ public ResponseEntity handleHttpMessageNotReadableExcepti log.error("Unexpected error: {}", ex.getMessage(), ex); CreateProjectResponse response = new CreateProjectResponse(); response.setLocation(ProjectController.API_BASE_PATH); - response.setError(ErrorKey.BAD_REQUEST_BODY.getMessage()); - response.setErrorKey(ErrorKey.BAD_REQUEST_BODY.getKey()); - response.setMessage("An error occurred while processing the request."); + response.setError(ErrorKey.COMPONENT_PARAM_INVALID_FORMAT.getMessage()); + response.setErrorKey(ErrorKey.COMPONENT_PARAM_INVALID_FORMAT.getKey()); + response.setMessage("Request body should be a valid json."); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java new file mode 100644 index 00000000..f99bdd0d --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectAlreadyExistsException.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectAlreadyExistsException extends RuntimeException { + + private ErrorKey errorKey; + + public ProjectAlreadyExistsException() { + super(); + } + + public ProjectAlreadyExistsException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } +} + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java index 10b087dd..15866caa 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -6,6 +6,7 @@ import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; @@ -15,6 +16,7 @@ import org.opendevstack.apiservice.serviceproject.service.ProjectExistenceService; import org.springframework.stereotype.Component; +import java.text.MessageFormat; import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -37,7 +39,7 @@ public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntit String x2account = firstNonBlank(request.getX2OdsAccount(), flavor.getServiceAccount()); String location = firstNonBlank(request.getLocation(), flavor.getLocation()); String projectKey = resolveProjectKey(request.getProjectKey(), flavor); - String projectName = firstNonBlank(request.getProjectName(), projectKey); + String projectName = resolveProjectName(request.getProjectName(), projectKey); String projectDescription = firstNonBlank(request.getProjectDescription(), "project " + projectFlavor); return new ProjectCreationCommand( @@ -91,7 +93,11 @@ private ClientAppProjectFlavorEntity resolveByConfigurationItem( if (matchingFlavors.size() != 1) { log.warn("ConfigItem '{}' does not match exactly one flavor for clientApp '{}'", configurationItem, clientId); - throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM); + String message = MessageFormat. + format("Not exists a project flavor configured for the Config Item {0}. " + + "To create a project under {0} the projectFlavor parameter is mandatory.", + configurationItem); + throw new ProjectValidationException(ErrorKey.INVALID_CONFIG_ITEM, message); } ClientAppProjectFlavorEntity matchedFlavor = matchingFlavors.getFirst(); @@ -112,7 +118,7 @@ private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlav return existingProjectKey; } - throw new ProjectValidationException(ErrorKey.PROJECT_ALREADY_EXISTS); + throw new ProjectAlreadyExistsException(ErrorKey.PROJECT_ALREADY_EXISTS); } String pattern = flavor.getProjectKeyPattern(); @@ -138,5 +144,19 @@ private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFl private String firstNonBlank(String preferred, String fallback) { return Strings.isNotEmpty(preferred) ? preferred : fallback; } + + private String resolveProjectName(String preferred, String fallback) { + if (!Strings.isEmpty(preferred)) { + try { + if (projectExistenceService.isProjectFoundByName(preferred)) { + throw new ProjectAlreadyExistsException(ErrorKey.PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS); + } + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if project name already exists: " + e.getMessage(), e); + } + } + + return firstNonBlank(preferred, fallback); + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java index 83942c6c..e9db30aa 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectExceptionHandlerTest.java @@ -11,6 +11,7 @@ import org.opendevstack.apiservice.project.controller.ProjectController; import org.opendevstack.apiservice.project.exception.ClientAppNotRegisteredException; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; @@ -18,6 +19,7 @@ import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BeanPropertyBindingResult; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -287,4 +289,32 @@ void handle_generic_exception_returns_internal_server_error() { assertNull(result.getBody().getStatus()); assertNull(result.getBody().getErrorDescription()); } + + @Test + void handle_http_message_not_readable_exception_returns_bad_request_with_error_key_017() { + HttpMessageNotReadableException exception = new HttpMessageNotReadableException("Malformed JSON", new RuntimeException("cause")); + + ResponseEntity result = sut.handleHttpMessageNotReadableException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Bad Request", result.getBody().getError()); + assertEquals("017", result.getBody().getErrorKey()); + assertEquals("Request body should be a valid json.", result.getBody().getMessage()); + } + + @Test + void handle_project_already_exists_exception_returns_conflict() { + ProjectAlreadyExistsException exception = new ProjectAlreadyExistsException(); + + ResponseEntity result = sut.handleProjectAlreadyExistsException(exception); + + assertEquals(HttpStatus.CONFLICT, result.getStatusCode()); + assertNotNull(result.getBody()); + assertEquals(ProjectController.API_BASE_PATH, result.getBody().getLocation()); + assertEquals("Conflict", result.getBody().getError()); + assertEquals("025", result.getBody().getErrorKey()); + assertEquals("Project already exists", result.getBody().getMessage()); + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java index c537efd3..0c71c77b 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -8,6 +8,7 @@ import org.opendevstack.apiservice.persistence.entity.ClientAppEntity; import org.opendevstack.apiservice.persistence.entity.ClientAppProjectFlavorEntity; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ProjectCreationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; @@ -97,14 +98,14 @@ void build_generates_project_key_when_request_project_key_is_null() throws Proje } @Test - void build_throws_validation_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + void build_throws_project_already_exists_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); when(projectExistenceService.isProjectFound("KEY01")).thenReturn(true); - ProjectValidationException ex = assertThrows(ProjectValidationException.class, + ProjectAlreadyExistsException ex = assertThrows(ProjectAlreadyExistsException.class, () -> sut.build(request, clientApp)); assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); } @@ -144,6 +145,33 @@ void build_throws_project_key_generation_exception_when_generation_fails() throw assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); } + @Test + void build_throws_project_already_exists_exception_when_project_name_already_exists() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setProjectName("Existing Project"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + when(projectExistenceService.isProjectFoundByName("Existing Project")).thenReturn(true); + + assertThrows(ProjectAlreadyExistsException.class, () -> sut.build(request, clientApp)); + } + + @Test + void build_throws_project_creation_exception_when_project_name_check_fails() throws Exception { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setProjectName("Any Project"); + + when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); + when(projectExistenceService.isProjectFoundByName("Any Project")) + .thenThrow(new ProjectExistenceServiceException("lookup failed")); + + assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); + } + private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectKey(projectKey); diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java index 2a5dc000..7393dc5e 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/ProjectRepository.java @@ -22,6 +22,8 @@ public interface ProjectRepository extends JpaRepository { Optional findByProjectKeyIgnoreCase(String projectKey); + + List findByProjectNameIgnoreCase(String projectName); List findByDeletedFalse(); diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java index 72060ad6..199a43ba 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/mapper/ProjectResponseMapper.java @@ -1,8 +1,11 @@ package org.opendevstack.apiservice.serviceproject.mapper; import java.util.Arrays; +import java.util.List; + import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.IterableMapping; import org.mapstruct.Named; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; @@ -13,8 +16,12 @@ public interface ProjectResponseMapper { @Mapping(source = "status", target = "status", qualifiedByName = "mapStatus") @Mapping(source = "id", target = "projectId") + @Named("mapEntityToResponse") ProjectResponse toCreateProjectResponse(ProjectEntity entity); + @IterableMapping(qualifiedByName = "mapEntityToResponse") + List toCreateProjectResponse(List entities); + @Named("mapStatus") default Status mapStatus(String value) { if (value == null) { diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java index ed13614d..39330437 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java @@ -5,4 +5,6 @@ public interface ProjectExistenceService { boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException; + + boolean isProjectFoundByName(String projecName) throws ProjectExistenceServiceException; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java index f2ba3aad..3a23a8a4 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -3,10 +3,14 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; +import java.util.List; + public interface ProjectService { ProjectResponse saveProject(ProjectRequest request); ProjectResponse getProject(String projectKey); + + List findProjectsByName(String projectName); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java index 02149251..c5762567 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -53,6 +53,11 @@ public boolean isProjectFound(String projectKey) throws ProjectExistenceServiceE } } + @Override + public boolean isProjectFoundByName(String projecName) throws ProjectExistenceServiceException { + return !projectService.findProjectsByName(projecName).isEmpty(); + } + private boolean existsInCollection(String projectKey) { return projectService.getProject(projectKey) != null; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index cdbddddd..a7e09956 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.List; import java.util.Optional; @Service @@ -61,6 +62,12 @@ public ProjectResponse getProject(String projectKey) { return null; } + @Override + public List findProjectsByName(String projectName) { + List projects = projectRepository.findByProjectNameIgnoreCase(projectName); + return projectResponseMapper.toCreateProjectResponse(projects); + } + private String getLdapGroup(String role, String projectKey) { return ldapGroupPattern .replace("{{projectKey}}", projectKey) diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java index aaabce3d..b4372918 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImplTest.java @@ -11,6 +11,7 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.service.ProjectService; +import java.util.List; import java.util.Collections; import java.util.Set; @@ -35,8 +36,8 @@ class ProjectExistenceServiceImplTest { private ProjectExistenceServiceImpl sut; @BeforeEach - void setUp() throws Exception { - MockitoAnnotations.openMocks(this).close(); + void setUp() { + MockitoAnnotations.openMocks(this); sut = new ProjectExistenceServiceImpl(bitbucketService, jiraService, openshiftService, projectService); } @@ -134,4 +135,24 @@ void is_project_found_throws_exception_when_openshift_fails() { sut.isProjectFound("KEY8"); }); } + + @Test + void is_project_found_by_name_returns_true_when_project_exists_in_db() throws Exception { + when(projectService.findProjectsByName("Project One")).thenReturn(List.of(new ProjectResponse())); + + boolean result = sut.isProjectFoundByName("Project One"); + + assertTrue(result); + verify(projectService).findProjectsByName("Project One"); + } + + @Test + void is_project_found_by_name_returns_false_when_project_does_not_exist_in_db() throws Exception { + when(projectService.findProjectsByName("Missing Project")).thenReturn(List.of()); + + boolean result = sut.isProjectFoundByName("Missing Project"); + + assertFalse(result); + verify(projectService).findProjectsByName("Missing Project"); + } } \ No newline at end of file diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java index 51adb000..cadb492f 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -10,6 +10,7 @@ import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; import org.opendevstack.apiservice.serviceproject.model.Status; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -18,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -89,7 +91,7 @@ void get_project_returns_null_when_project_does_not_exist() { assertNull(result); verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); - verify(projectResponseMapper, never()).toCreateProjectResponse(any()); + verify(projectResponseMapper, never()).toCreateProjectResponse(any(ProjectEntity.class)); } @Test @@ -145,4 +147,46 @@ void get_project_returns_response_for_soft_deleted_project() { assertEquals(projectKey, result.getProjectKey()); verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); } + + @Test + void find_projects_by_name_returns_responses_when_projects_exist() { + String projectName = "My Project"; + + ProjectEntity projectEntity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey("MY-PROJECT") + .projectName(projectName) + .build(); + + ProjectResponse expectedResponse = ProjectResponse.builder() + .projectKey("MY-PROJECT") + .status(Status.RUNNING) + .build(); + + when(projectRepository.findByProjectNameIgnoreCase(projectName)).thenReturn(List.of(projectEntity)); + when(projectResponseMapper.toCreateProjectResponse(List.of(projectEntity))).thenReturn(List.of(expectedResponse)); + + List result = projectService.findProjectsByName(projectName); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("MY-PROJECT", result.get(0).getProjectKey()); + verify(projectRepository).findByProjectNameIgnoreCase(projectName); + verify(projectResponseMapper).toCreateProjectResponse(List.of(projectEntity)); + } + + @Test + void find_projects_by_name_returns_empty_list_when_project_does_not_exist() { + String projectName = "Unknown Project"; + + when(projectRepository.findByProjectNameIgnoreCase(projectName)).thenReturn(List.of()); + when(projectResponseMapper.toCreateProjectResponse(anyList())).thenReturn(List.of()); + + List result = projectService.findProjectsByName(projectName); + + assertNotNull(result); + assertEquals(0, result.size()); + verify(projectRepository).findByProjectNameIgnoreCase(projectName); + verify(projectResponseMapper).toCreateProjectResponse(anyList()); + } } \ No newline at end of file From d619cd1b42bd9e4d9d69596ede2f513ac5f1c5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Mart=C3=ADn?= Date: Thu, 30 Apr 2026 09:29:13 +0200 Subject: [PATCH 13/27] Project registration without automation execution (#21) --- api-project/openapi/api-project.yaml | 6 ++- .../facade/impl/ProjectsFacadeImpl.java | 51 ++++++++++++++----- .../facade/impl/ProjectsFacadeImplTest.java | 36 +++++++++++++ 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 26f00f7b..efb1c04c 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.4 +openapi: 3.0.3 info: title: ODS API Server description: API documentation for ODS (Open DevStack) API Service @@ -153,6 +153,10 @@ components: type: string description: Owner of the project. If x2OdsAccount is provided but owner is missing, error MANDATORY_OWNER (024) is returned. pattern: "^[a-z]{1,10}$" + registrationOnly: + type: boolean + description: Indicates whether the project should be only registrated or a proper initialization is required. (Default false) + default: false oneOf: - required: [projectFlavor] - required: [configurationItem] diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 87a9046c..746fcd4f 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -67,30 +67,57 @@ public CreateProjectResponse createProject(CreateProjectRequest request, UUID cl ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp); + ProjectResponse project; + + if (Boolean.TRUE.equals(request.getRegistrationOnly())) { + project = registerProject(command); + } else { + project = createNewProject(command); + } + + return projectCreationResponseMapper.toSuccessResponse(command, project); + } + + @Override + public CreateProjectResponse getProject(String projectKey) { + return projectMapper.toApiResponse(projectService.getProject(projectKey)); + } + + private ProjectResponse registerProject(ProjectCreationCommand command) { + + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); + projectRequest.setStatus(Status.RUNNING); + projectRequest.setProjectFlavor("REGULAR"); + + return projectService.saveProject(projectRequest); + } + + private ProjectResponse createNewProject(ProjectCreationCommand command) { + ProjectRequest projectRequest = projectMapper.toServiceRequest(command); projectRequest.setStatus(Status.PENDING); - + ProjectResponse project = projectService.saveProject(projectRequest); + initializeProject(command, projectRequest, project); + + return project; + } + + private void initializeProject( + ProjectCreationCommand command, ProjectRequest projectRequest, ProjectResponse project) { String projectId = project.getProjectId().toString(); Map workflowParameters = automationParametersMapper.toWorkflowParameters(command, projectId); - + AutomationExecutionResult automationExecutionResult = automationPlatformService .executeWorkflow(createProjectWorkflow, workflowParameters); - if (automationExecutionResult.isSuccessful()) { - return projectCreationResponseMapper.toSuccessResponse(command, project); - } else { + if (!automationExecutionResult.isSuccessful()) { projectRequest.setProjectId(project.getProjectId()); projectRequest.setStatus(Status.FAILED); projectService.saveProject(projectRequest); - throw new ProjectCreationException("Failed to create project: " + throw new ProjectCreationException("Failed to create project: " + automationExecutionResult.getErrorDetails()); - } - } - - @Override - public CreateProjectResponse getProject(String projectKey) { - return projectMapper.toApiResponse(projectService.getProject(projectKey)); + } } } \ No newline at end of file diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index eb15edd8..dd622efc 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -30,6 +30,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -120,6 +121,41 @@ void create_project_returns_success_response_when_automation_is_successful() { verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); } + @Test + void create_project_with_registrationOnly_returns_success_response_when_automation_is_not_executed() { + CreateProjectRequest request = new CreateProjectRequest(); + request.setRegistrationOnly(true); + ClientAppEntity clientApp = ClientAppEntity.builder().clientId(CLIENT_ID).build(); + ProjectCreationCommand command = new ProjectCreationCommand( + "P3RO01", "name", "desc", null, "CI-001", "eu", "x2test", "owner", CLIENT_ID); + ProjectRequest serviceRequest = new ProjectRequest(); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectId(UUID.fromString("11111111-1111-1111-1111-111111111111")) + .projectKey("P3RO01") + .status(Status.RUNNING) + .build(); + CreateProjectResponse apiResponse = new CreateProjectResponse(); + apiResponse.setStatus("Running"); + apiResponse.setProjectFlavor("REGULAR"); + + when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); + when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); + when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); + when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); + + CreateProjectResponse result = sut.createProject(request, CLIENT_ID); + + assertEquals("Running", result.getStatus()); + assertEquals("REGULAR", result.getProjectFlavor()); + verify(projectCreationCommandBuilder).build(request, clientApp); + verify(projectMapper).toServiceRequest(command); + verify(automationParametersMapper, never()) + .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); + verify(automationPlatformService, never()).executeWorkflow(anyString(), anyMap()); + verify(projectCreationResponseMapper).toSuccessResponse(command, projectResponse); + } + @Test void create_project_throws_project_creation_exception_when_automation_is_not_successful() { CreateProjectRequest request = new CreateProjectRequest(); From 44befbb8bdbd6c38235b4aa1fb3607ddac1852c1 Mon Sep 17 00:00:00 2001 From: Vali Tuguran Date: Tue, 5 May 2026 18:06:38 +0200 Subject: [PATCH 14/27] Add marketplace 2.0 api client (#18) Add Marketplace 2.0 client with config. --------- Co-authored-by: zxBCN Valeriu_Tuguran,Constantin (IT EDP) EXTERNAL Co-authored-by: Angel Martinez Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .gitignore | 2 + Makefile | 17 + .../openapi/api-project-component-v0.yaml | 1 - api-project-component-v0/pom.xml | 6 + .../controller/ComponentsResponseFactory.java | 8 + .../ProjectComponentsController.java | 18 +- .../ProjectComponentsExceptionHandler.java | 52 + .../CatalogItemNotFoundException.java | 12 + .../ComponentAlreadyExistsException.java | 12 + .../ComponentBadRequestException.java | 12 + .../exception/ComponentCreationException.java | 4 + .../exception/ComponentNotFoundException.java | 4 + .../ComponentRetrievalException.java | 12 + .../project/facade/ComponentsFacade.java | 147 +- .../project/mapper/MarketplaceMapper.java | 114 +- .../apiservice/project/mapper/StatusMap.java | 26 + .../ProjectComponentsControllerTest.java | 56 +- ...ProjectComponentsExceptionHandlerTest.java | 15 + .../project/facade/ComponentsFacadeTest.java | 82 +- .../project/mapper/MarketplaceMapperTest.java | 107 ++ .../project/util/TestObjectsBuilder.java | 22 +- api-project/pom.xml | 6 + .../project/controller/ProjectController.java | 4 +- .../project/util/SecurityUtils.java | 33 - application.yaml | 26 + .../core/security/jwt/JwtUtils.java | 68 + .../core/security/obo/OboTokenException.java | 12 + .../core/security/obo/OboTokenProperties.java | 17 + .../core/security/obo/OboTokenResponse.java | 25 + .../core/security/obo/OboTokenService.java | 68 + .../core/security/jwt/JwtUtilsTest.java | 132 ++ .../security/obo/OboTokenServiceTest.java | 129 ++ .../.openapi-generator-ignore | 43 + .../openapi-component_catalog-v1.0.0.yaml | 1366 +++++++++++++++++ .../openapi-component_provisioner-v1.0.0.yaml | 443 ++++++ external-service-marketplace/pom.xml | 122 ++ .../client/MarketplaceApiClient.java | 70 + .../client/MarketplaceApiClientFactory.java | 215 +++ .../config/MarketplaceInstanceConfig.java | 38 + .../config/MarketplaceServiceConfig.java | 25 + .../exception/MarketplaceException.java | 12 + .../model/CreateComponentParameter.java | 17 - .../service/CatalogItemOperations.java | 29 + .../service/MarketplaceService.java | 37 +- .../service/impl/MarketplaceServiceImpl.java | 352 +++++ .../impl/MarketplaceServiceMockImpl.java | 84 - .../MarketplaceApiClientFactoryTest.java | 163 ++ .../service/CatalogItemOperationsTest.java | 39 + .../service/MarketplaceServiceImplTest.java | 956 ++++++++++++ .../persistence/dao/PolicyDaoImpl.java | 2 +- .../AuthorizationPolicyJpaRepository.java | 10 +- 51 files changed, 4983 insertions(+), 289 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java delete mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java create mode 100644 external-service-marketplace/.openapi-generator-ignore create mode 100644 external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml create mode 100644 external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java delete mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java create mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java delete mode 100644 external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java create mode 100644 external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java create mode 100644 external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java create mode 100644 external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java diff --git a/.gitignore b/.gitignore index c765529b..46a7edea 100644 --- a/.gitignore +++ b/.gitignore @@ -97,5 +97,7 @@ api-project/src/main/java/org/opendevstack/apiservice/project/api api-project/src/main/java/org/opendevstack/apiservice/project/model api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/api api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/model +external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/openapi + **/.openapi-generator diff --git a/Makefile b/Makefile index 79b44381..ff7c92a4 100644 --- a/Makefile +++ b/Makefile @@ -91,8 +91,25 @@ check-maven: clean: check-maven @echo "$(BLUE)Cleaning build artifacts...$(NC)" $(MAVEN_WRAPPER) clean + @echo "$(BLUE)Removing all target directories across modules...$(NC)" + @find . -type d -name target -prune -exec rm -rf {} + + @echo "$(BLUE)Removing generated OpenAPI model directories...$(NC)" + @find . -type d -path "*/src/main/java/*/model" \ + ! -path "./external-service-aap/src/main/java/org/opendevstack/apiservice/externalservice/aap/model" \ + ! -path "./service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/model" \ + ! -path "./external-service-projects-info-service/src/main/java/org/opendevstack/apiservice/externalservice/projectsinfoservice/model" \ + ! -path "./external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model" \ + ! -path "./external-service-uipath/src/main/java/org/opendevstack/apiservice/externalservice/uipath/model" \ + -prune -exec rm -rf {} + @echo "$(GREEN)✓ Clean complete$(NC)" +## Remove local Maven cache for org.opendevstack.apiservice +clean-cache: + @echo "$(BLUE)Removing local Maven cache for org.opendevstack.apiservice...$(NC)" + @rm -rf "$$HOME/.m2/repository/org/opendevstack/apiservice" + @rm -rf "$$HOME/.m2/repositories/org/opendevstack/apiservice" + @echo "$(GREEN)✓ Maven cache clean complete$(NC)" + ## Compile the project compile: check-java check-maven @echo "$(BLUE)Compiling project...$(NC)" diff --git a/api-project-component-v0/openapi/api-project-component-v0.yaml b/api-project-component-v0/openapi/api-project-component-v0.yaml index c85d72a4..4e331b42 100644 --- a/api-project-component-v0/openapi/api-project-component-v0.yaml +++ b/api-project-component-v0/openapi/api-project-component-v0.yaml @@ -90,7 +90,6 @@ paths: description: Component id schema: type: string - format: uuid responses: '200': description: Component information diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml index 845c6d57..35f3ddf5 100644 --- a/api-project-component-v0/pom.xml +++ b/api-project-component-v0/pom.xml @@ -50,6 +50,12 @@ ${project.version} + + org.opendevstack.apiservice + core-security + ${project.version} + + io.jsonwebtoken jjwt-api diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java index 28fa4a02..0feb0adb 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ComponentsResponseFactory.java @@ -22,10 +22,18 @@ public static CreateComponentResponse forbidden(String path, String message, Com return buildResponse(HttpStatus.FORBIDDEN, errorKey, path, message); } + public static CreateComponentResponse conflict(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.CONFLICT, errorKey, path, message); + } + public static CreateComponentResponse notFound(String path, String message, ComponentErrorKey errorKey) { return buildResponse(HttpStatus.NOT_FOUND, errorKey, path, message); } + public static CreateComponentResponse unprocessableEntity(String path, String message, ComponentErrorKey errorKey) { + return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, errorKey, path, message); + } + public static CreateComponentResponse internalError(String path, String message, ComponentErrorKey errorKey) { return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, errorKey, path, message); } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java index d288cae6..a78afeb6 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -3,7 +3,6 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.api.ProjectComponentsApi; -import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; @@ -15,8 +14,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import java.util.UUID; - @RestController @AllArgsConstructor @Slf4j @@ -31,20 +28,18 @@ public class ProjectComponentsController implements ProjectComponentsApi { @Override public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - Component component = componentsFacade.createProjectComponent(projectId, createComponentRequest); - if (component == null) { - throw new ComponentCreationException(String.format("Failed to create component for project '%s'", projectId)); - } + componentsFacade.provisionProjectComponent(projectId, createComponentRequest); - log.info("Created component {} for project id {} and request {}", component, projectId, createComponentRequest); + log.info("Created component '{}' for project '{}'", createComponentRequest.getName(), projectId); return componentResponseMapper.toResponseEntity( - ComponentsResponseFactory.entityCreated(projectId, component.getId()) + ComponentsResponseFactory.entityCreated(projectId, createComponentRequest.getName()) ); + } @Override - public ResponseEntity getProjectComponent(String projectId, UUID componentId) { - Component component = componentsFacade.getProjectComponent(projectId, componentId.toString()); + public ResponseEntity getProjectComponent(String projectId, String componentId) { + Component component = componentsFacade.getProjectComponent(projectId, componentId); if (component == null) { throw new ComponentNotFoundException( String.format("Component '%s' not found for project '%s'", componentId, projectId) @@ -54,4 +49,5 @@ public ResponseEntity getProjectComponent(String projectId, UUID comp log.info("Retrieved component '{}' for project '{}': {}", componentId, projectId, component); return ResponseEntity.status(HttpStatus.OK).body(component); } + } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java index f561d3df..add5c7f8 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -4,9 +4,13 @@ import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.project.controller.ComponentsResponseFactory; import org.opendevstack.apiservice.project.controller.ProjectComponentsController; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; +import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -138,6 +142,54 @@ public ResponseEntity handleComponentCreationException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + @ExceptionHandler(ComponentRetrievalException.class) + public ResponseEntity handleComponentRetrievalException( + ComponentRetrievalException ex, + HttpServletRequest request) { + + log.error("Component retrieval failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + + @ExceptionHandler(ComponentAlreadyExistsException.class) + public ResponseEntity handleComponentAlreadyExistsException( + ComponentAlreadyExistsException ex, + HttpServletRequest request) { + + log.warn("Component already exists: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.conflict( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.CONFLICT).body(response); + } + + @ExceptionHandler(ComponentBadRequestException.class) + public ResponseEntity handleComponentBadRequestException( + ComponentBadRequestException ex, + HttpServletRequest request) { + + log.warn("Bad request from downstream service: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.unprocessableEntity( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException( Exception ex, diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java new file mode 100644 index 00000000..9b66377d --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/CatalogItemNotFoundException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class CatalogItemNotFoundException extends RuntimeException { + + public CatalogItemNotFoundException(String message) { + super(message); + } + + public CatalogItemNotFoundException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java new file mode 100644 index 00000000..84355862 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentAlreadyExistsException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentAlreadyExistsException extends RuntimeException { + + public ComponentAlreadyExistsException(String message) { + super(message); + } + + public ComponentAlreadyExistsException(String message, Exception e) { + super(message, e); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java new file mode 100644 index 00000000..10deaec9 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentBadRequestException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentBadRequestException extends RuntimeException { + + public ComponentBadRequestException(String message) { + super(message); + } + + public ComponentBadRequestException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java index f0359189..dce84849 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentCreationException.java @@ -5,4 +5,8 @@ public class ComponentCreationException extends RuntimeException { public ComponentCreationException(String message) { super(message); } + + public ComponentCreationException(String message, Exception e) { + super(message, e); + } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java index 8ee5ffc7..38caa879 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentNotFoundException.java @@ -5,4 +5,8 @@ public class ComponentNotFoundException extends RuntimeException { public ComponentNotFoundException(String message) { super(message); } + + public ComponentNotFoundException(String message, Exception e) { + super(message, e); + } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java new file mode 100644 index 00000000..655c5be8 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRetrievalException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentRetrievalException extends RuntimeException { + + public ComponentRetrievalException(String message) { + super(message); + } + + public ComponentRetrievalException(String message, Exception e) { + super(message, e); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 42bab0e3..38883104 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -2,15 +2,23 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.service.CatalogItemOperations; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.CatalogItemNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; +import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import java.util.List; @@ -24,25 +32,134 @@ public class ComponentsFacade { private final MarketplaceMapper marketplaceMapper; public Component getProjectComponent(String projectId, String componentId) { - ProjectComponent marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); - if (marketplaceComponent == null) { - log.info("Marketplace component with id {} not found", componentId); - throw new ComponentNotFoundException( - String.format("Component '%s' not found for project '%s'", componentId, projectId) + try { + ProjectComponentExtendedInfo marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); + if (marketplaceComponent == null) { + log.info("Marketplace component with id {} not found", componentId); + throw new ComponentNotFoundException( + String.format("Component '%s' not found for project '%s'", componentId, projectId) + ); + } + String catalogItemId = CatalogItemOperations.buildCatalogItemId(marketplaceComponent); + CatalogItem catalogItem = marketplaceExternalService.getCatalogItem(catalogItemId); + if (catalogItem == null) { + log.info("Catalog item with id {} not found", catalogItemId); + throw new CatalogItemNotFoundException( + String.format("Catalog item with id '%s' not found", catalogItemId) + ); + } + Component component = marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent, catalogItem); + log.info("Marketplace v0 component retrieved: {}", component); + return component; + } catch (MarketplaceException e) { + log.error("Failed to retrieve component with id {} for project with id {}: {}", componentId, projectId, e.getMessage(), e); + throw new ComponentRetrievalException( + String.format("Failed to retrieve component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e ); } - return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); } - public Component createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - List createComponentParameterList = marketplaceMapper.mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest); - ProjectComponent marketplaceComponent = marketplaceExternalService.createProjectComponent(projectId, createComponentParameterList); - if (marketplaceComponent == null) { - log.error("Failed to create component in marketplace for project with id {}", projectId); + public void provisionProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + try { + CatalogItem catalogItem = resolveCatalogItem(createComponentRequest); + List createComponentParameterList = marketplaceMapper + .mapCreateComponentRequestToCreateComponentParameterList(createComponentRequest, catalogItem); + boolean success = marketplaceExternalService.provisionProjectComponent(projectId, createComponentParameterList); + if (!success) { + log.error("Failed to create component in marketplace for project with id {}", projectId); + throw new ComponentCreationException( + String.format("Failed to create component for project '%s'", projectId) + ); + } + } catch (MarketplaceException e) { + if (isConflictCause(e)) { + throw new ComponentAlreadyExistsException(e.getMessage(), e); + } + if (isBadRequestCause(e)) { + String downstreamMessage = extractHttpErrorMessage(e); + throw new ComponentBadRequestException(downstreamMessage, e); + } throw new ComponentCreationException( - String.format("Failed to create component for project '%s'", projectId) + String.format("Failed to create component for project '%s': %s", projectId, e.getMessage()), e ); } - return marketplaceMapper.mapMarketplaceComponentToV0Component(marketplaceComponent); + } + + /** + * Resolves the catalog item that matches the requested {@code productId} (interpreted as the + * Marketplace catalog item slug). Returns {@code null} when the request or product id is missing, + * when the catalog item cannot be found, or when the lookup fails – callers must tolerate a + * missing catalog item and fall back to default parameter handling. + */ + private CatalogItem resolveCatalogItem(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getProductId() == null + || createComponentRequest.getProductId().isBlank()) { + return null; + } + String slug = createComponentRequest.getProductId(); + try { + CatalogItem catalogItem = marketplaceExternalService.getCatalogItemBySlug(slug); + if (catalogItem == null) { + log.warn("No catalog item found for slug '{}'; provisioning will fall back to default parameter types", slug); + } + return catalogItem; + } catch (MarketplaceException e) { + log.warn("Failed to retrieve catalog item for slug '{}': {}. Provisioning will fall back to default parameter types", + slug, e.getMessage()); + return null; + } + } + + private boolean isConflictCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.Conflict) { + return true; + } + current = current.getCause(); + } + return false; + } + + private boolean isBadRequestCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.UnprocessableEntity + || current instanceof HttpClientErrorException.BadRequest) { + return true; + } + current = current.getCause(); + } + return false; + } + + private String extractHttpErrorMessage(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException httpError) { + return httpError.getResponseBodyAsString(); + } + current = current.getCause(); + } + return throwable.getMessage(); + } + + public Boolean deleteProjectComponent(String projectId, String componentId) { + try { + return marketplaceExternalService.deleteProjectComponent(projectId, componentId); + } catch (MarketplaceException e) { + log.error("Failed to delete component with id {} for project with id {}", componentId, projectId, e); + return false; + } + } + + public boolean registerProjectComponent(String projectId, String componentId) { + try { + marketplaceExternalService.registerProjectComponent(projectId, componentId); + return true; + } catch (MarketplaceException e) { + log.error("Failed to register component in marketplace for project with id {}", projectId, e); + return false; + } } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java index 8e845003..2a0eec4b 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -1,73 +1,97 @@ package org.opendevstack.apiservice.project.mapper; -import org.mapstruct.IterableMapping; import org.mapstruct.Mapper; -import org.mapstruct.Mapping; -import org.mapstruct.Named; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; import org.opendevstack.apiservice.project.model.Component; -import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.opendevstack.apiservice.project.model.EnvironmentsDTO; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.UUID; @Mapper(componentModel = "spring") public interface MarketplaceMapper { - @Mapping(target = "id", source = "componentId", qualifiedByName = "uuidToString") - @Mapping(target = "environment", source = "environment", qualifiedByName = "toEnvironment") - @Mapping(target = "status", source = "status", qualifiedByName = "toComponentStatus") - @Mapping(target = "params", expression = "java(java.util.Collections.emptyMap())") - @Mapping(target = "resultTraceback", ignore = true) - Component mapMarketplaceComponentToV0Component(ProjectComponent source); + String DEFAULT_PARAMETER_TYPE = "string"; - default List mapCreateComponentRequestToCreateComponentParameterList(CreateComponentRequest createComponentRequest) { - if (createComponentRequest == null || createComponentRequest.getParams() == null) { - return List.of(); + default Component mapMarketplaceComponentToV0Component(ProjectComponentExtendedInfo source, CatalogItem catalogItem) throws MarketplaceException { + Component component = new Component(); + component.setId(source.getComponentId()); + component.setEnvironment(EnvironmentsDTO.DEV); // Env is always DEV so we hardcode it as such + component.setRepositoryURL(source.getComponentUrl()); + component.setComponentType(""); // We agreed to hardcode the type as empty + component.setStatus(StatusMap.toOldStatus(source.getStatus())); + if (component.getStatus() == null) { + throw new MarketplaceException("No status mapping found for status " + source.getStatus()); + } + if (catalogItem != null) { + component.setProductId(catalogItem.getId()); + component.setProductName(catalogItem.getTitle()); + component.setProductDescription(catalogItem.getShortDescription()); } - return mapEntriesToCreateComponentParameterList(createComponentRequest.getParams().entrySet().stream().toList()); + if (source.getParameters() != null) { + source.getParameters().forEach( + param -> component.putParamsItem(param.getName(), param.getValues()) + ); + } + return component; } - @IterableMapping(qualifiedByName = "toCreateComponentParameter") - List mapEntriesToCreateComponentParameterList(List> entries); + default List mapCreateComponentRequestToCreateComponentParameterList( + CreateComponentRequest createComponentRequest, CatalogItem catalogItem) { + if (createComponentRequest == null) { + return List.of(); + } + + Map parameterTypesByName = buildParameterTypeIndex(catalogItem); + + List parameters = new ArrayList<>(); + parameters.add(createParameter("component_id", createComponentRequest.getName(), DEFAULT_PARAMETER_TYPE)); + parameters.add(createParameter("catalog_item_slug", createComponentRequest.getProductId(), DEFAULT_PARAMETER_TYPE)); - @Named("toCreateComponentParameter") - @Mapping(target = "name", source = "key") - @Mapping(target = "type", constant = "string") - @Mapping(target = "value", expression = "java(String.valueOf(entry.getValue()))") - CreateComponentParameter toCreateComponentParameter(Map.Entry entry); + if (createComponentRequest.getParams() != null && !createComponentRequest.getParams().isEmpty()) { + createComponentRequest.getParams().forEach((name, value) -> { + String type = parameterTypesByName.getOrDefault(name, DEFAULT_PARAMETER_TYPE); + parameters.add(createParameter(name, value, type)); + }); + } - @Named("uuidToString") - default String uuidToString(UUID sourceId) { - return sourceId != null ? sourceId.toString() : null; + return parameters; } - @Named("toComponentStatus") - default ComponentsStatusDTO toComponentStatus(String sourceStatus) { - if (sourceStatus == null || sourceStatus.isBlank()) { - return null; - } - try { - return ComponentsStatusDTO.fromValue(sourceStatus); - } catch (IllegalArgumentException ex) { - return null; - } + default ProvisionActionParameter createParameter(String name, Object value, String type) { + return new ProvisionActionParameter().name(name).type(type).value(value); } - @Named("toEnvironment") - default EnvironmentsDTO toEnvironment(String sourceEnv) { - if (sourceEnv == null || sourceEnv.isBlank()) { - return null; + /** + * Builds a map of parameter name -> type from the catalog item user actions. + * The type comes from the {@link CatalogItemUserActionParameter#getType()} value + * (e.g. {@code string}, {@code boolean}, {@code multiplelist}, {@code singlelist}, ...). + */ + private static Map buildParameterTypeIndex(CatalogItem catalogItem) { + Map index = new HashMap<>(); + if (catalogItem == null || catalogItem.getUserActions() == null) { + return index; } - try { - return EnvironmentsDTO.fromValue(sourceEnv); - } catch (IllegalArgumentException ex) { - return null; + for (CatalogItemUserAction action : catalogItem.getUserActions()) { + if (action == null || action.getParameters() == null) { + continue; + } + for (CatalogItemUserActionParameter param : action.getParameters()) { + if (param == null || param.getName() == null || param.getType() == null) { + continue; + } + index.putIfAbsent(param.getName(), param.getType()); + } } + return index; } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java new file mode 100644 index 00000000..cf9816bf --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/StatusMap.java @@ -0,0 +1,26 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; + +import java.util.HashMap; +import java.util.Map; + +public class StatusMap { + + private StatusMap() { + } + + static final Map STATUS_MAP = new HashMap<>(); + + static { + STATUS_MAP.put("CREATING", ComponentsStatusDTO.RUNNING); + STATUS_MAP.put("CREATED", ComponentsStatusDTO.READY); + STATUS_MAP.put("FAILED", ComponentsStatusDTO.FAILED); + STATUS_MAP.put("DELETING", ComponentsStatusDTO.DELETING); + STATUS_MAP.put("UNKNOWN", ComponentsStatusDTO.UNKNOWN); + } + + static ComponentsStatusDTO toOldStatus(String status) { + return STATUS_MAP.get(status); + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java index 0b67225a..8142c12d 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -6,7 +6,7 @@ import org.mapstruct.factory.Mappers; import org.mockito.Mock; import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.facade.ComponentsFacade; import org.opendevstack.apiservice.project.mapper.ComponentResponseMapper; @@ -16,12 +16,11 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import java.util.UUID; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestComponent; @@ -50,14 +49,11 @@ void tearDown() throws Exception { } @Test - void create_project_component_returns_ok_when_component_is_created() { + void create_project_component_returns_ok_with_component_name_in_path() { String projectId = "testProjectId"; - CreateComponentRequest request = buildTestCreateComponentRequest(); - Component createdComponent = buildTestComponent(); - createdComponent.setId("component-123"); + CreateComponentRequest request = buildTestCreateComponentRequest(); // name = "testcomponent" - when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) - .thenReturn(createdComponent); + doNothing().when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); @@ -66,63 +62,49 @@ void create_project_component_returns_ok_when_component_is_created() { assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.OK.name()); assertThat(response.getBody().getErrorKey()).isEqualTo("000"); assertThat(response.getBody().getMessage()).isEqualTo("Component created"); - assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/component-123"); - verify(componentsFacade).createProjectComponent(projectId, request); - } - - @Test - void create_project_component_returns_internal_error_when_component_creation_returns_null() { - String projectId = "testProjectId"; - CreateComponentRequest request = buildTestCreateComponentRequest(); - - when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) - .thenReturn(null); - - assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) - .isInstanceOf(ComponentCreationException.class) - .hasMessage("Failed to create component for project 'testProjectId'"); - verify(componentsFacade).createProjectComponent(projectId, request); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).provisionProjectComponent(projectId, request); } @Test - void create_project_component_propagates_exception_when_facade_throws_exception() { + void create_project_component_propagates_exception_when_facade_throws_exception() { String projectId = "testProjectId"; CreateComponentRequest request = buildTestCreateComponentRequest(); - when(componentsFacade.createProjectComponent(eq(projectId), any(CreateComponentRequest.class))) - .thenThrow(new RuntimeException("boom")); + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) .isInstanceOf(RuntimeException.class) .hasMessage("boom"); - verify(componentsFacade).createProjectComponent(projectId, request); + verify(componentsFacade).provisionProjectComponent(projectId, request); } @Test - void get_project_component_returns_ok_when_component_exists() { + void get_project_component_returns_ok_when_component_exists() throws MarketplaceException { String projectId = "projectId"; - UUID componentId = UUID.randomUUID(); + String componentId = "test-component-one"; Component testComponent = buildTestComponent(); - when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(testComponent); + when(componentsFacade.getProjectComponent(projectId, componentId)).thenReturn(testComponent); ResponseEntity response = projectComponentsController.getProjectComponent(projectId, componentId); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(response.getBody()).isEqualTo(testComponent); - verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); + verify(componentsFacade).getProjectComponent(projectId, componentId); } @Test - void get_project_component_throws_not_found_when_component_does_not_exist() { + void get_project_component_throws_not_found_when_component_does_not_exist() throws MarketplaceException { String projectId = "projectId"; - UUID componentId = UUID.randomUUID(); + String componentId = "test-component-one"; - when(componentsFacade.getProjectComponent(projectId, componentId.toString())).thenReturn(null); + when(componentsFacade.getProjectComponent(projectId, componentId)).thenReturn(null); assertThatThrownBy(() -> projectComponentsController.getProjectComponent(projectId, componentId)) .isInstanceOf(ComponentNotFoundException.class) .hasMessage("Component '" + componentId + "' not found for project 'projectId'"); - verify(componentsFacade).getProjectComponent(projectId, componentId.toString()); + verify(componentsFacade).getProjectComponent(projectId, componentId); } } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java index e6bced24..92a0601e 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; @@ -134,6 +135,20 @@ void handle_component_creation_exception_returns_internal_server_error() { assertThat(response.getBody().getMessage()).isEqualTo("Creation failed"); } + @Test + void handle_component_already_exists_exception_returns_conflict() { + ComponentAlreadyExistsException exception = new ComponentAlreadyExistsException( + "This component name already exists, please choose another name."); + + ResponseEntity response = handler.handleComponentAlreadyExistsException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.CONFLICT.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getMessage()).isEqualTo("This component name already exists, please choose another name."); + } + @Test void handle_generic_exception_returns_internal_server_error() { RuntimeException exception = new RuntimeException("boom"); diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 978dae81..b8c77488 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -1,31 +1,38 @@ package org.opendevstack.apiservice.project.facade; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mapstruct.factory.Mappers; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; - -import java.util.List; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCatalogItem; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCreateComponentRequest; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestMarketplaceComponent; +@ExtendWith(MockitoExtension.class) class ComponentsFacadeTest { private final MarketplaceMapper marketplaceMapper = Mappers.getMapper(MarketplaceMapper.class); @@ -35,36 +42,31 @@ class ComponentsFacadeTest { private ComponentsFacade componentsFacade; - private AutoCloseable openMocks; - @BeforeEach void setup() { - openMocks = MockitoAnnotations.openMocks(this); componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); } - @AfterEach - void tearDown() throws Exception { - openMocks.close(); - } - @Test - void get_project_component_returns_mapped_component_when_marketplace_returns_data() { - ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); + void get_project_component_returns_mapped_component_when_marketplace_returns_data() throws MarketplaceException { + ProjectComponentExtendedInfo marketplaceComponent = buildTestMarketplaceComponent(); + CatalogItem testCatalogItem = buildTestCatalogItem(); when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(marketplaceComponent); + when(marketplaceExternalService.getCatalogItem(anyString())) + .thenReturn(testCatalogItem); Component retrievedComponent = componentsFacade.getProjectComponent("testProject", "testComponent"); assertThat(retrievedComponent).isNotNull(); - assertThat(retrievedComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); + assertThat(retrievedComponent.getId()).isNotNull(); assertThat(retrievedComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); verify(marketplaceExternalService).getProjectComponent("testProject", "testComponent"); } @Test - void get_project_component_throws_not_found_when_marketplace_returns_null() { + void get_project_component_throws_not_found_when_marketplace_returns_null() throws MarketplaceException { when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) .thenReturn(null); @@ -75,31 +77,47 @@ void get_project_component_throws_not_found_when_marketplace_returns_null() { } @Test - void create_project_component_returns_mapped_component_when_marketplace_creates_component() { - ProjectComponent marketplaceComponent = buildTestMarketplaceComponent(); + void create_project_component_returns_mapped_component_when_marketplace_creates_component() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) - .thenReturn(marketplaceComponent); + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenReturn(true); - Component createdComponent = componentsFacade.createProjectComponent("testProject", request); + componentsFacade.provisionProjectComponent("testProject", request); - assertThat(createdComponent).isNotNull(); - assertThat(createdComponent.getId()).isEqualTo(marketplaceComponent.getComponentId().toString()); - assertThat(createdComponent.getStatus()).isEqualTo(ComponentsStatusDTO.RUNNING); - verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } @Test - void create_project_component_throws_creation_exception_when_marketplace_returns_null() { + void create_project_component_throws_creation_exception_when_marketplace_returns_null() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); - when(marketplaceExternalService.createProjectComponent(eq("testProject"), any(List.class))) - .thenReturn(null); + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenReturn(false); - assertThatThrownBy(() -> componentsFacade.createProjectComponent("testProject", request)) + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) .isInstanceOf(ComponentCreationException.class) .hasMessage("Failed to create component for project 'testProject'"); - verify(marketplaceExternalService).createProjectComponent(eq("testProject"), any(List.class)); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } + + @Test + void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + HttpClientErrorException conflict = HttpClientErrorException.Conflict.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + new byte[0], + null + ); + + when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) + .thenThrow(new MarketplaceException("This component name already exists, please choose another name.", conflict)); + + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) + .isInstanceOf(ComponentAlreadyExistsException.class) + .hasMessage("This component name already exists, please choose another name."); + verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); + } } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java new file mode 100644 index 00000000..6495d055 --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java @@ -0,0 +1,107 @@ +package org.opendevstack.apiservice.project.mapper; + +import org.junit.jupiter.api.Test; +import org.mapstruct.factory.Mappers; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.project.model.CreateComponentRequest; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class MarketplaceMapperTest { + + private final MarketplaceMapper mapper = Mappers.getMapper(MarketplaceMapper.class); + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_resolvesTypeFromCatalogUserActions() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-amp-test-four"); + request.setProductId("catest_external-lists-item"); + + List projectGroup = List.of( + "CN=BI-AS-ATLASSIAN-P-EDPI-STAKEHOLDER,OU=BIDS-managed,DC=eu,DC=boehringer,DC=com", + "CN=BI-AS-ATLASSIAN-P-EDPI-MANAGER,OU=BIDS-managed,DC=eu,DC=boehringer,DC=com"); + request.setParams(Map.of( + "requestor_email", "user@example.com", + "Project_Group", projectGroup, + "enable_feature", true + )); + + CatalogItem catalogItem = new CatalogItem(); + catalogItem.setUserActions(List.of(buildProvisionAction( + param("requestor_email", "string"), + param("Project_Group", "multiplelist"), + param("enable_feature", "boolean") + ))); + + List result = + mapper.mapCreateComponentRequestToCreateComponentParameterList(request, catalogItem); + + assertThat(result).extracting(ProvisionActionParameter::getName) + .containsExactlyInAnyOrder("component_id", "catalog_item_slug", + "requestor_email", "Project_Group", "enable_feature"); + + ProvisionActionParameter projectGroupParam = findByName(result, "Project_Group"); + assertThat(projectGroupParam.getType()).isEqualTo("multiplelist"); + assertThat(projectGroupParam.getValue()).isEqualTo(projectGroup); + + ProvisionActionParameter emailParam = findByName(result, "requestor_email"); + assertThat(emailParam.getType()).isEqualTo("string"); + assertThat(emailParam.getValue()).isEqualTo("user@example.com"); + + ProvisionActionParameter booleanParam = findByName(result, "enable_feature"); + assertThat(booleanParam.getType()).isEqualTo("boolean"); + assertThat(booleanParam.getValue()).isEqualTo(true); + + ProvisionActionParameter componentId = findByName(result, "component_id"); + assertThat(componentId.getType()).isEqualTo("string"); + assertThat(componentId.getValue()).isEqualTo("test-amp-test-four"); + + ProvisionActionParameter slug = findByName(result, "catalog_item_slug"); + assertThat(slug.getType()).isEqualTo("string"); + assertThat(slug.getValue()).isEqualTo("catest_external-lists-item"); + } + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_fallsBackToStringWhenCatalogItemMissing() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("anything", "value")); + + List result = + mapper.mapCreateComponentRequestToCreateComponentParameterList(request, null); + + ProvisionActionParameter anything = findByName(result, "anything"); + assertThat(anything.getType()).isEqualTo("string"); + assertThat(anything.getValue()).isEqualTo("value"); + } + + @Test + void mapCreateComponentRequestToCreateComponentParameterList_returnsEmptyWhenRequestNull() { + assertThat(mapper.mapCreateComponentRequestToCreateComponentParameterList(null, null)).isEmpty(); + } + + private static CatalogItemUserAction buildProvisionAction(CatalogItemUserActionParameter... params) { + CatalogItemUserAction action = new CatalogItemUserAction(); + action.setId("PROVISION"); + action.setParameters(List.of(params)); + return action; + } + + private static CatalogItemUserActionParameter param(String name, String type) { + CatalogItemUserActionParameter p = new CatalogItemUserActionParameter(); + p.setName(name); + p.setType(type); + return p; + } + + private static ProvisionActionParameter findByName(List list, String name) { + return list.stream().filter(p -> name.equals(p.getName())).findFirst().orElseThrow(); + } +} diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java index 0b81f7bd..a9c059f8 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -1,6 +1,7 @@ package org.opendevstack.apiservice.project.util; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; @@ -25,15 +26,24 @@ public static Component buildTestComponent() { return component; } - public static ProjectComponent buildTestMarketplaceComponent() { - ProjectComponent component = new ProjectComponent(); - component.setComponentId(UUID.randomUUID()); - component.setStatus("RUNNING"); - component.setCanBeDeleted(false); + public static ProjectComponentExtendedInfo buildTestMarketplaceComponent() { + ProjectComponentExtendedInfo component = new ProjectComponentExtendedInfo(); + component.setComponentId(UUID.randomUUID().toString()); + component.setStatus("CREATING"); component.setComponentUrl("http://test.component.url"); + component.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + component.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); return component; } + public static CatalogItem buildTestCatalogItem() { + CatalogItem catalogItem = new CatalogItem(); + catalogItem.setId(UUID.randomUUID().toString()); + catalogItem.setTitle("Test Catalog Item"); + catalogItem.setShortDescription("This is a test catalog item"); + return catalogItem; + } + public static CreateComponentRequest buildTestCreateComponentRequest() { CreateComponentRequest request = new CreateComponentRequest(); request.setName("testcomponent"); diff --git a/api-project/pom.xml b/api-project/pom.xml index 3a7962e1..7172641e 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -57,6 +57,12 @@ persistence ${project.version} + + + org.opendevstack.apiservice + core-security + ${project.version} + io.jsonwebtoken diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index c9bc530e..54c33275 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -3,11 +3,11 @@ import jakarta.validation.Valid; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; import org.opendevstack.apiservice.project.api.ProjectsApi; import org.opendevstack.apiservice.project.facade.ProjectsFacade; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; -import org.opendevstack.apiservice.project.util.SecurityUtils; import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -38,7 +38,7 @@ public class ProjectController implements ProjectsApi { @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { projectRequestValidator.validate(createProjectRequest); - UUID clientId = SecurityUtils.getClientId(); + UUID clientId = JwtUtils.getClientId(); CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); projectResponse.setLocation(API_BASE_PATH + "/" + projectResponse.getProjectKey()); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java b/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java deleted file mode 100644 index bb926a98..00000000 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/util/SecurityUtils.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.opendevstack.apiservice.project.util; - -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; - -import java.util.UUID; - -public class SecurityUtils { - - private SecurityUtils() { - // to avoid instantiation - } - - public static UUID getClientId() { - Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal(); - - if (principal instanceof Jwt jwt) { - String clientId = jwt.getClaimAsString("azp"); - if (clientId == null || clientId.isBlank()) { - clientId = jwt.getClaimAsString("appid"); - } - - if (clientId == null || clientId.isBlank()) { - throw new InvalidBearerTokenException("Client ID not found in token claims"); - } - - return UUID.fromString(clientId); - } else { - throw new InvalidBearerTokenException("Invalid authentication token"); - } - } -} diff --git a/application.yaml b/application.yaml index bfaa024c..deb70580 100644 --- a/application.yaml +++ b/application.yaml @@ -80,6 +80,13 @@ app: - /actuator/health - /actuator/info - /api/v1/projects/*/platforms + obo: + # Azure AD token endpoint used for On-Behalf-Of token exchange. + token-url: ${OBO_TOKEN_URL:https://login.microsoftonline.com/${AZURE_TENANT_ID:}/oauth2/v2.0/token} + # Client ID of this application's Azure AD app registration. + client-id: ${OBO_CLIENT_ID} + # Client secret for OBO exchange. Must be injected via environment variable or secret store. + client-secret: ${OBO_CLIENT_SECRET} # Spring Boot Actuator configuration. # Restrict these endpoints in production if they expose operational details. @@ -278,3 +285,22 @@ externalservices: projects-info-service: # Base URL of the downstream Projects Info Service consumed by this application. base-url: ${PROJECTS_INFO_SERVICE_BASE_URL:http://localhost:8081} + + jira: + # Name of the default Jira instance. + default-instance: ${JIRA_DEFAULT_INSTANCE:jira} + instances: + jira: + base-url: ${JIRA_JIRA_DEV_BASE_URL:https://jira.example.com} + bearer-token: ${JIRA_JIRA_DEV_BEARER_TOKEN:} + connection-timeout: ${JIRA_JIRA_DEV_CONNECTION_TIMEOUT:30000} + read-timeout: ${JIRA_JIRA_DEV_READ_TIMEOUT:30000} + trust-all-certificates: ${JIRA_JIRA_DEV_TRUST_ALL:false} + + marketplace: + instances: + default: + project-components-base-url: ${MARKETPLACE_PROJECT_COMPONENTS_BASE_URL} + provisioner-actions-base-url: ${MARKETPLACE_PROVISIONER_ACTIONS_BASE_URL:} + obo-scope: ${MARKETPLACE_OBO_SCOPE:} + trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java new file mode 100644 index 00000000..3c903192 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; + +import java.util.UUID; + +public final class JwtUtils { + + private JwtUtils() { + } + + /** + * Extracts the raw JWT token value from the current SecurityContext. + * + * @return the JWT token string + * @throws InvalidBearerTokenException if the principal is not a JWT + */ + public static String getTokenValue() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return jwt.getTokenValue(); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + private static Object currentPrincipal() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new InvalidBearerTokenException("No authentication present in security context"); + } + Object principal = authentication.getPrincipal(); + if (principal == null) { + throw new InvalidBearerTokenException("No principal present in security context"); + } + return principal; + } + + /** + * Extracts the Azure AD client ID from the current SecurityContext JWT. + * Checks the {@code azp} claim first, then falls back to {@code appid}. + * + * @return the client ID as UUID + * @throws InvalidBearerTokenException if the principal is not a JWT or no client ID claim is found + */ + public static UUID getClientId() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractClientId(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Extracts the Azure AD client ID from the given JWT. + */ + public static UUID extractClientId(Jwt jwt) { + String clientId = jwt.getClaimAsString("azp"); + if (clientId == null || clientId.isBlank()) { + clientId = jwt.getClaimAsString("appid"); + } + if (clientId == null || clientId.isBlank()) { + throw new InvalidBearerTokenException("Client ID not found in token claims"); + } + return UUID.fromString(clientId); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java new file mode 100644 index 00000000..bbb797a9 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.core.security.obo; + +public class OboTokenException extends RuntimeException { + + public OboTokenException(String message) { + super(message); + } + + public OboTokenException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java new file mode 100644 index 00000000..0454b89e --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenProperties.java @@ -0,0 +1,17 @@ +package org.opendevstack.apiservice.core.security.obo; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "app.security.obo") +@Data +public class OboTokenProperties { + + private String tokenUrl; + + private String clientId; + + private String clientSecret; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java new file mode 100644 index 00000000..58947384 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenResponse.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.core.security.obo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OboTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private int expiresIn; + + private String scope; +} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java new file mode 100644 index 00000000..6a9a7a21 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/obo/OboTokenService.java @@ -0,0 +1,68 @@ +package org.opendevstack.apiservice.core.security.obo; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Service +@Slf4j +public class OboTokenService { + + private static final String GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + + private final OboTokenProperties properties; + private final RestTemplate restTemplate; + + @Autowired + public OboTokenService(OboTokenProperties properties, RestTemplate restTemplate) { + this.properties = properties; + this.restTemplate = restTemplate; + } + + /** + * Exchanges the given JWT assertion for an OBO (On-Behalf-Of) access token. + * + * @param assertion the incoming JWT token value (from the original request) + * @param scope the target API scope (e.g. {@code api:///Api.Access}) + * @return the OBO access token string + * @throws OboTokenException if the token exchange fails + */ + public String exchangeToken(String assertion, String scope) { + log.debug("Exchanging JWT for OBO token with scope '{}'", scope); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", GRANT_TYPE); + body.add("client_id", properties.getClientId()); + body.add("client_secret", properties.getClientSecret()); + body.add("assertion", assertion); + body.add("requested_token_use", "on_behalf_of"); + body.add("scope", scope); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> request = new HttpEntity<>(body, headers); + + try { + ResponseEntity response = + restTemplate.postForEntity(properties.getTokenUrl(), request, OboTokenResponse.class); + + if (response.getBody() == null || response.getBody().getAccessToken() == null) { + throw new OboTokenException("OBO token response body or access_token is null"); + } + + log.debug("OBO token obtained successfully, expires in {} seconds", response.getBody().getExpiresIn()); + return response.getBody().getAccessToken(); + } catch (RestClientException e) { + throw new OboTokenException("Failed to exchange JWT for OBO token: " + e.getMessage(), e); + } + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java new file mode 100644 index 00000000..76903b44 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java @@ -0,0 +1,132 @@ +package org.opendevstack.apiservice.core.security.jwt; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class JwtUtilsTest { + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void get_token_value_returns_jwt_token_string() { + // GIVEN + Jwt jwt = buildJwt("eyJhbGciOiJSUzI1NiJ9.test", Map.of("azp", "client-a")); + setSecurityContext(jwt); + + // WHEN + String token = JwtUtils.getTokenValue(); + + // THEN + assertEquals("eyJhbGciOiJSUzI1NiJ9.test", token); + } + + @Test + void get_token_value_throws_when_principal_is_not_jwt() { + // GIVEN + SecurityContext ctx = mock(SecurityContext.class); + var auth = mock(org.springframework.security.core.Authentication.class); + when(auth.getPrincipal()).thenReturn("not-a-jwt"); + when(ctx.getAuthentication()).thenReturn(auth); + SecurityContextHolder.setContext(ctx); + + // WHEN / THEN + assertThrows(InvalidBearerTokenException.class, JwtUtils::getTokenValue); + } + + @Test + void get_client_id_returns_azp_claim() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000001"; + Jwt jwt = buildJwt("token", Map.of("azp", clientId)); + setSecurityContext(jwt); + + // WHEN + UUID result = JwtUtils.getClientId(); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void get_client_id_falls_back_to_appid_when_azp_is_blank() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000002"; + Jwt jwt = buildJwt("token", Map.of("azp", "", "appid", clientId)); + setSecurityContext(jwt); + + // WHEN + UUID result = JwtUtils.getClientId(); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void get_client_id_throws_when_no_client_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("sub", "user")); + setSecurityContext(jwt); + + // WHEN / THEN + assertThrows(InvalidBearerTokenException.class, JwtUtils::getClientId); + } + + @Test + void extract_client_id_from_jwt_with_azp() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000003"; + Jwt jwt = buildJwt("token", Map.of("azp", clientId)); + + // WHEN + UUID result = JwtUtils.extractClientId(jwt); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + @Test + void extract_client_id_from_jwt_with_appid_fallback() { + // GIVEN + String clientId = "00000000-0000-0000-0000-000000000004"; + Jwt jwt = buildJwt("token", Map.of("appid", clientId)); + + // WHEN + UUID result = JwtUtils.extractClientId(jwt); + + // THEN + assertEquals(UUID.fromString(clientId), result); + } + + private Jwt buildJwt(String tokenValue, Map claims) { + Jwt.Builder builder = Jwt.withTokenValue(tokenValue) + .header("alg", "RS256") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)); + claims.forEach(builder::claim); + return builder.build(); + } + + private void setSecurityContext(Jwt jwt) { + JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(auth); + SecurityContextHolder.setContext(ctx); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java new file mode 100644 index 00000000..ad9d0ca4 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/obo/OboTokenServiceTest.java @@ -0,0 +1,129 @@ +package org.opendevstack.apiservice.core.security.obo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class OboTokenServiceTest { + + @Mock + private RestTemplate restTemplate; + + private OboTokenProperties properties; + + private OboTokenService sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + properties = new OboTokenProperties(); + properties.setTokenUrl("https://login.microsoftonline.com/tenant-id/oauth2/v2.0/token"); + properties.setClientId("test-client-id"); + properties.setClientSecret("test-client-secret"); + sut = new OboTokenService(properties, restTemplate); + } + + @Test + void exchange_token_returns_access_token_on_success() { + // GIVEN + String assertion = "jwt-assertion-value"; + String scope = "api://target-app/Api.Access"; + OboTokenResponse tokenResponse = new OboTokenResponse(); + tokenResponse.setAccessToken("obo-access-token"); + tokenResponse.setTokenType("Bearer"); + tokenResponse.setExpiresIn(3600); + tokenResponse.setScope(scope); + + when(restTemplate.postForEntity(eq(properties.getTokenUrl()), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + String result = sut.exchangeToken(assertion, scope); + + // THEN + assertEquals("obo-access-token", result); + verify(restTemplate).postForEntity(eq(properties.getTokenUrl()), any(HttpEntity.class), eq(OboTokenResponse.class)); + } + + @Test + @SuppressWarnings("unchecked") + void exchange_token_sends_correct_form_parameters() { + // GIVEN + String assertion = "my-jwt"; + String scope = "api://app/scope"; + OboTokenResponse tokenResponse = new OboTokenResponse(); + tokenResponse.setAccessToken("token"); + + ArgumentCaptor>> captor = ArgumentCaptor.forClass(HttpEntity.class); + when(restTemplate.postForEntity(anyString(), captor.capture(), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(tokenResponse, HttpStatus.OK)); + + // WHEN + sut.exchangeToken(assertion, scope); + + // THEN + MultiValueMap body = captor.getValue().getBody(); + assertNotNull(body); + assertEquals("urn:ietf:params:oauth:grant-type:jwt-bearer", body.getFirst("grant_type")); + assertEquals("test-client-id", body.getFirst("client_id")); + assertEquals("test-client-secret", body.getFirst("client_secret")); + assertEquals("my-jwt", body.getFirst("assertion")); + assertEquals("on_behalf_of", body.getFirst("requested_token_use")); + assertEquals("api://app/scope", body.getFirst("scope")); + } + + @Test + void exchange_token_throws_obo_exception_when_response_body_is_null() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(null, HttpStatus.OK)); + + // WHEN / THEN + OboTokenException ex = assertThrows(OboTokenException.class, + () -> sut.exchangeToken("jwt", "scope")); + assertTrue(ex.getMessage().contains("null")); + } + + @Test + void exchange_token_throws_obo_exception_when_access_token_is_null() { + // GIVEN + OboTokenResponse response = new OboTokenResponse(); + response.setAccessToken(null); + + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenReturn(new ResponseEntity<>(response, HttpStatus.OK)); + + // WHEN / THEN + assertThrows(OboTokenException.class, () -> sut.exchangeToken("jwt", "scope")); + } + + @Test + void exchange_token_throws_obo_exception_on_rest_client_error() { + // GIVEN + when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(OboTokenResponse.class))) + .thenThrow(new RestClientException("Connection refused")); + + // WHEN / THEN + OboTokenException ex = assertThrows(OboTokenException.class, + () -> sut.exchangeToken("jwt", "scope")); + assertTrue(ex.getMessage().contains("Connection refused")); + } +} diff --git a/external-service-marketplace/.openapi-generator-ignore b/external-service-marketplace/.openapi-generator-ignore new file mode 100644 index 00000000..82ac4f7f --- /dev/null +++ b/external-service-marketplace/.openapi-generator-ignore @@ -0,0 +1,43 @@ +# OpenAPI Generator Ignore +# Generated by openapi-generator https://github.com/openapitools/openapi-generator + +# Use this file to prevent files from being overwritten by the generator. +# The patterns follow closely to .gitignore or .dockerignore. + +# As an example, the C# client generator defines ApiClient.cs. +# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: +#ApiClient.cs + +# You can match any string of characters against a directory, file or extension with a single asterisk (*): +#foo/*/qux +# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux + +# You can recursively match patterns against a directory, file or extension with a double asterisk (**): +#foo/**/qux +# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux + +# You can also negate patterns with an exclamation (!). +# For example, you can ignore all files in a docs folder with the file extension .md: +#docs/*.md +# Then explicitly reverse the ignore rule for a single file: +#!docs/README.md + +api +api/** +gradle +gradle/** +.github +.github/** +pom.xml +**/AndroidManifest.xml +.gitignore +.openapi-generator-ignore +.travis.yml +build.gradle +build.sbt +git_push.sh +gradle.properties +gradlew +gradlew.bat +settings.gradle + diff --git a/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml new file mode 100644 index 00000000..6bf9eb3c --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_catalog-v1.0.0.yaml @@ -0,0 +1,1366 @@ +openapi: 3.0.3 +info: + title: Component Catalog REST API + version: '1.0.0' + description: > + The Component Catalog API allows clients to retrieve information about CatalogItems, CatalogFilters and Files entities. + + Catalog and File entities also exist internally, but only referenced by id's on requests and not returned on responses. + + **NOTES**: + - The OpenAPI specification file is also used to [generate](https://openapi-generator.tech/) REST client(s) and a server REST API. + - Clients and servers generated from the same OpenAPI specification version are guaranteed to be **compatible**. + contact: + name: EDPCore Team + url: https://confluence.biscrum.com/pages/viewpage.action?spaceKey=EDP&title=Welcome +servers: + - url: http://{baseurl}/v1 + variables: + baseurl: + default: localhost:8080 + description: Default address for a Component Catalog's backend REST API instance. +security: + - bearerAuth: [ ] +tags: + - name: CatalogHealth + description: Public actuator health endpoint. + - name: CatalogItems + description: CatalogItems operations. + - name: CatalogFilters + description: CatalogFilters operations. + - name: CatalogItemUserActionMessageDefinitions + description: User actions standardized messages definitions + - name: Files + description: File operations. + - name: SchemaValidations + description: Schema Validations operations. + - name: ProvisionerActions + description: Provisioning notifications from AWX/Provisioner +paths: + /actuator/health: + get: + tags: + - CatalogHealth + summary: Health check endpoint + description: Public actuator health endpoint. + operationId: getCatalogHealth + security: [] + responses: + "200": + description: Service health status. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: UP + groups: + type: array + items: + type: string + required: + - status + + /project/{projectKey}/components: + get: + tags: + - Project-components + summary: Returns the information of the project's components in the Bitbucket repository. + operationId: getProjectComponents + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: A list of Project Component Information + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ProjectComponentInfo' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /project/{projectKey}/component/{componentId}: + get: + tags: + - Project-components + summary: Returns the extended information of a project component given both its project key and component ID in the Bitbucket repository. + operationId: getProjectComponentById + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + - name: componentId + in: path + description: component ID. + required: true + schema: + type: string + responses: + "200": + description: The extended information of a project component. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectComponentExtendedInfo' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-descriptors: + get: + tags: + - Catalog-descriptors + summary: List of all available Catalog Descriptors. + description: > + Returns a list of all available Catalog Descriptors.
+ operationId: getCatalogDescriptors + responses: + "200": + description: A list of Catalog Descriptors. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogDescriptor' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalogs/{catalogId}: + get: + tags: + - Catalogs + summary: Get a catalog by id. + description: > + Returns a valid catalog. + operationId: getCatalog + parameters: + - name: catalogId + in: path + description: id for the Catalog. + required: true + schema: + type: string + responses: + "200": + description: A Single catalog. + content: + application/json: + schema: + $ref: '#/components/schemas/Catalog' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/slug/{slug}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided slug. + description: > + Returns the CatalogItem identified by a composite slug with format `{project-key}_{catalog-item-repository-name}`.
+ The separator is the first underscore (`_`); everything after it (the repo name) may itself contain underscores.
+ The project-key is the normalised (lowercase) Bitbucket project key that owns the item's repository. + The catalog-item-repository-name is matched against the Bitbucket repository slug of the item.
+ Returns 404 if no catalog item matches the provided slug. + operationId: getCatalogItemBySlug + parameters: + - name: slug + in: path + description: > + Composite slug with format `{project-key}_{catalog-item-repository-name}`. + The separator is the first underscore; the repo name may contain additional underscores. + Example: `myproject_my-component-repo` + required: true + schema: + type: string + example: 'myproject_my-component-repo' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid or malformed slug provided. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided slug. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items: + get: + tags: + - CatalogItems + summary: List of all CatalogItems. + description: > + Returns a list of all CatalogItems for the given Catalog identified by catalogId.
+ CatalogItems referenced on a Catalog that are either invalid or non-existent are **excluded** from the response. + operationId: getCatalogItems + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + - name: sortByTitle + in: query + description: Sort the returned CatalogItems by title, either in ascending or descending order. + required: true + schema: + $ref: '#/components/schemas/SortOrder' + example: 'asc' + responses: + "200": + description: A list of valid CatalogItems. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/{id}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided id. + description: > + Returns the CatalogItem associated to the provided id, unless: +
    +
  • The id is not associated to any CatalogItem.
  • +
  • Or the associated CatalogItem is invalid and can't be processed to create a response.
  • +
+ operationId: getCatalogItemById + parameters: + - name: id + in: path + description: id for the CatalogItem. + required: true + schema: + type: string + example: 'aSdFam...yCg==' + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /{projectKey}/catalog-items: + get: + tags: + - CatalogItems + summary: List of all CatalogItems given a project key. + description: > + Returns a list of all CatalogItems for the given Catalog identified by catalogId given a project key.
+ CatalogItems referenced on a Catalog that are either invalid or non-existent are **excluded** from the response. + operationId: getCatalogItemsForProjectKey + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + - name: sortByTitle + in: query + description: Sort the returned CatalogItems by title, either in ascending or descending order. + required: true + schema: + $ref: '#/components/schemas/SortOrder' + example: 'asc' + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: A list of valid CatalogItems. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItem' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /{projectKey}/catalog-items/{id}: + get: + tags: + - CatalogItems + summary: Returns the CatalogItem associated to the provided id, given a project key. + description: > + Returns the CatalogItem associated to the provided id, given a project key, unless: +
    +
  • The id is not associated to any CatalogItem.
  • +
  • Or the associated CatalogItem is invalid and can't be processed to create a response.
  • +
  • Project key does not exist or user has no visibility over it
  • +
+ operationId: getCatalogItemByIdForProjectKey + parameters: + - name: id + in: path + description: id for the CatalogItem. + required: true + schema: + type: string + example: 'aSdFam...yCg==' + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + responses: + "200": + description: The CatalogItem. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItem' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid CatalogItem associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-filters: + get: + tags: + - CatalogFilters + summary: List of all CatalogItemFilters. + description: > + Returns the list of all CatalogItemFilters for the CatalogItems on the Catalog identified by catalogId.
+ CatalogItemFilters are built based on the contents of the Catalog and its CatalogItems.
+ Catalog or CatalogItems **with errors** will affect the number and/or contents of the returned CatalogItemFilters. + operationId: getCatalogFilters + parameters: + - name: catalogId + in: query + description: id for the Catalog. + required: true + schema: + type: string + example: 'cHJvam...yCg==' + responses: + "200": + description: A list of CatalogItemFilters. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CatalogItemFilter' + "400": + description: Invalid parameters provided on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /files/{id}/contents: + get: + tags: + - Files + summary: Returns the contents of a File. + description: > + Returns the contents of a File entity associated to the provided id, unless: +
    +
  • The id is not associated to any File.
  • +
  • Or the associated File is invalid (e.g. corrupted) and can't be processed to create a response.
  • +
+ operationId: getFileById + parameters: + - name: id + in: path + description: id for the File. + required: true + schema: + type: string + example: 'cHJvam...yCg==' + - name: format + in: query + description: desired format for the returned File contents, **must** match the actual format. + required: true + schema: + $ref: '#/components/schemas/FileFormat' + example: image + responses: + "200": + description: File contents, either in binary or text format. + content: + "application/octet-stream": + schema: + type: string + format: byte + description: binary file contents. + example: '' + "text/*": + schema: + type: string + description: text file contents. + example: '# About\nThis repository contains the source code for...' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: No File associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "422": + description: Invalid File associated to the provided id. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /catalog-items/{catalogItemId}/user-actions/{userActionId}/messages-definitions/{messageDefinitionId}: + post: + tags: + - CatalogItemUserActionMessageDefinitions + summary: Get a message definition by id. + description: > + Returns an standard message definition + operationId: getMessageDefinitionByCatalogItemIdAndMessageId + parameters: + - name: catalogItemId + in: path + description: id for the CatalogItem + required: true + schema: + type: string + - name: userActionId + in: path + description: id for the CatalogItemUserAction + required: true + schema: + type: string + - name: messageDefinitionId + in: path + description: id for the CatalogItemUserActionMessageDefinition + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + responses: + "200": + description: A single message definition. + content: + application/json: + schema: + $ref: '#/components/schemas/CatalogItemUserActionMessageDefinition' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + /schema-validation/{className}: + post: + tags: + - SchemaValidations + summary: Validate a yaml against a proper schema. + description: > + Validates the provided Catalog schema against the expected format and structure.
+ Returns a 200 OK response if the schema is valid, otherwise returns a 400 Bad Request with details about the validation errors. + operationId: validateCatalogSchema + parameters: + - name: className + in: path + description: ClassName for the uploaded file, so we can get proper schema for validation. + required: true + schema: + type: string + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + "200": + description: Validation resul. + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResult' + '400': + description: Invalid input or validation failed + + /provision/{project-key}/{status}: + put: + tags: + - ProvisionerActions + summary: Create new project component + description: > + This endpoint will create a new project component. + operationId: notifyProvisioningStatusUpdate + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + patch: + tags: + - ProvisionerActions + summary: Update an existing project component + description: > + This endpoint receives provisioning status update notifications from AWX. + operationId: notifyProvisioningStatusUpdatePartially + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Provisioning status for the component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning status update completed. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/{project-key}: + delete: + security: + - basicAuth: [] # Enable ONLY basicAuth + tags: + - ProvisionerActions + summary: Delete provision status component from the file + description: > + This endpoint receives provisioning status delete notifications from Component Provisioner. + operationId: deleteProvisioningStatus + parameters: + - name: project-key + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningDeleteRequest' + responses: + "200": + description: Project component properly deleted. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + +components: + securitySchemes: + bearerAuth: + type: http + description: > + Authorization via bearer token. + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + description: Basic authentication for internal provisioning endpoint + schemas: + Catalog: + properties: + name: + type: string + example: 'catalog-name' + description: + type: string + example: 'A brief description for a catalog' + communityPageId: + type: string + example: 'aSdFam...yCg==' + links: + type: array + items: + $ref: '#/components/schemas/CatalogLink' + tags: + type: array + items: + type: string + example: + - 'tasks' + - 'technologies' + ProjectComponentInfo: + properties: + componentId: + type: string + example: 'edpc-4132-v2' + status: + type: string + example: 'CREATING' + canBeDeleted: + type: boolean + example: true + logoUrl: + type: string + example: https://somepic.jpg + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + ProjectComponentParameter: + properties: + name: + type: string + example: 'environment' + values: + type: array + items: + type: string + example: + - 'dev' + - 'test' + ProjectComponentExtendedInfo: + properties: + componentId: + type: string + example: 'nextjs-basic-app' + catalogItemId: + type: string + example: 'some-encoded-info' + catalogItemRef: + type: string + example: 'more-encoded-info' + status: + type: string + example: 'CREATING' + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + parameters: + type: array + items: + $ref: '#/components/schemas/ProjectComponentParameter' + CatalogDescriptor: + properties: + id: + type: string + example: 'aSdFam...yCg==' + slug: + type: string + example: 'aSdFam...yCg==' + CatalogLink: + properties: + url: + type: string + example: 'http://some-link.com' + name: + type: string + example: 'whatever name' + CatalogItem: + properties: + id: + type: string + example: 'aSdFam...yCg==' + slug: + type: string + description: > + Composite slug computed from the normalised Bitbucket project key and the repository slug of the item, + in the format `{project-key}_{repo-name}`. Calculated at mapping time; not retrieved from Bitbucket. + example: 'myproject_my-component-repo' + path: + type: string + example: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master + title: + type: string + example: An item title + shortDescription: + type: string + example: This is a short description for the item + descriptionFileId: + type: string + example: cHJvam...0ZXIK + imageFileId: + type: string + example: cHJvam...YXN0Z + itemSrc: + type: string + example: 'https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master' + tags: + type: array + items: + $ref: '#/components/schemas/CatalogItemTag' + authors: + type: array + items: + type: string + example: + - '@SomeAuthor' + - '@SomeOtherAuthor' + date: + type: string + format: date-time + example: '2021-07-01T00:00:00Z' + userActions: + type: array + items: + $ref: '#/components/schemas/CatalogItemUserAction' + restrictions: + $ref: '#/components/schemas/CatalogItemRestriction' + required: + - id + - title + - shortDescription + - description + - image + - authors + - date + example: + id: aSdFam...yCg== + slug: myproject_some-repo + path: projects/SOMEPROJECT/repos/some-repo/raw/CatalogItem.yaml?at=refs/heads/master + title: An item title + shortDescription: This is a short description for the item + descriptionFileId: cHJvam...0ZXIK + imageFileId: cHJvam...YXN0Z + itemSrc: https://bitbucket.some-company.com/projects/SOMEPROJECT/repos/some-repo/browse/CatalogItem.yaml?at=refs/heads/master + tags: + - label: data + options: + - 'some-data-option' + - 'some-other-data-option' + authors: + - '@SomeAuthor' + - '@SomeOtherAuthor' + date: "2021-07-01T00:00:00Z" + CatalogItemUserAction: + properties: + id: + type: string + nullable: false + example: 'CODE' + displayName: + type: string + nullable: false + example: 'View Code' + url: + type: string + nullable: true + example: 'https://quickstarter' + triggerMessage: + type: string + nullable: true + example: 'Provisioning a component' + requestable: + type: boolean + nullable: true + example: true + restrictionMessage: + type: string + nullable: true + example: 'You do not have permissions to provision this component.' + parameters: + type: array + items: + $ref: '#/components/schemas/CatalogItemUserActionParameter' + example: + id: "PROVISION" + triggerMessage: "Provisioning a component custom message" + parameters: + - name: "workflow" + type: "string" + required: true + defaultValue: "9987" + description: "Workflow to execute." + visible: false + CatalogItemUserActionMessageDefinition: + properties: + id: + type: string + nullable: false + example: 'OPENSHIFT_CONNECTION_ERROR' + type: + $ref: '#/components/schemas/CatalogItemUserActionMessageType' + title: + type: string + nullable: false + example: 'An error occurred while connecting to OpenShift' + message: + type: string + nullable: false + example: > + Authorization error: please check your user credentials for deployment + and try again later. + createsIncident: + type: boolean + nullable: false + example: + id: "OPENSHIFT_CONNECTION_ERROR" + title: "An error occurred while connecting to OpenShift" + message: > + Authorization error: please check your user credentials for deployment + and try again later. + CatalogItemUserActionMessageType: + type: string + enum: + - success + - error + example: error + CatalogItemUserActionParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + required: + type: boolean + example: 'true' + defaultValue: + type: string + nullable: true + example: '123' + defaultValues: + nullable: true + type: array + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + options: + nullable: true + type: array + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + locations: + type: array + nullable: true + items: + $ref: '#/components/schemas/CatalogItemUserActionParameterLocation' + label: + type: string + nullable: false + example: 'Workflow to execute.' + placeholder: + type: string + nullable: true + example: 'some placeholder for a workflow' + hint: + type: string + nullable: true + example: 'some hint for a workflow' + sendOnDeletion: + type: boolean + nullable: true + example: false + visible: + type: boolean + example: 'true' + validations: + type: array + nullable: true + items: + $ref: '#/components/schemas/CatalogItemUserActionParameterValidation' + example: + name: "workflow" + type: "string" + required: true + defaultValue: "9987" + description: "Workflow to execute." + visible: false + CatalogItemUserActionParameterValidation: + properties: + regex: + type: string + example: '/^[a-z\s]{0,255}$/i' + errorMessage: + type: string + example: 'There is an error in the provided value, please check it and try again.' + CatalogItemUserActionParameterLocation: + properties: + location: + type: string + example: 'EU' + value: + type: string + example: '1234' + CatalogItemTag: + properties: + label: + type: string + example: data + options: + type: array + uniqueItems: true + items: + type: string + example: + - 'some-data-option' + - 'some-other-data-option' + CatalogItemFilter: + properties: + label: + type: string + example: business + options: + type: array + uniqueItems: true + items: + type: string + example: + - 'some-business-option' + - 'some-other-business-option' + example: + label: business + options: + - 'some-business-option' + - 'some-other-business-option' + CatalogItemRestriction: + properties: + projects: + type: array + uniqueItems: true + items: + type: string + example: + - 'project-key-1' + - 'project-key-2' + SortOrder: + type: string + enum: + - asc + - desc + example: asc + FileFormat: + type: string + enum: + - image + - markdown + - yaml + example: markdown + RestErrorMessage: + properties: + message: + type: string + required: + - message + ValidationResult: + type: object + properties: + valid: + type: boolean + errors: + type: array + items: + $ref: '#/components/schemas/ValidationMessage' + ValidationMessage: + type: object + properties: + type: + type: string + code: + type: string + message: + type: string + required: + - message + + # === New, explicit request models to avoid sharing === + ProvisioningStatusUpdateRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. It may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + + componentUrl: + type: string + description: the repository url where the component was provisioned + example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" + nullable: true + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" + + ProvisioningDeleteRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - value + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" \ No newline at end of file diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml new file mode 100644 index 00000000..4de634aa --- /dev/null +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -0,0 +1,443 @@ +openapi: 3.0.3 +info: + title: Component Provisioner REST API + version: '1.0.0' + description: > + The Component Provisioner API allows clients to trigger Ansible Automation Platform (AWX) workflows. + + **NOTES**: + - The OpenAPI specification file is also used to [generate](https://openapi-generator.tech/) REST client(s) and a server REST API. + - Clients and servers generated from the same OpenAPI specification version are guaranteed to be **compatible**. + contact: + name: EDPCore Team + url: https://confluence.biscrum.com/pages/viewpage.action?spaceKey=EDP&title=Welcome +servers: + - url: http://{baseurl}/v1 + variables: + baseurl: + default: localhost:8080 + description: Default address for a Component Provisioner's backend REST API instance. +security: + - bearerAuth: [] +tags: + - name: ProvisionerHealth + description: Public actuator health endpoint. + - name: ProvisionerActions + description: ProvisionerAction operations. + - name: ProvisionerMessagesDefinitions + description: Provisioner standardized messages definitions + - name: ProvisionResults + description: Work with project components statuses +paths: + /actuator/health: + get: + tags: + - ProvisionerHealth + summary: Health check endpoint + description: Public actuator health endpoint. + operationId: getProvisionerHealth + security: [] + responses: + "200": + description: Service health status. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: UP + groups: + type: array + items: + type: string + required: + - status + + /provision-actions: + post: + tags: + - ProvisionerActions + summary: Execute a provisioning action with parameters + description: > + This endpoint receives ProvisionerActions from clients and triggers them in AWX. + operationId: triggerProvisionAction + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionAction' + responses: + "201": + description: Provisioning created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionActionResponse' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + + /catalog-items/{catalogItemId}/user-actions/{action}/message-definitions/{id}: + post: + tags: + - ProvisionerMessagesDefinitions + summary: Get a message definition by catalogItemId and id. + description: > + Returns an standard message definition + operationId: getMessageDefinitionByCatalogItemIdAndMessageId + parameters: + - name: catalogItemId + in: path + description: id for the Catalog Item where Message is defined. + required: true + schema: + type: string + - name: action + in: path + description: Action for the MessageDefinition. + required: true + schema: + type: string + - name: id + in: path + description: id for the MessageDefinition. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: + type: string + responses: + "200": + description: A single message definition. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionerMessageDefinition' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "404": + description: Catalog not found + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + + /provision/{projectKey}/{status}: + put: + tags: + - ProvisionResults + summary: Notify provisioning Status Update + description: > + This endpoint receives provisioning status update notifications from AWX. + operationId: notifyProvisioningStatusUpdate + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Project key of the provisioned component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + componentId: + type: string + description: The componentId set by the user. + example: "any-component-id-from-backend" + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. Mind that it may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + catalogItemSlug: + type: string + description: The slug for the provisioned component. + example: "myproject_repo_name" + componentUrl: + type: string + description: The bitbucket repository url for the provisioned component. + example: "https://bitbucket.com/projects/myproject/repos/repo_name" + responses: + "200": + description: Provisioning completion notified. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /provision/{projectKey}: + delete: + security: + - basicAuth: [ ] # Enable ONLY basicAuth + tags: + - ProvisionResults + summary: Delete provision status component from the file + description: > + This endpoint receives provisioning status delete notifications from Component Provisioner. + operationId: deleteProvisioningStatus + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningDeleteRequest' + responses: + "200": + description: Project component properly deleted. + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + + /support/delete/{projectKey}/{componentId}: + post: + tags: + - ProvisionResults + summary: Request App Support to do operations to delete provision status component (and dependencies) from the file + description: > + This endpoint receives project key and componentId and send an create an incident to app support. + operationId: createIncident + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: componentId + in: path + description: Component id of the provisioned component. + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateIncidentAction' + responses: + "201": + description: Incident properly created. + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisionActionResponse' + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. +components: + securitySchemes: + bearerAuth: + type: http + description: > + Authorization via bearer token. + scheme: bearer + bearerFormat: JWT + basicAuth: + type: http + scheme: basic + description: Basic authentication for internal provisioning endpoint + schemas: + ProvisionAction: + properties: + id: + type: string + nullable: false + example: 'PROVISION' + parameters: + type: array + items: + $ref: '#/components/schemas/ProvisionActionParameter' + example: + id: "PROVISION" + triggerMessage: "Provisioning a component custom message" + parameters: + - name: "workflow" + type: "string" + required: true + defaultValue: "2558" + description: "Workflow to execute." + visible: false + CreateIncidentAction: + properties: + parameters: + type: array + items: + $ref: '#/components/schemas/CreateIncidentParameter' + example: + parameters: + - name: "cluster_location" + type: "string" + value: "eu" + ProvisionActionParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + value: + type: object + nullable: false + example: '2558' + example: + name: "workflow" + type: "string" + value: "2558" + ProvisionActionResponse: + properties: + failed: + title: Job failed to execute + type: boolean + id: + title: Job ID + type: integer + created: + title: Job created timestamp + type: string + format: date-time + modified: + title: Job modified timestamp + type: string + format: date-time + ProvisionerMessageDefinition: + properties: + id: + type: string + nullable: false + example: 'OPENSHIFT_CONNECTION_ERROR' + type: + $ref: '#/components/schemas/ProvisionerMessageDefinitionType' + title: + type: string + nullable: false + example: 'An error occurred while connecting to OpenShift' + message: + type: string + nullable: false + example: > + Authorization error: please check your user credentials for deployment + and try again later. + createsIncident: + type: boolean + nullable: false + example: + id: "OPENSHIFT_CONNECTION_ERROR" + title: "An error occurred while connecting to OpenShift" + message: > + Authorization error: please check your user credentials for deployment + and try again later. + ProvisionerMessageDefinitionType: + type: string + enum: + - success + - error + example: error + RestErrorMessage: + properties: + message: + type: string + required: + - message + + ProvisioningDeleteRequest: + type: object + properties: + componentId: + type: string + description: The componentId set by the user. + example: "any-component-id-from-backend" + + CreateIncidentParameter: + properties: + name: + type: string + nullable: false + example: 'workflow' + type: + type: string + nullable: false + example: 'string' + value: + type: object + nullable: false + example: '2558' + example: + name: "workflow" + type: "string" + value: "2558" \ No newline at end of file diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index 63585515..bdc0f968 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -21,6 +21,13 @@ ${project.version}
+ + + org.opendevstack.apiservice + core-security + ${project.version} + + org.springframework.boot @@ -87,15 +94,130 @@ jakarta.annotation-api + + jakarta.validation + jakarta.validation-api + + + + io.swagger.core.v3 + swagger-annotations + 2.2.21 + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + + + org.apache.httpcomponents + httpclient + 4.5.14 + provided + + + org.springframework.boot spring-boot-starter-cache + + javax.annotation + javax.annotation-api + 1.3.2 + compile + + + org.jetbrains + annotations + 17.0.0 + compile + + + org.openapitools + openapi-generator-maven-plugin + + + generate-marketplace-catalog-client + + generate + + + FILTER=operationId:getProjectComponents|getProjectComponentById|getCatalogItemById|getCatalogItemBySlug|getCatalogHealth + java + ${project.basedir} + resttemplate + ${project.basedir}/openapi/openapi-component_catalog-v1.0.0.yaml + org.opendevstack.apiservice.externalservice.marketplace.openapi.api + org.opendevstack.apiservice.externalservice.marketplace.openapi.model + org.opendevstack.apiservice.externalservice.marketplace.openapi + false + true + true + true + false + false + false + false + + false + true + true + true + true + + + java8 + jackson + true + + + + + generate-marketplace-provisioner-client + + generate + + + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|createIncident|getProvisionerHealth + java + ${project.basedir} + resttemplate + ${project.basedir}/openapi/openapi-component_provisioner-v1.0.0.yaml + org.opendevstack.apiservice.externalservice.marketplace.openapi.api + org.opendevstack.apiservice.externalservice.marketplace.openapi.model + org.opendevstack.apiservice.externalservice.marketplace.openapi + false + true + true + true + false + false + false + false + + false + true + true + true + true + + + java8 + jackson + true + + + + + diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java new file mode 100644 index 00000000..7796f686 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -0,0 +1,70 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.openapitools.jackson.nullable.JsonNullableModule; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.auth.HttpBearerAuth; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +@Getter +@Slf4j +public class MarketplaceApiClient { + + + private final String instanceName; + private final MarketplaceInstanceConfig config; + private final ApiClient apiClient; + + /** + * Constructor for MarketplaceApiClient. + * + * @param instanceName Name of the Marketplace instance + * @param config Configuration for this instance + * @param restTemplate RestTemplate configured with appropriate timeouts and SSL settings + */ + public MarketplaceApiClient(String instanceName, MarketplaceInstanceConfig config, RestTemplate restTemplate) { + this.instanceName = instanceName; + this.config = config; + + // Configure ObjectMapper with JsonNullableModule for the RestTemplate + configureRestTemplateWithJsonNullable(restTemplate); + + // Initialize the generated ApiClient + this.apiClient = new ApiClient(restTemplate); + + log.info("MarketplaceApiClient initialized for instance '{}'", instanceName); + } + + /** + * Configure RestTemplate's ObjectMapper to handle JsonNullable types. + * + * @param restTemplate RestTemplate to configure + */ + private void configureRestTemplateWithJsonNullable(RestTemplate restTemplate) { + for (HttpMessageConverter converter : restTemplate.getMessageConverters()) { + if (converter instanceof MappingJackson2HttpMessageConverter jacksonConverter) { + ObjectMapper objectMapper = jacksonConverter.getObjectMapper(); + objectMapper.registerModule(new JsonNullableModule()); + log.debug("Registered JsonNullableModule with ObjectMapper for instance '{}'", instanceName); + return; + } + } + log.warn("No MappingJackson2HttpMessageConverter found in RestTemplate for instance '{}'", instanceName); + } + + /** + * Sets the bearer token for authentication on this client. + * This replaces any previously configured bearer token. + * + * @param bearerToken the bearer token to use for subsequent API calls + */ + public void setBearerToken(String bearerToken) { + HttpBearerAuth auth = (HttpBearerAuth) this.apiClient.getAuthentication("bearerAuth"); + auth.setBearerToken(bearerToken); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java new file mode 100644 index 00000000..093825d8 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java @@ -0,0 +1,215 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceServiceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.net.HttpURLConnection; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.Map; +import java.util.Set; + +@Component +@Slf4j +public class MarketplaceApiClientFactory { + + private final MarketplaceServiceConfig configuration; + private final RestTemplateBuilder restTemplateBuilder; + + /** + * Constructor with dependency injection. + * + * @param configuration Marketplace service configuration + * @param restTemplateBuilder RestTemplate builder for creating HTTP clients + */ + public MarketplaceApiClientFactory(MarketplaceServiceConfig configuration, + RestTemplateBuilder restTemplateBuilder) { + this.configuration = configuration; + this.restTemplateBuilder = restTemplateBuilder; + + log.info("MarketplaceApiClientFactory initialized with {} instance(s)", + configuration.getInstances().size()); + } + + /** + * Resolve the effective instance name. + *
    + *
  • If the default instance is configured via {@code externalservices.marketplace.default-instance}, it is returned.
  • + *
  • Otherwise the first entry of the instances map is returned (insertion order).
  • + *
  • If no instances are configured at all, a {@link MarketplaceException} is thrown.
  • + *
+ * + * @return The resolved instance name (never {@code null}/blank) + * @throws MarketplaceException if no Marketplace instances are configured + */ + public String getDefaultInstanceName() throws MarketplaceException { + + String defaultInstance = configuration.getDefaultInstance(); + if (defaultInstance != null && !defaultInstance.isBlank()) { + return defaultInstance; + } + + Map instances = configuration.getInstances(); + if (instances == null || instances.isEmpty()) { + throw new MarketplaceException("No Marketplace instances configured"); + } + + return instances.keySet().iterator().next(); + } + + /** + * Get a {@link MarketplaceApiClient} for a specific instance. + * If {@code instanceName} is {@code null} or blank, this method will throw a {@link MarketplaceException} + * to avoid ambiguity. The caller should explicitly call {@link #getClient()} to get the default instance client + * in that case. + * + * @param instanceName Name of the Marketplace instance, or {@code null}/{@code ""} for the default + * @return Configured MarketplaceApiClient + * @throws MarketplaceException if the instance is not configured + */ + public MarketplaceApiClient getClient(String instanceName) throws MarketplaceException { + if (instanceName == null || instanceName.isBlank()) { + throw new MarketplaceException( + String.format("Provide instance name. Available instances: %s", + configuration.getInstances().keySet())); + } + + MarketplaceInstanceConfig instanceConfig = configuration.getInstances().get(instanceName); + + if (instanceConfig == null) { + throw new MarketplaceException( + String.format("Marketplace instance '%s' is not configured. Available instances: %s", + instanceName, configuration.getInstances().keySet())); + } + + log.info("Creating new MarketplaceApiClient for instance '{}'", instanceName); + + RestTemplate restTemplate = createRestTemplate(instanceConfig); + return new MarketplaceApiClient(instanceName, instanceConfig, restTemplate); + } + + /** + * Get a {@link MarketplaceApiClient} for a specific instance with the given bearer token. + * + * @param instanceName Name of the Marketplace instance + * @param bearerToken Bearer token to use for authentication + * @return Configured MarketplaceApiClient with the given bearer token + * @throws MarketplaceException if the instance is not configured + */ + public MarketplaceApiClient getClient(String instanceName, String bearerToken) throws MarketplaceException { + MarketplaceApiClient client = getClient(instanceName); + client.setBearerToken(bearerToken); + return client; + } + + /** + * Get the default client, as determined by {@code externalservices.marketplace.default-instance}. + * Falls back to the first configured instance when {@code default-instance} is not set. + * + * @return MarketplaceApiClient for the default instance + * @throws MarketplaceException if no instances are configured + */ + public MarketplaceApiClient getClient() throws MarketplaceException { + String defaultInstanceName = getDefaultInstanceName(); + MarketplaceInstanceConfig instanceConfig = configuration.getInstances().get(defaultInstanceName); + RestTemplate restTemplate = createRestTemplate(instanceConfig); + + return new MarketplaceApiClient(defaultInstanceName, instanceConfig, restTemplate); + } + + /** + * Get all available instance names. + * + * @return Set of configured instance names + */ + public Set getAvailableInstances() { + return configuration.getInstances().keySet(); + } + + /** + * Check if an instance is configured. + * + * @param instanceName Name of the instance to check + * @return true if configured, false otherwise + */ + public boolean hasInstance(String instanceName) { + return configuration.getInstances().containsKey(instanceName); + } + + /** + * Create a configured RestTemplate for a Marketplace instance. + * + * @param config Configuration for the instance + * @return Configured RestTemplate + */ + private RestTemplate createRestTemplate(MarketplaceInstanceConfig config) { + RestTemplate restTemplate = restTemplateBuilder.build(); + + SimpleClientHttpRequestFactory requestFactory; + if (config.isTrustAllCertificates()) { + log.warn("Trust all certificates is enabled for Marketplace API connection. " + + "This should only be used in development environments!"); + requestFactory = createTrustAllRequestFactory(); + } else { + requestFactory = new SimpleClientHttpRequestFactory(); + } + requestFactory.setConnectTimeout(config.getConnectionTimeout()); + requestFactory.setReadTimeout(config.getReadTimeout()); + restTemplate.setRequestFactory(requestFactory); + + return restTemplate; + } + + /** + * Builds a {@link SimpleClientHttpRequestFactory} that disables certificate and hostname + * verification only for the connections opened by this factory (no JVM-global side effects). + * WARNING: should only be used in development environments. + */ + @SuppressWarnings({"java:S4830", "java:S5527"}) + private SimpleClientHttpRequestFactory createTrustAllRequestFactory() { + final javax.net.ssl.SSLSocketFactory socketFactory; + try { + TrustManager[] trustAllCertificates = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + public void checkClientTrusted(X509Certificate[] certs, String authType) { + // Intentionally empty - dev only + } + public void checkServerTrusted(X509Certificate[] certs, String authType) { + // Intentionally empty - dev only + } + } + }; + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustAllCertificates, new java.security.SecureRandom()); + socketFactory = context.getSocketFactory(); + } catch (NoSuchAlgorithmException | KeyManagementException ex) { + log.error("Failed to configure SSL trust all certificates for Marketplace API", ex); + return new SimpleClientHttpRequestFactory(); + } + + return new SimpleClientHttpRequestFactory() { + @Override + protected void prepareConnection(HttpURLConnection connection, String httpMethod) throws java.io.IOException { + if (connection instanceof HttpsURLConnection httpsConnection) { + httpsConnection.setSSLSocketFactory(socketFactory); + httpsConnection.setHostnameVerifier((hostname, session) -> true); + } + super.prepareConnection(connection, httpMethod); + } + }; + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java new file mode 100644 index 00000000..7b170d20 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -0,0 +1,38 @@ +package org.opendevstack.apiservice.externalservice.marketplace.config; + +import lombok.Data; + +@Data +public class MarketplaceInstanceConfig { + /** + * The project components base URL of the Marketplace + */ + private String projectComponentsBaseUrl; + + /** + * The provisioner actions base URL of the Marketplace + */ + private String provisionerActionsBaseUrl; + + /** + * Connection timeout in milliseconds (default: 30000) + */ + private int connectionTimeout = 30000; + + /** + * Read timeout in milliseconds (default: 30000). + */ + private int readTimeout = 30000; + + /** + * Whether to trust all SSL certificates (default: false). + * WARNING: Should only be used in development environments. + */ + private boolean trustAllCertificates = false; + + /** + * OAuth2 scope used for OBO token exchange when calling this Marketplace instance. + * Example: {@code api:///Api.Access} + */ + private String oboScope; +} \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java new file mode 100644 index 00000000..bbaec735 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceServiceConfig.java @@ -0,0 +1,25 @@ +package org.opendevstack.apiservice.externalservice.marketplace.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@ConfigurationProperties(prefix = "externalservices.marketplace") +@Data +public class MarketplaceServiceConfig { + + /** + * Name of the default Marketplace instance to use when no instance name is provided. + * If not set, the first configured instance is used as default. + */ + private String defaultInstance; + + /** + * Map of Marketplace instances with the instance name as the key and the configuration as the value. + */ + private Map instances = new HashMap<>(); +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java new file mode 100644 index 00000000..f31934c4 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/exception/MarketplaceException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.externalservice.marketplace.exception; + +public class MarketplaceException extends Exception { + + public MarketplaceException(String message) { + super(message); + } + + public MarketplaceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java deleted file mode 100644 index a4409c39..00000000 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/model/CreateComponentParameter.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.opendevstack.apiservice.externalservice.marketplace.model; - - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@NoArgsConstructor -@AllArgsConstructor -@Data -public class CreateComponentParameter { - - private String name; - private String type; - private String value; - -} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java new file mode 100644 index 00000000..14919d4f --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class CatalogItemOperations { + + private CatalogItemOperations() { + } + + public static byte[] encodeId(String id) { + return Base64.getUrlEncoder().encode(id.getBytes(StandardCharsets.UTF_8)); + } + + public static byte[] decodeId(String id) { + return Base64.getUrlDecoder().decode(id); + } + + public static String buildCatalogItemId(ProjectComponentExtendedInfo component) { + if (component == null || component.getCatalogItemId() == null || component.getCatalogItemRef() == null) { + return null; + } + String decodedId = new String(decodeId(component.getCatalogItemId()), StandardCharsets.UTF_8); + String decodedRef = new String(decodeId(component.getCatalogItemRef()), StandardCharsets.UTF_8); + return new String(encodeId(decodedId + decodedRef), StandardCharsets.UTF_8); + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index 8873f42e..fc47b9b2 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -1,15 +1,44 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; import org.opendevstack.apiservice.externalservice.api.ExternalService; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; import java.util.List; +import java.util.Set; public interface MarketplaceService extends ExternalService { + Set getAvailableInstances(); - ProjectComponent getProjectComponent(String projectId, String componentId); + boolean hasInstance(String instanceName); + + String getDefaultInstance() throws MarketplaceException; + + ProjectComponentExtendedInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException; + + ProjectComponentExtendedInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException; + + CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException; + + CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException; + + CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException; + + boolean provisionProjectComponent(String projectId, List params) throws MarketplaceException; + + boolean provisionProjectComponent(String instanceName, String projectId, List params) throws MarketplaceException; + + boolean deleteProjectComponent(String projectId, String componentId) throws MarketplaceException; + + boolean deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + + void registerProjectComponent(String projectId, String componentId) throws MarketplaceException; + + void registerProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; - ProjectComponent createProjectComponent(String projectId, List params); } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java new file mode 100644 index 00000000..1e58a502 --- /dev/null +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -0,0 +1,352 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service.impl; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.core.security.obo.OboTokenService; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.CatalogHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.CatalogItemsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProjectComponentsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionResultsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerActionsApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CreateIncidentAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetCatalogHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetProvisionerHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.NotifyProvisioningStatusUpdateRequest; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionAction; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.util.List; +import java.util.Set; + +@Service +@Slf4j +public class MarketplaceServiceImpl implements MarketplaceService { + private static final String HEALTH_STATUS_UP = "UP"; + + private final MarketplaceApiClientFactory clientFactory; + private final OboTokenService oboTokenService; + + public MarketplaceServiceImpl(MarketplaceApiClientFactory clientFactory, + OboTokenService oboTokenService) { + this.clientFactory = clientFactory; + this.oboTokenService = oboTokenService; + log.info("MarketplaceServiceImpl initialized"); + } + + + @Override + public CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException { + return getCatalogItem(getDefaultInstance(), catalogItemId); + } + + @Override + public CatalogItem getCatalogItem(String instanceName, String catalogItemId) throws MarketplaceException { + log.debug("Marketplace service GET catalog item with id {} in instance {} ", catalogItemId, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return catalogItemsApi.getCatalogItemById(catalogItemId); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog item with id '{}' not found in Marketplace instance '{}'", + catalogItemId, instanceName); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting catalog item '%s' in instance '%s'", + catalogItemId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog item '%s' in instance '%s'", + catalogItemId, instanceName), e); + } + } + + @Override + public CatalogItem getCatalogItemBySlug(String slug) throws MarketplaceException { + return getCatalogItemBySlug(getDefaultInstance(), slug); + } + + @Override + public CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws MarketplaceException { + log.debug("Marketplace service GET catalog item with slug {} in instance {} ", slug, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + CatalogItemsApi catalogItemsApi = new CatalogItemsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return catalogItemsApi.getCatalogItemBySlug(slug); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Catalog item with slug '{}' not found in Marketplace instance '{}'", + slug, instanceName); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting catalog item with slug '%s' in instance '%s'", + slug, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve catalog item with slug '%s' in instance '%s'", + slug, instanceName), e); + } + } + + @Override + public ProjectComponentExtendedInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException { + return getProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public ProjectComponentExtendedInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service GET component with id {} for project {} in instance {} ", componentId, projectId, instanceName); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + ProjectComponentsApi projectComponentsApi = new ProjectComponentsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); + return projectComponentsApi.getProjectComponentById(projectId, componentId); + } catch (HttpClientErrorException.NotFound e) { + log.debug("Component with id '{}' not found in Marketplace instance '{}' for project '{}'", + componentId, instanceName, projectId); + return null; + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when getting project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to retrieve project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + @Override + public boolean provisionProjectComponent(String projectId, List params) throws MarketplaceException { + return provisionProjectComponent(getDefaultInstance(), projectId, params); + } + + @Override + public boolean provisionProjectComponent(String instanceName, + String projectId, + List params) + throws MarketplaceException { + log.debug("Marketplace service PROVISION component for project {}: ", projectId); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + MarketplaceInstanceConfig config = marketplaceClient.getConfig(); + + String provisionerActionsBaseUrl = config.getProvisionerActionsBaseUrl(); + + ProvisionAction provisionAction = new ProvisionAction(); + provisionAction.setId("PROVISION"); + provisionAction.addParametersItem(new ProvisionActionParameter().name("project_key").type("string").value(projectId)); + + params.forEach(provisionAction::addParametersItem); + + ProvisionerActionsApi provisionerActionsApi = new ProvisionerActionsApi(apiClient); + apiClient.setBasePath(provisionerActionsBaseUrl); + + ProvisionActionResponse response = provisionerActionsApi.triggerProvisionAction(provisionAction); + return !Boolean.TRUE.equals(response.getFailed()); + } catch (HttpClientErrorException.Conflict e) { + throw new MarketplaceException("This component name already exists, please choose another name.", e); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when provisioning project component in project '%s' and instance '%s'", + projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to provision project component in project '%s' and instance '%s'", + projectId, instanceName), e); + } + } + + @Override + public boolean deleteProjectComponent(String projectId, String componentId) throws MarketplaceException { + return deleteProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public boolean deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service DELETE component {} for project {}: ", componentId, projectId); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + + ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); + log.debug("Api client base path: {}", apiClient.getBasePath()); + + CreateIncidentAction deleteAction = new CreateIncidentAction(); + ProvisionActionResponse response = provisionResultsApi.createIncident(projectId, componentId, deleteAction); + return !Boolean.TRUE.equals(response.getFailed()); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when deleting project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to delete project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + @Override + public void registerProjectComponent(String projectId, String componentId) throws MarketplaceException { + registerProjectComponent(getDefaultInstance(), projectId, componentId); + } + + @Override + public void registerProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + log.debug("Marketplace service REGISTER component {} for project {}: ", componentId, projectId); + try { + MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + ApiClient apiClient = marketplaceClient.getApiClient(); + + ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); + apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); + log.debug("Api client base path: {}", apiClient.getBasePath()); + + NotifyProvisioningStatusUpdateRequest registerRequest = new NotifyProvisioningStatusUpdateRequest(); + registerRequest.setComponentId(componentId); + provisionResultsApi.notifyProvisioningStatusUpdate(projectId, "CREATED", registerRequest); + } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { + throw new MarketplaceException( + String.format("Access denied when registering project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } catch (RestClientException e) { + throw new MarketplaceException( + String.format("Failed to register project component '%s' in project '%s' and instance '%s'", + componentId, projectId, instanceName), e); + } + } + + /** + * {@inheritDoc} + */ + @Override + public String getDefaultInstance() throws MarketplaceException { + return clientFactory.getDefaultInstanceName(); + } + + /** + * {@inheritDoc} + * + * Returns {@code false} (without throwing) if no instances are configured or + * if either public Marketplace health endpoint is not UP. + */ + @Override + public boolean isHealthy() { + Set instances = getAvailableInstances(); + if (instances.isEmpty()) { + log.warn("No Marketplace instances configured - reporting unhealthy"); + return false; + } + + final MarketplaceApiClient marketplaceClient; + try { + marketplaceClient = clientFactory.getClient(getDefaultInstance()); + } catch (MarketplaceException ex) { + log.warn("Could not resolve Marketplace client for health check", ex); + return false; + } + + boolean provisionerHealthy = isProvisionerEndpointUp(marketplaceClient); + if (!provisionerHealthy) { + return false; + } + + return isCatalogEndpointUp(marketplaceClient); + } + + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + try { + String provisionerBaseUrl = marketplaceClient.getConfig().getProvisionerActionsBaseUrl(); + ApiClient healthApiClient = marketplaceClient.getApiClient(); + healthApiClient.setBasePath(provisionerBaseUrl); + + ProvisionerHealthApi healthApi = new ProvisionerHealthApi(healthApiClient); + GetProvisionerHealth200Response response = healthApi.getProvisionerHealth(); + return isStatusUp(response != null ? response.getStatus() : null, + "provisionerActionsBaseUrl", provisionerBaseUrl); + } catch (RestClientException ex) { + log.warn("Health check via provisionerActionsBaseUrl failed", ex); + return false; + } + } + + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + try { + String catalogBaseUrl = marketplaceClient.getConfig().getProjectComponentsBaseUrl(); + ApiClient healthApiClient = marketplaceClient.getApiClient(); + healthApiClient.setBasePath(catalogBaseUrl); + + CatalogHealthApi healthApi = new CatalogHealthApi(healthApiClient); + GetCatalogHealth200Response response = healthApi.getCatalogHealth(); + return isStatusUp(response != null ? response.getStatus() : null, + "projectComponentsBaseUrl", catalogBaseUrl); + } catch (RestClientException ex) { + log.warn("Health check via projectComponentsBaseUrl failed", ex); + return false; + } + } + + private boolean isStatusUp(String status, String endpointName, String baseUrl) { + boolean healthy = HEALTH_STATUS_UP.equals(status); + if (!healthy) { + log.warn("Health endpoint from {}='{}' returned status '{}'", endpointName, baseUrl, status); + } + return healthy; + } + + @Override + public Set getAvailableInstances() { + return clientFactory.getAvailableInstances(); + } + + @Override + public boolean hasInstance(String instanceName) { + return clientFactory.hasInstance(instanceName); + } + + /** + * Creates a {@link MarketplaceApiClient} authenticated with an OBO token + * obtained from the current request's JWT. + */ + private MarketplaceApiClient getOboAuthenticatedClient(String instanceName) throws MarketplaceException { + MarketplaceApiClient client = clientFactory.getClient(instanceName); + String oboScope = client.getConfig().getOboScope(); + if (oboScope == null || oboScope.isBlank()) { + throw new MarketplaceException( + String.format("OBO scope not configured for Marketplace instance '%s'", instanceName)); + } + String assertion = JwtUtils.getTokenValue(); + final String oboToken; + try { + oboToken = oboTokenService.exchangeToken(assertion, oboScope); + } catch (RuntimeException ex) { + throw new MarketplaceException( + String.format( + "Failed to exchange OBO token for Marketplace instance '%s' with scope '%s'", + instanceName, oboScope), + ex); + } + client.setBearerToken(oboToken); + return client; + } +} diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java deleted file mode 100644 index ec1007c0..00000000 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceMockImpl.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.opendevstack.apiservice.externalservice.marketplace.service.impl; - -import lombok.extern.slf4j.Slf4j; -import org.opendevstack.apiservice.externalservice.marketplace.model.CreateComponentParameter; -import org.opendevstack.apiservice.externalservice.marketplace.model.ProjectComponent; -import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; -import org.springframework.stereotype.Service; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; - -@Service -@Slf4j -public class MarketplaceServiceMockImpl implements MarketplaceService { - - @Override - public boolean isHealthy() { - return true; - } - - private Map mockComponentsCache = new HashMap<>(); - - public ProjectComponent getProjectComponent(String projectId, String componentId) { - log.info("Get component with id '" + componentId + "' for project '" + projectId + "'"); - ComposedId composedId = new ComposedId(projectId, componentId); - return mockComponentsCache.get(composedId); - } - - public ProjectComponent createProjectComponent(String projectId, List createComponentParams) { - log.info("Creating component for project '" + projectId + "'" + " with request: " + createComponentParams); - ProjectComponent mockComponent = new ProjectComponent(); - mockComponent.setComponentId(UUID.randomUUID()); - mockComponent.setCanBeDeleted(true); - mockComponent.setStatus("CREATING"); - mockComponent.setName(extractParam(createComponentParams, "component_id")); - mockComponent.setProductId(extractParam(createComponentParams, "component_type")); - mockComponent.setProductName("Mock Product"); - mockComponent.setProductDescription("Mock product description"); - mockComponent.setEnvironment("DEV"); - mockComponent.setComponentType("ODS"); - ComposedId composedId = new ComposedId(projectId, mockComponent.getComponentId().toString()); - mockComponentsCache.put(composedId, mockComponent); - - return mockComponent; - } - - private String extractParam(List params, String key) { - return params.stream() - .filter(p -> key.equals(p.getName())) - .map(CreateComponentParameter::getValue) - .findFirst() - .orElse(null); - } - - class ComposedId { - private String projectId; - private String componentId; - - public ComposedId(String projectId, String componentId) { - this.projectId = projectId; - this.componentId = componentId; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - - ComposedId that = (ComposedId) o; - - if (!projectId.equals(that.projectId)) return false; - return componentId.equals(that.componentId); - } - - @Override - public int hashCode() { - int result = projectId.hashCode(); - result = 31 * result + componentId.hashCode(); - return result; - } - } -} diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java new file mode 100644 index 00000000..d8bdbfe3 --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactoryTest.java @@ -0,0 +1,163 @@ +package org.opendevstack.apiservice.externalservice.marketplace.client; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceServiceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.web.client.RestTemplate; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarketplaceApiClientFactory}. + * Focuses on the default-instance resolution logic introduced in {@code resolveInstanceName}. + */ +@ExtendWith(MockitoExtension.class) +class MarketplaceApiClientFactoryTest { + + @Mock + private RestTemplateBuilder restTemplateBuilder; + + @Mock + private RestTemplate restTemplate; + + private MarketplaceServiceConfig configuration; + + @BeforeEach + void setUp() { + configuration = new MarketplaceServiceConfig(); + lenient().when(restTemplateBuilder.build()).thenReturn(restTemplate); + } + + private MarketplaceApiClientFactory factory() { + return new MarketplaceApiClientFactory(configuration, restTemplateBuilder); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName → configured default + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_returnsConfiguredDefaultInstance() throws MarketplaceException { + configuration.setDefaultInstance("prod"); + + assertEquals("prod", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName – without default → fallback to first instance + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_noDefaultConfigured_returnsFirstInstance() throws MarketplaceException { + // LinkedHashMap preserves insertion order → "alpha" is first + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://marketplace.example.com")); + instances.put("beta", config("https://marketplace-beta.example.com")); + configuration.setInstances(instances); + + assertEquals("alpha", factory().getDefaultInstanceName()); + } + + // ------------------------------------------------------------------------- + // resolveInstanceName – no instances at all → exception + // ------------------------------------------------------------------------- + + @Test + void resolveInstanceName_null_noInstancesConfigured_throwsMarketplaceException() { + // no instances set → empty map + MarketplaceApiClientFactory f = factory(); + + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> f.getDefaultInstanceName()); + assertTrue(ex.getMessage().toLowerCase().contains("no marketplace instances configured"), + "Expected 'no marketplace instances configured' in: " + ex.getMessage()); + } + + @Test + void getClient_null_throwsMarketplaceException() throws MarketplaceException { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient(null)); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_blank_throwsMarketplaceException() throws MarketplaceException { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient("")); + assertTrue(ex.getMessage().toLowerCase().contains("provide instance name"), + "Expected 'provide instance name' in: " + ex.getMessage()); + } + + @Test + void getClient_unknownInstance_throwsMarketplaceException() { + configuration.setInstances(Map.of("dev", config("https://marketplace.dev.example.com"))); + + MarketplaceException ex = assertThrows(MarketplaceException.class, + () -> factory().getClient("nonexistent")); + assertTrue(ex.getMessage().contains("not configured")); + assertTrue(ex.getMessage().contains("nonexistent")); + } + + @Test + void getClient_returnsClientForConfiguredDefaultInstance() throws MarketplaceException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + configuration.setDefaultInstance("prod"); + configuration.setInstances(orderedMap("dev", "prod")); + + MarketplaceApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("prod", client.getInstanceName()); + } + + @Test + void getClient_noDefaultConfigured_returnsFirstInstance() throws MarketplaceException { + when(restTemplateBuilder.build()).thenReturn(restTemplate); + Map instances = new LinkedHashMap<>(); + instances.put("alpha", config("https://marketplace.example.com")); + instances.put("beta", config("https://marketplace-beta.example.com")); + configuration.setInstances(instances); + + MarketplaceApiClient client = factory().getClient(); + + assertNotNull(client); + assertEquals("alpha", client.getInstanceName()); + } + + @Test + void getClient_noInstancesConfigured_throwsMarketplaceException() { + MarketplaceException ex = assertThrows(MarketplaceException.class, () -> factory().getClient()); + assertTrue(ex.getMessage().toLowerCase().contains("no marketplace instances configured")); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static MarketplaceInstanceConfig config(String baseUrl) { + MarketplaceInstanceConfig c = new MarketplaceInstanceConfig(); + c.setProjectComponentsBaseUrl(baseUrl); + c.setProvisionerActionsBaseUrl(baseUrl); + return c; + } + + /** Creates a LinkedHashMap with two configs using their names as base-url stems. */ + private static Map orderedMap(String first, String second) { + Map m = new LinkedHashMap<>(); + m.put(first, config("https://" + first + ".example.com")); + m.put(second, config("https://" + second + ".example.com")); + return m; + } +} \ No newline at end of file diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java new file mode 100644 index 00000000..74bce9f2 --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java @@ -0,0 +1,39 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.junit.jupiter.api.Test; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +class CatalogItemOperationsTest { + + @Test + void testBuildCatalogItemId_whenNullComponent_ReturnNull() { + String catalogItemId = CatalogItemOperations.buildCatalogItemId(null); + assertNull(catalogItemId); + } + + @Test + void testBuildCatalogItemId_whenNullValues_ReturnNull() { + ProjectComponentExtendedInfo testComponentExtendedInfo = new ProjectComponentExtendedInfo(); + testComponentExtendedInfo.setCatalogItemId(null); + testComponentExtendedInfo.setCatalogItemRef(null); + + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentExtendedInfo); + assertNull(catalogItemId); + } + + @Test + void testBuildCatalogItemId_whenCorrectValues_ReturnCorrectlyBuiltId() { + ProjectComponentExtendedInfo testComponentExtendedInfo = new ProjectComponentExtendedInfo(); + testComponentExtendedInfo.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + testComponentExtendedInfo.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); + + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentExtendedInfo); + assertNotNull(catalogItemId); + assertEquals("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy", catalogItemId); + } + +} \ No newline at end of file diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java new file mode 100644 index 00000000..4f6749ac --- /dev/null +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -0,0 +1,956 @@ +package org.opendevstack.apiservice.externalservice.marketplace.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.stubbing.OngoingStubbing; +import org.opendevstack.apiservice.core.security.obo.OboTokenService; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; +import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClientException; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link MarketplaceService}. + * These tests use mocks and do not require actual Marketplace connectivity. + */ +@ExtendWith(MockitoExtension.class) +class MarketplaceServiceImplTest { + + // Generated-API endpoint paths invoked by ApiClient#invokeAPI. These mirror the + // values produced by the OpenAPI generator so tests fail loudly if a path or + // HTTP method changes unexpectedly. + private static final String PATH_CATALOG_ITEM_BY_ID = "/catalog-items/{id}"; + private static final String PATH_PROJECT_COMPONENT = "/project/{projectKey}/component/{componentId}"; + private static final String PATH_PROVISION_ACTIONS = "/provision-actions"; + private static final String PATH_DELETE_COMPONENT = "/support/delete/{projectKey}/{componentId}"; + private static final String PATH_NOTIFY_PROVISIONING = "/provision/{projectKey}/{status}"; + + @Mock + private MarketplaceApiClientFactory clientFactory; + + @Mock + private MarketplaceApiClient marketplaceApiClient; + + @Mock + private ApiClient apiClient; + + @Mock + private OboTokenService oboTokenService; + + private MarketplaceService marketplaceService; + + @BeforeEach + void setUp() { + marketplaceService = new MarketplaceServiceImpl(clientFactory, oboTokenService); + + // Set up a fake SecurityContext so JwtUtils.getTokenValue() works + Jwt jwt = Jwt.withTokenValue("test-jwt-assertion") + .header("alg", "RS256") + .claim("azp", "test-client-id") + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(3600)) + .build(); + SecurityContext ctx = SecurityContextHolder.createEmptyContext(); + ctx.setAuthentication(new JwtAuthenticationToken(jwt)); + SecurityContextHolder.setContext(ctx); + + // Default OBO stub — tests that fail before OBO won't reach this + lenient().when(oboTokenService.exchangeToken(anyString(), anyString())) + .thenReturn("obo-test-token"); + + // Stub ApiClient utility methods used by the generated ProjectApi / ServerInfoApi + // before invokeAPI is reached. Without these, putAll(null) causes NullPointerException. + lenient().when(apiClient.parameterToMultiValueMap(any(), anyString(), any())) + .thenReturn(new LinkedMultiValueMap<>()); + lenient().when(apiClient.selectHeaderAccept(any())) + .thenReturn(List.of(MediaType.APPLICATION_JSON)); + lenient().when(apiClient.selectHeaderContentType(any())) + .thenReturn(MediaType.APPLICATION_JSON); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + /** + * Stubs {@code apiClient.invokeAPI(...)} for a specific endpoint path and HTTP method. + * Using {@code eq(path)} and {@code eq(method)} (instead of bare {@code any()} for every + * argument) makes the test assert that the service is calling the expected generated-API + * operation, while still being tolerant of the surrounding boilerplate arguments. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private OngoingStubbing> whenInvokeAPI(String path, HttpMethod method) { + return (OngoingStubbing) when(apiClient.invokeAPI( + eq(path), + eq(method), + any(), + any(), + any(), + any(HttpHeaders.class), + any(), + any(), + any(), + any(), + any(), + any(ParameterizedTypeReference.class))); + } + + // ------------------------------------------------------------------------- + // getProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_InstanceNotConfigured() throws MarketplaceException { + // Arrange + String instanceName = "nonexistent"; + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient(instanceName)) + .thenThrow(new MarketplaceException("Marketplace instance 'nonexistent' is not configured")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("not configured")); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetProjectComponent_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "UNKNOWN"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + // ------------------------------------------------------------------------- + // isHealthy + // ------------------------------------------------------------------------- + + @Test + void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + @Test + void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; + + boolean result = service.isHealthy(); + + assertTrue(result); + } + + @Test + void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + @Test + void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; + + boolean result = service.isHealthy(); + + assertFalse(result); + } + + // ------------------------------------------------------------------------- + // getAvailableInstances / hasInstance + // ------------------------------------------------------------------------- + + @Test + void testGetAvailableInstances() { + // Arrange + Set expected = Set.of("dev", "prod"); + when(clientFactory.getAvailableInstances()).thenReturn(expected); + + // Act + Set result = marketplaceService.getAvailableInstances(); + + // Assert + assertEquals(expected, result); + verify(clientFactory).getAvailableInstances(); + } + + @Test + void testHasInstance_Existing_ReturnsTrue() { + // Arrange + when(clientFactory.hasInstance("dev")).thenReturn(true); + + // Act + Assert + assertTrue(marketplaceService.hasInstance("dev")); + } + + @Test + void testHasInstance_NonExistent_ReturnsFalse() { + // Arrange + when(clientFactory.hasInstance("nope")).thenReturn(false); + + // Act + Assert + assertFalse(marketplaceService.hasInstance("nope")); + } + + // ------------------------------------------------------------------------- + // Default-instance support + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_NoInstanceArg_UsesDefaultClient() throws MarketplaceException { + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenReturn(ResponseEntity.ok(null)); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + verify(clientFactory).getClient("default"); + } + + @Test + void testGetProjectComponent_NullInstanceName_PropagatesFactoryException() throws MarketplaceException { + // The factory rejects null/blank instance names; the service must surface that. + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient(null)) + .thenThrow(new MarketplaceException("Provide instance name. Available instances: []")); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(null, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Provide instance name")); + verify(clientFactory).getClient(null); + } + + @Test + void testGetProjectComponent_BlankInstanceName_PropagatesFactoryException() throws MarketplaceException { + // The factory rejects null/blank instance names; the service must surface that. + String projectKey = "PROJ"; + String componentId = "test-component"; + + when(clientFactory.getClient("")) + .thenThrow(new MarketplaceException("Provide instance name. Available instances: []")); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent("", projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Provide instance name")); + verify(clientFactory).getClient(""); + } + + @Test + void testGetProjectComponent_NoInstanceArg_NotFound_ReturnsFalse() throws MarketplaceException { + String projectKey = "ZZZNOPE"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + + assertNull(result); + } + + @Test + void testGetProjectComponent_NoInstanceArg_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(new RestClientException("timeout")); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + assertThrows(MarketplaceException.class, () -> marketplaceService.getProjectComponent("PROJ", + "test-component")); + } + + @Test + void testGetDefaultInstance_DelegatesToFactory() throws MarketplaceException { + when(clientFactory.getDefaultInstanceName()).thenReturn("prod"); + + String result = marketplaceService.getDefaultInstance(); + + assertEquals("prod", result); + verify(clientFactory).getDefaultInstanceName(); + } + + @Test + void testGetDefaultInstance_FactoryThrows_PropagatesException() throws MarketplaceException { + when(clientFactory.getDefaultInstanceName()) + .thenThrow(new MarketplaceException("No Marketplace instances configured")); + + assertThrows(MarketplaceException.class, () -> marketplaceService.getDefaultInstance()); + } + + @Test + void testProvisionProjectComponent_Conflict_ThrowsMarketplaceExceptionWithDuplicateMessage() throws MarketplaceException { + String instanceName = "dev"; + String projectKey = "EDPC"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + HttpClientErrorException conflictEx = HttpClientErrorException.create( + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + "{\"message\":\"This component name already exists, please choose another name.\"}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 + ); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).thenThrow(conflictEx); + + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertEquals("This component name already exists, please choose another name.", exception.getMessage()); + } + + @Test + void testGetCatalogItem_RestClientException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET) + .thenThrow(new RestClientException("Connection failed")); + + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.getCatalogItem(instanceName, catalogItemId)); + + verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); + } + + @Test + void testGetCatalogItem_NotFound_ReturnsNull() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + CatalogItem result = marketplaceService.getCatalogItem(instanceName, catalogItemId); + + // Assert + assertNull(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testGetCatalogItem_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(forbiddenEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getCatalogItem(instanceName, catalogItemId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testGetCatalogItem_DefaultInstance() throws MarketplaceException { + // Arrange + String catalogItemId = "test-catalog-item-base64-string"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException notFoundEx = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + whenInvokeAPI(PATH_CATALOG_ITEM_BY_ID, HttpMethod.GET).thenThrow(notFoundEx); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act + CatalogItem result = marketplaceService.getCatalogItem(catalogItemId); + + // Assert + assertNull(result); + verify(clientFactory).getClient("default"); + } + + // ------------------------------------------------------------------------- + // getProjectComponent — auth error + // ------------------------------------------------------------------------- + + @Test + void testGetProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(forbiddenEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.getProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + // ------------------------------------------------------------------------- + // provisionProjectComponent — success, auth error, REST error, default instance + // ------------------------------------------------------------------------- + + @Test + void testProvisionProjectComponent_Success_ReturnsTrue() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of()); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testProvisionProjectComponent_Failed_ReturnsFalse() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(true); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of()); + + // Assert + assertFalse(result); + } + + @Test + void testProvisionProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("Failed to provision")); + } + + @Test + void testProvisionProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException unauthorizedEx = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).thenThrow(unauthorizedEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testProvisionProjectComponent_DefaultInstance_Success() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.provisionProjectComponent(projectKey, List.of()); + + // Assert + assertTrue(result); + verify(clientFactory).getClient("default"); + } + + @Test + void testProvisionProjectComponent_OboScopeNotConfigured_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + // OBO scope is null + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + + assertTrue(exception.getMessage().contains("OBO scope not configured")); + } + + // ------------------------------------------------------------------------- + // deleteProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testDeleteProjectComponent_Success_ReturnsTrue() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertTrue(result); + verify(clientFactory).getClient(instanceName); + } + + @Test + void testDeleteProjectComponent_Failed_ReturnsFalse() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(true); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId); + + // Assert + assertFalse(result); + } + + @Test + void testDeleteProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Failed to delete")); + } + + @Test + void testDeleteProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException forbiddenEx = HttpClientErrorException.create( + HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST).thenThrow(forbiddenEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testDeleteProjectComponent_DefaultInstance() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + ProvisionActionResponse mockResponse = new ProvisionActionResponse(); + mockResponse.setFailed(false); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) + .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + + // Act + boolean result = marketplaceService.deleteProjectComponent(projectKey, componentId); + + // Assert + assertTrue(result); + verify(clientFactory).getClient("default"); + } + + // ------------------------------------------------------------------------- + // registerProjectComponent + // ------------------------------------------------------------------------- + + @Test + void testRegisterProjectComponent_Success() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + + // Act — should not throw + marketplaceService.registerProjectComponent(instanceName, projectKey, componentId); + + // Assert + verify(clientFactory).getClient(instanceName); + } + + @Test + void testRegisterProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT) + .thenThrow(new RestClientException("Connection refused")); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Failed to register")); + } + + @Test + void testRegisterProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + // Arrange + String instanceName = "dev"; + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException unauthorizedEx = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(unauthorizedEx); + + // Act & Assert + MarketplaceException exception = assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(instanceName, projectKey, componentId)); + + assertTrue(exception.getMessage().contains("Access denied")); + } + + @Test + void testRegisterProjectComponent_DefaultInstance() throws MarketplaceException { + // Arrange + String projectKey = "PROJ"; + String componentId = "test-component"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); + instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); + instanceConfig.setOboScope("api://test/scope"); + + when(clientFactory.getDefaultInstanceName()).thenReturn("default"); + when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + + // Act — should not throw + marketplaceService.registerProjectComponent(projectKey, componentId); + + // Assert + verify(clientFactory).getClient("default"); + } + +} \ No newline at end of file diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java index 408d54bf..9b85c97d 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/dao/PolicyDaoImpl.java @@ -41,7 +41,7 @@ public List findByApiDefinitionIdAndClientId(String apiDefinitionId, @Override public List findGlobalByApiDefinitionId(String apiDefinitionId) { - return repository.findByApiDefinitionIdAndClientIdIsNull(apiDefinitionId).stream() + return repository.findGlobalByApiDefinitionId(apiDefinitionId).stream() .map(this::toDto) .toList(); } diff --git a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java index 8f71fb73..1df91826 100644 --- a/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java +++ b/persistence/src/main/java/org/opendevstack/apiservice/persistence/repository/AuthorizationPolicyJpaRepository.java @@ -1,6 +1,8 @@ package org.opendevstack.apiservice.persistence.repository; import org.opendevstack.apiservice.persistence.entity.AuthorizationPolicyEntity; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -12,5 +14,11 @@ public interface AuthorizationPolicyJpaRepository extends JpaRepository findByApiDefinitionIdAndClientId(String apiDefinitionId, String clientId); - List findByApiDefinitionIdAndClientIdIsNull(String apiDefinitionId); + @Query(""" + SELECT p + FROM AuthorizationPolicyEntity p + WHERE p.apiDefinitionId = :apiDefinitionId + AND (p.clientId IS NULL OR TRIM(p.clientId) = '') + """) + List findGlobalByApiDefinitionId(@Param("apiDefinitionId") String apiDefinitionId); } From f7c9aae3fe67187825a78fded25778dc48356aad Mon Sep 17 00:00:00 2001 From: Vali Tuguran Date: Thu, 7 May 2026 20:25:33 +0200 Subject: [PATCH 15/27] Feature/marketplace deletion (#23) Update marketplace deletion service and controller with tests. --------- Co-authored-by: zxBCN Valeriu_Tuguran,Constantin (IT EDP) EXTERNAL Co-authored-by: Angel Martinez Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api-project-component-v0-internal.yaml | 66 +++++ api-project-component-v0/pom.xml | 29 ++ .../ProjectComponentsInternalController.java | 29 ++ .../ProjectComponentsExceptionHandler.java | 12 +- .../exception/ComponentDeletionException.java | 12 + .../project/facade/ComponentsFacade.java | 30 +- .../ProjectComponentsControllerTest.java | 1 + ...ojectComponentsInternalControllerTest.java | 64 +++++ .../project/facade/ComponentsFacadeTest.java | 23 +- .../openapi-component_provisioner-v1.0.0.yaml | 2 +- external-service-marketplace/pom.xml | 2 +- .../client/MarketplaceApiClient.java | 6 + .../client/MarketplaceApiClientFactory.java | 6 +- .../config/MarketplaceInstanceConfig.java | 10 + .../service/MarketplaceService.java | 4 +- .../service/impl/MarketplaceServiceImpl.java | 20 +- .../service/MarketplaceServiceImplTest.java | 257 +++++++----------- 17 files changed, 391 insertions(+), 182 deletions(-) create mode 100644 api-project-component-v0/openapi/api-project-component-v0-internal.yaml create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java create mode 100644 api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java diff --git a/api-project-component-v0/openapi/api-project-component-v0-internal.yaml b/api-project-component-v0/openapi/api-project-component-v0-internal.yaml new file mode 100644 index 00000000..a903d6e1 --- /dev/null +++ b/api-project-component-v0/openapi/api-project-component-v0-internal.yaml @@ -0,0 +1,66 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v1 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: + - name: Project Components Internal + description: API for managing project components with extended methods + +paths: + /projects/{projectId}/components/{componentId}: + delete: + tags: + - Project Components Internal + summary: Delete component information + operationId: deleteProjectComponent + description: Deletes a specific component + parameters: + - name: projectId + in: path + required: true + description: Project key + schema: + type: string + pattern: "^[A-Z]{2}[A-Z0-9]{1,8}$" + minLength: 3 + maxLength: 10 + - name: componentId + in: path + required: true + description: Component id + schema: + type: string + minLength: 2 + maxLength: 52 + pattern: "^[a-z]+(-?[a-z0-9]+)*$" + responses: + "204": + description: Project component properly deleted or no component found for the provided componentId. + "400": + description: Bad request. + "401": + description: Invalid credentials on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + +components: + securitySchemes: + basicAuth: + type: http + scheme: basic + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + diff --git a/api-project-component-v0/pom.xml b/api-project-component-v0/pom.xml index 35f3ddf5..495e438e 100644 --- a/api-project-component-v0/pom.xml +++ b/api-project-component-v0/pom.xml @@ -161,6 +161,35 @@ + + generate-api-project-component-extended + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-component-v0-internal.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + +
diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java new file mode 100644 index 00000000..63c1f3d1 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalController.java @@ -0,0 +1,29 @@ +package org.opendevstack.apiservice.project.controller; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.api.ProjectComponentsInternalApi; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@AllArgsConstructor +@Slf4j +@RequestMapping(ProjectComponentsInternalController.API_BASE_PATH) +public class ProjectComponentsInternalController implements ProjectComponentsInternalApi { + + public static final String API_BASE_PATH = "/api/pub/v1"; + + private final ComponentsFacade componentsFacade; + + @Override + public ResponseEntity deleteProjectComponent(String projectId, String componentId) { + componentsFacade.deleteProjectComponent(projectId, componentId); + log.info("Deleted component with id '{}' for project '{}'", componentId, projectId); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java index add5c7f8..5af85796 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -7,10 +7,10 @@ import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; -import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -142,6 +142,16 @@ public ResponseEntity handleComponentCreationException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + @ExceptionHandler(ComponentDeletionException.class) + public ResponseEntity handleComponentDeletionException( + ComponentDeletionException ex, + HttpServletRequest request) { + + log.error("Component deletion failed: {}", ex.getMessage(), ex); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null); + } + @ExceptionHandler(ComponentRetrievalException.class) public ResponseEntity handleComponentRetrievalException( ComponentRetrievalException ex, diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java new file mode 100644 index 00000000..f1eeffad --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentDeletionException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentDeletionException extends RuntimeException { + + public ComponentDeletionException(String message) { + super(message); + } + + public ComponentDeletionException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 38883104..56cc6c38 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -12,6 +12,7 @@ import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; @@ -144,13 +145,34 @@ private String extractHttpErrorMessage(Throwable throwable) { return throwable.getMessage(); } - public Boolean deleteProjectComponent(String projectId, String componentId) { + public void deleteProjectComponent(String projectId, String componentId) { try { - return marketplaceExternalService.deleteProjectComponent(projectId, componentId); + marketplaceExternalService.deleteProjectComponent(projectId, componentId); + log.info("Successfully deleted component '{}' for project '{}'", componentId, projectId); } catch (MarketplaceException e) { - log.error("Failed to delete component with id {} for project with id {}", componentId, projectId, e); - return false; + log.error("Failed to delete component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); + // Check if it's an access denied error + if (isAccessDeniedCause(e)) { + throw new ComponentDeletionException( + String.format("Access denied when deleting component '%s' from project '%s'", componentId, projectId), e); + } + // Generic deletion failure + throw new ComponentDeletionException( + String.format("Failed to delete component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e + ); + } + } + + private boolean isAccessDeniedCause(Throwable throwable) { + Throwable current = throwable; + while (current != null) { + if (current instanceof HttpClientErrorException.Unauthorized + || current instanceof HttpClientErrorException.Forbidden) { + return true; + } + current = current.getCause(); } + return false; } public boolean registerProjectComponent(String projectId, String componentId) { diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java index 8142c12d..3f69e779 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -107,4 +107,5 @@ void get_project_component_throws_not_found_when_component_does_not_exist() thro .hasMessage("Component '" + componentId + "' not found for project 'projectId'"); verify(componentsFacade).getProjectComponent(projectId, componentId); } + } \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java new file mode 100644 index 00000000..4554359c --- /dev/null +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsInternalControllerTest.java @@ -0,0 +1,64 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ComponentDeletionException; +import org.opendevstack.apiservice.project.facade.ComponentsFacade; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +class ProjectComponentsInternalControllerTest { + + @Mock + private ComponentsFacade componentsFacade; + + private ProjectComponentsInternalController projectComponentsInternalController; + + private AutoCloseable openMocks; + + @BeforeEach + void setup() { + openMocks = MockitoAnnotations.openMocks(this); + projectComponentsInternalController = new ProjectComponentsInternalController(componentsFacade); + } + + @AfterEach + void tearDown() throws Exception { + openMocks.close(); + } + + @Test + void delete_project_component_returns_no_content_when_component_delete_works() { + String projectId = "projectId"; + String componentId = "test-component-delete"; + + ResponseEntity response = projectComponentsInternalController.deleteProjectComponent(projectId, componentId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(response.getBody()).isNull(); + verify(componentsFacade).deleteProjectComponent(projectId, componentId); + } + + @Test + void delete_project_component_throws_exception_when_component_delete_api_throws_exception() { + String projectId = "projectId"; + String componentId = "test-component-delete"; + + doThrow(new ComponentDeletionException("test exception")) + .when(componentsFacade).deleteProjectComponent(anyString(), anyString()); + + assertThrows(ComponentDeletionException.class, () -> + projectComponentsInternalController.deleteProjectComponent(projectId, componentId) + ); + verify(componentsFacade).deleteProjectComponent(projectId, componentId); + } +} \ No newline at end of file diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index b8c77488..05074994 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -12,6 +12,7 @@ import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; +import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; @@ -21,11 +22,12 @@ import org.springframework.http.HttpStatus; import org.springframework.web.client.HttpClientErrorException; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCatalogItem; @@ -120,4 +122,23 @@ void create_project_component_throws_already_exists_when_marketplace_returns_con .hasMessage("This component name already exists, please choose another name."); verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } + + + @Test + void delete_project_component_ends_successfully_for_existing_component() throws MarketplaceException { + componentsFacade.deleteProjectComponent("testProject", "testComponent"); + + verify(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent"); + } + + @Test + void delete_project_component_throws_component_deletion_exception_when_marketplace_exception_is_thrown() throws MarketplaceException { + doThrow(new MarketplaceException("Test exception")) + .when(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent"); + + assertThatThrownBy(() -> componentsFacade.deleteProjectComponent("testProject", "testComponent")) + .isInstanceOf(ComponentDeletionException.class) + .hasMessageContaining("Test exception"); + verify(marketplaceExternalService).deleteProjectComponent("testProject", "testComponent"); + } } \ No newline at end of file diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml index 4de634aa..69e19aba 100644 --- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -244,7 +244,7 @@ paths: schema: $ref: '#/components/schemas/ProvisioningDeleteRequest' responses: - "200": + "204": description: Project component properly deleted. "400": description: Bad request. diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index bdc0f968..a8799c6c 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -186,7 +186,7 @@ generate - FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|createIncident|getProvisionerHealth + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProvisioningStatus|getProvisionerHealth java ${project.basedir} resttemplate diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java index 7796f686..62056745 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -37,6 +37,12 @@ public MarketplaceApiClient(String instanceName, MarketplaceInstanceConfig confi // Initialize the generated ApiClient this.apiClient = new ApiClient(restTemplate); + if (config.getUsername() != null && config.getPassword() != null) { + this.apiClient.setUsername(config.getUsername()); + this.apiClient.setPassword(config.getPassword()); + log.info("MarketplaceApiClient for instance '{}' uses basic authentication", instanceName); + } + log.info("MarketplaceApiClient initialized for instance '{}'", instanceName); } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java index 093825d8..3b6fed5c 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClientFactory.java @@ -34,7 +34,7 @@ public class MarketplaceApiClientFactory { * @param restTemplateBuilder RestTemplate builder for creating HTTP clients */ public MarketplaceApiClientFactory(MarketplaceServiceConfig configuration, - RestTemplateBuilder restTemplateBuilder) { + RestTemplateBuilder restTemplateBuilder) { this.configuration = configuration; this.restTemplateBuilder = restTemplateBuilder; @@ -70,7 +70,7 @@ public String getDefaultInstanceName() throws MarketplaceException { /** * Get a {@link MarketplaceApiClient} for a specific instance. - * If {@code instanceName} is {@code null} or blank, this method will throw a {@link MarketplaceException} + * If {@code instanceName} is {@code null} or blank, this method will throw a {@link MarketplaceException} * to avoid ambiguity. The caller should explicitly call {@link #getClient()} to get the default instance client * in that case. * @@ -212,4 +212,4 @@ protected void prepareConnection(HttpURLConnection connection, String httpMethod } }; } -} +} \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java index 7b170d20..9fb66a1c 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -14,6 +14,16 @@ public class MarketplaceInstanceConfig { */ private String provisionerActionsBaseUrl; + /** + * The username used for basic auth + */ + private String username; + + /** + * The password used for basic auth + */ + private String password; + /** * Connection timeout in milliseconds (default: 30000) */ diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index fc47b9b2..29ffdf45 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -33,9 +33,9 @@ public interface MarketplaceService extends ExternalService { boolean provisionProjectComponent(String instanceName, String projectId, List params) throws MarketplaceException; - boolean deleteProjectComponent(String projectId, String componentId) throws MarketplaceException; + void deleteProjectComponent(String projectId, String componentId) throws MarketplaceException; - boolean deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + void deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; void registerProjectComponent(String projectId, String componentId) throws MarketplaceException; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 1e58a502..de7e36ef 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -15,7 +15,6 @@ import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerActionsApi; import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerHealthApi; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CreateIncidentAction; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetCatalogHealth200Response; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetProvisionerHealth200Response; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.NotifyProvisioningStatusUpdateRequest; @@ -23,6 +22,7 @@ import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionAction; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisioningDeleteRequest; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -140,9 +140,9 @@ public boolean provisionProjectComponent(String projectId, List params) + List params) throws MarketplaceException { log.debug("Marketplace service PROVISION component for project {}: ", projectId); try { @@ -177,24 +177,24 @@ public boolean provisionProjectComponent(String instanceName, } @Override - public boolean deleteProjectComponent(String projectId, String componentId) throws MarketplaceException { - return deleteProjectComponent(getDefaultInstance(), projectId, componentId); + public void deleteProjectComponent(String projectId, String componentId) throws MarketplaceException { + deleteProjectComponent(getDefaultInstance(), projectId, componentId); } @Override - public boolean deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + public void deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { log.debug("Marketplace service DELETE component {} for project {}: ", componentId, projectId); try { - MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); ApiClient apiClient = marketplaceClient.getApiClient(); ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); log.debug("Api client base path: {}", apiClient.getBasePath()); - CreateIncidentAction deleteAction = new CreateIncidentAction(); - ProvisionActionResponse response = provisionResultsApi.createIncident(projectId, componentId, deleteAction); - return !Boolean.TRUE.equals(response.getFailed()); + ProvisioningDeleteRequest provisioningDeleteRequest = new ProvisioningDeleteRequest(); + provisioningDeleteRequest.setComponentId(componentId); + provisionResultsApi.deleteProvisioningStatus(projectId, provisioningDeleteRequest); } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { throw new MarketplaceException( String.format("Access denied when deleting project component '%s' in project '%s' and instance '%s'", diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 4f6749ac..03fb26e4 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -209,88 +209,88 @@ void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException // ------------------------------------------------------------------------- @Test - void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { - when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); + void testIsHealthy_NoInstancesConfigured_ReturnsFalse() { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of()); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { - @Override - protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { - return true; - } + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } - @Override - protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { - return true; - } - }; + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; - boolean result = service.isHealthy(); + boolean result = service.isHealthy(); - assertFalse(result); + assertFalse(result); } @Test - void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { - when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); - when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); - when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + void testIsHealthy_BothEndpointsUp_ReturnsTrue() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { - @Override - protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { - return true; - } + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } - @Override - protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { - return true; - } - }; + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } + }; - boolean result = service.isHealthy(); + boolean result = service.isHealthy(); - assertTrue(result); - } + assertTrue(result); + } - @Test - void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { - when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); - when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); - when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + @Test + void testIsHealthy_ProvisionerDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { - @Override - protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { - return false; - } - }; + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; - boolean result = service.isHealthy(); + boolean result = service.isHealthy(); - assertFalse(result); - } + assertFalse(result); + } - @Test - void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { - when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); - when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); - when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); + @Test + void testIsHealthy_CatalogDown_ReturnsFalse() throws MarketplaceException { + when(clientFactory.getAvailableInstances()).thenReturn(Set.of("dev")); + when(clientFactory.getDefaultInstanceName()).thenReturn("dev"); + when(clientFactory.getClient("dev")).thenReturn(marketplaceApiClient); - MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { - @Override - protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { - return true; - } + MarketplaceServiceImpl service = new MarketplaceServiceImpl(clientFactory, oboTokenService) { + @Override + protected boolean isProvisionerEndpointUp(MarketplaceApiClient marketplaceClient) { + return true; + } - @Override - protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { - return false; - } - }; + @Override + protected boolean isCatalogEndpointUp(MarketplaceApiClient marketplaceClient) { + return false; + } + }; - boolean result = service.isHealthy(); + boolean result = service.isHealthy(); - assertFalse(result); + assertFalse(result); } // ------------------------------------------------------------------------- @@ -447,11 +447,11 @@ void testProvisionProjectComponent_Conflict_ThrowsMarketplaceExceptionWithDuplic instanceConfig.setOboScope("api://test/scope"); HttpClientErrorException conflictEx = HttpClientErrorException.create( - HttpStatus.CONFLICT, - "Conflict", - HttpHeaders.EMPTY, - "{\"message\":\"This component name already exists, please choose another name.\"}".getBytes(StandardCharsets.UTF_8), - StandardCharsets.UTF_8 + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + "{\"message\":\"This component name already exists, please choose another name.\"}".getBytes(StandardCharsets.UTF_8), + StandardCharsets.UTF_8 ); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); @@ -460,7 +460,7 @@ void testProvisionProjectComponent_Conflict_ThrowsMarketplaceExceptionWithDuplic whenInvokeAPI(PATH_PROVISION_ACTIONS, HttpMethod.POST).thenThrow(conflictEx); MarketplaceException exception = assertThrows(MarketplaceException.class, () -> - marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); + marketplaceService.provisionProjectComponent(instanceName, projectKey, List.of())); assertEquals("This component name already exists, please choose another name.", exception.getMessage()); } @@ -471,7 +471,7 @@ void testGetCatalogItem_RestClientException() throws MarketplaceException { String instanceName = "dev"; String catalogItemId = "test-catalog-item-base64-string"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setOboScope("api://test/scope"); + instanceConfig.setOboScope("api://test/scope"); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); @@ -493,7 +493,7 @@ void testGetCatalogItem_NotFound_ReturnsNull() throws MarketplaceException { String instanceName = "dev"; String catalogItemId = "test-catalog-item-base64-string"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setOboScope("api://test/scope"); + instanceConfig.setOboScope("api://test/scope"); HttpClientErrorException notFoundEx = HttpClientErrorException.create( HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); @@ -516,7 +516,7 @@ void testGetCatalogItem_AuthError_ThrowsMarketplaceException() throws Marketplac String instanceName = "dev"; String catalogItemId = "test-catalog-item-base64-string"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setOboScope("api://test/scope"); + instanceConfig.setOboScope("api://test/scope"); HttpClientErrorException forbiddenEx = HttpClientErrorException.create( HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); @@ -537,7 +537,7 @@ void testGetCatalogItem_DefaultInstance() throws MarketplaceException { // Arrange String catalogItemId = "test-catalog-item-base64-string"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setOboScope("api://test/scope"); + instanceConfig.setOboScope("api://test/scope"); HttpClientErrorException notFoundEx = HttpClientErrorException.create( HttpStatus.NOT_FOUND, "Not Found", HttpHeaders.EMPTY, new byte[0], null); @@ -730,132 +730,71 @@ void testProvisionProjectComponent_OboScopeNotConfigured_ThrowsMarketplaceExcept // ------------------------------------------------------------------------- // deleteProjectComponent // ------------------------------------------------------------------------- - @Test - void testDeleteProjectComponent_Success_ReturnsTrue() throws MarketplaceException { + void testDeleteProjectComponent_RestClientException() throws MarketplaceException { // Arrange String instanceName = "dev"; - String projectKey = "PROJ"; - String componentId = "test-component"; + String componentId = "test-component-id"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - - ProvisionActionResponse mockResponse = new ProvisionActionResponse(); - mockResponse.setFailed(false); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) - .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(new RestClientException("Connection failed")); - // Act - boolean result = marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId); + // Act & Assert + assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, componentId)); - // Assert - assertTrue(result); verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); } @Test - void testDeleteProjectComponent_Failed_ReturnsFalse() throws MarketplaceException { + void testDeleteProjectComponent_Unauthorized_ThrowsException() throws MarketplaceException { // Arrange String instanceName = "dev"; - String projectKey = "PROJ"; - String componentId = "test-component"; + String componentId = "test-component-id"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - - ProvisionActionResponse mockResponse = new ProvisionActionResponse(); - mockResponse.setFailed(true); + HttpClientErrorException unauthorizedException = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenThrow(unauthorizedException); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) - .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); // Act - boolean result = marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId); + assertThrows(MarketplaceException.class, () -> + marketplaceService.deleteProjectComponent(instanceName, componentId)); // Assert - assertFalse(result); - } - - @Test - void testDeleteProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { - // Arrange - String instanceName = "dev"; - String projectKey = "PROJ"; - String componentId = "test-component"; - MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - - when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); - when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); - when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) - .thenThrow(new RestClientException("Connection refused")); - - // Act & Assert - MarketplaceException exception = assertThrows(MarketplaceException.class, () -> - marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId)); - - assertTrue(exception.getMessage().contains("Failed to delete")); + verify(clientFactory).getClient(instanceName); } @Test - void testDeleteProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + void testDeleteProjectComponent_ComponentExists_NoExceptionThrown() throws MarketplaceException { // Arrange String instanceName = "dev"; - String projectKey = "PROJ"; - String componentId = "test-component"; + String componentId = "test-component-id"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - HttpClientErrorException forbiddenEx = HttpClientErrorException.create( - HttpStatus.FORBIDDEN, "Forbidden", HttpHeaders.EMPTY, new byte[0], null); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); + when(apiClient.invokeAPI(anyString(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any(), any())) + .thenReturn(ResponseEntity.status(HttpStatus.NO_CONTENT).build()); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST).thenThrow(forbiddenEx); - - // Act & Assert - MarketplaceException exception = assertThrows(MarketplaceException.class, () -> - marketplaceService.deleteProjectComponent(instanceName, projectKey, componentId)); - - assertTrue(exception.getMessage().contains("Access denied")); - } - - @Test - void testDeleteProjectComponent_DefaultInstance() throws MarketplaceException { - // Arrange - String projectKey = "PROJ"; - String componentId = "test-component"; - MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - - ProvisionActionResponse mockResponse = new ProvisionActionResponse(); - mockResponse.setFailed(false); - - when(clientFactory.getDefaultInstanceName()).thenReturn("default"); - when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); - when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); - when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_DELETE_COMPONENT, HttpMethod.POST) - .thenReturn(new ResponseEntity<>(mockResponse, HttpStatus.OK)); // Act - boolean result = marketplaceService.deleteProjectComponent(projectKey, componentId); + marketplaceService.deleteProjectComponent(instanceName, componentId); // Assert - assertTrue(result); - verify(clientFactory).getClient("default"); + verify(clientFactory).getClient(instanceName); } // ------------------------------------------------------------------------- From 463b5ca039b358b11d0dcbc54340e2dd7b5becc1 Mon Sep 17 00:00:00 2001 From: Vali Tuguran Date: Thu, 14 May 2026 11:03:02 +0300 Subject: [PATCH 16/27] Encode password value as base64 (#25) Fix special chars in the password by base64 encoding. --------- Co-authored-by: zxBCN Valeriu_Tuguran,Constantin (IT EDP) EXTERNAL --- .../core/security/util/Base64Operations.java | 40 +++++++++++++++ .../security/util/Base64OperationsTest.java | 51 +++++++++++++++++++ .../client/MarketplaceApiClient.java | 3 +- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java new file mode 100644 index 00000000..41e4a627 --- /dev/null +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/util/Base64Operations.java @@ -0,0 +1,40 @@ +package org.opendevstack.apiservice.core.security.util; + +import java.util.Base64; + +public class Base64Operations { + + private Base64Operations() {} + + /** + * Encodes a string value using Base64 encoding. + * + * @param value the string value to encode + * @return the Base64 encoded string, or null if input is null + */ + public static String encode(String value) { + if (value == null) { + return null; + } + return Base64.getEncoder().encodeToString(value.getBytes()); + } + + /** + * Decodes a Base64 encoded string value. + * + * @param encodedValue the Base64 encoded string to decode + * @return the decoded string, or null if input is null + * @throws IllegalArgumentException if the input is not valid Base64 + */ + public static String decode(String encodedValue) { + if (encodedValue == null) { + return null; + } + try { + byte[] decodedBytes = Base64.getDecoder().decode(encodedValue); + return new String(decodedBytes); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid Base64 encoded value: " + encodedValue, e); + } + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java new file mode 100644 index 00000000..5b08a048 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/util/Base64OperationsTest.java @@ -0,0 +1,51 @@ +package org.opendevstack.apiservice.core.security.util; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Base64OperationsTest { + + @Test + void tectEncodeWhenNullInputReturnNull() { + String testString = null; + + String encodedString = Base64Operations.encode(testString); + + assertEquals(null, encodedString); + } + + @Test + void tectEncodeWhenCorrectInputReturnEncodedResult() { + String testString = "test;string"; + + String encodedString = Base64Operations.encode(testString); + + assertEquals("dGVzdDtzdHJpbmc=", encodedString); + } + + @Test + void testDecodeWhenNullInputReturnNull() { + String testString = null; + + String dencodedString = Base64Operations.decode(testString); + + assertEquals(null, dencodedString); + } + + @Test + void testDecodeWhenCorrectInputReturnDecodedResult() { + String testString = "dGVzdDtzdHJpbmc="; + + String dencodedString = Base64Operations.decode(testString); + + assertEquals("test;string", dencodedString); + } + + @Test + void testDecodeWhenBadInputThrowException() { + String testString = "NOT AN ENCODED STRING"; + + assertThrows(IllegalArgumentException.class, () -> Base64Operations.decode(testString)); + } +} \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java index 62056745..53d2f040 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.openapitools.jackson.nullable.JsonNullableModule; +import org.opendevstack.apiservice.core.security.util.Base64Operations; import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; import org.opendevstack.apiservice.externalservice.marketplace.openapi.auth.HttpBearerAuth; @@ -39,7 +40,7 @@ public MarketplaceApiClient(String instanceName, MarketplaceInstanceConfig confi if (config.getUsername() != null && config.getPassword() != null) { this.apiClient.setUsername(config.getUsername()); - this.apiClient.setPassword(config.getPassword()); + this.apiClient.setPassword(Base64Operations.decode(config.getPassword())); log.info("MarketplaceApiClient for instance '{}' uses basic authentication", instanceName); } From 83d44934c2f791289c85a3c436d80d5f86ca2736 Mon Sep 17 00:00:00 2001 From: Jorge Romero Date: Fri, 15 May 2026 14:45:36 +0200 Subject: [PATCH 17/27] Add missing API imports to database and refactor certificate installation script (#28) --- Makefile | 1 + docker/install-certs.sh | 6 +++-- .../migrations/005_add_component_apis.sql | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql diff --git a/Makefile b/Makefile index ff7c92a4..ceea7c8b 100644 --- a/Makefile +++ b/Makefile @@ -406,6 +406,7 @@ db-port-forward: @echo "$(YELLOW)Press Ctrl+C to stop the tunnel$(NC)" kubectl port-forward \ --namespace $(NAMESPACE) \ + --address 0.0.0.0 \ service/$(DB_K8S_SERVICE) \ $(DB_PF_LOCAL_PORT):$(DB_PF_REMOTE_PORT) diff --git a/docker/install-certs.sh b/docker/install-certs.sh index bfd62c3e..bbd3f329 100755 --- a/docker/install-certs.sh +++ b/docker/install-certs.sh @@ -19,10 +19,12 @@ fi CERT_DIR=$(mktemp -d) echo "Created temporary directory: $CERT_DIR" -echo "Creating new custom truststore from scratch at: $CUSTOM_TRUSTSTORE" +echo "Creating custom truststore based on Java cacerts at: $CUSTOM_TRUSTSTORE" -# Remove existing truststore if it exists +# Copy Java cacerts as the base truststore rm -f "$CUSTOM_TRUSTSTORE" +cp "$JAVA_HOME/lib/security/cacerts" "$CUSTOM_TRUSTSTORE" +chmod 600 "$CUSTOM_TRUSTSTORE" # Split CERT_URLS by comma and process each URL IFS=',' read -ra URLS <<< "$CERT_URLS" diff --git a/persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql b/persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql new file mode 100644 index 00000000..825794fc --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/005_add_component_apis.sql @@ -0,0 +1,26 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:007-update-platform-api-visibility +-- Description: Set project-platforms-v1 API as public. +-- ============================================================================ +UPDATE api_definitions +SET is_public = TRUE +WHERE api_id = 'project-platforms-v1'; + +--rollback UPDATE api_definitions SET is_public = FALSE WHERE api_id = 'project-platforms-v1'; + + +-- ============================================================================ +-- changeset ods:008-seed-component-api-definitions +-- Description: Seed API definitions for Project Components APIs v0 and v1. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-component-v1', 'Project Components API v1', 'projects/*/components', 'v1', ARRAY['CLIENT_CREDENTIALS', 'OBO'], FALSE, TRUE), + ('project-component-v0', 'Project Components API v0', 'projects/*/components', 'v0', ARRAY['CLIENT_CREDENTIALS', 'OBO'], FALSE, TRUE) +ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-component-v1', 'project-component-v0'); + + From d91c12a6ccaa7db3f6373e2b074e924fdb1da98f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Mart=C3=ADn?= Date: Tue, 19 May 2026 12:22:04 +0200 Subject: [PATCH 18/27] Feature/register component with registration flag (#29) Added possibility to register components by raising the registerOnly flag in the create component endpoint. This workflow calls to the Marketplace API 2.0 to register the component correctly. By default this flag is set to false, so the default component creation workflow is performed if this flag is not informed or set to false. --------- Co-authored-by: zxBCN Valeriu_Tuguran,Constantin (IT EDP) EXTERNAL Co-authored-by: Angel Martinez Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../ProjectComponentsController.java | 10 +- .../ProjectComponentsExceptionHandler.java | 17 ++ .../ComponentRegistrationException.java | 12 + .../project/facade/ComponentsFacade.java | 24 +- .../project/mapper/MarketplaceMapper.java | 41 +++- .../ProjectComponentsControllerTest.java | 51 ++++ ...ProjectComponentsExceptionHandlerTest.java | 15 ++ .../project/facade/ComponentsFacadeTest.java | 56 ++++- .../project/mapper/MarketplaceMapperTest.java | 97 +++++++- .../project/util/TestObjectsBuilder.java | 4 +- application.yaml | 3 + .../openapi-component_provisioner-v1.0.0.yaml | 220 ++++++++++++++++-- external-service-marketplace/pom.xml | 18 +- .../client/MarketplaceApiClient.java | 4 +- .../config/MarketplaceInstanceConfig.java | 5 + .../service/CatalogItemOperations.java | 2 +- .../service/MarketplaceService.java | 11 +- .../service/impl/MarketplaceServiceImpl.java | 60 +++-- .../service/CatalogItemOperationsTest.java | 2 +- .../service/MarketplaceServiceImplTest.java | 97 +++++--- 20 files changed, 619 insertions(+), 130 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java index a78afeb6..2f2e9c8d 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/ProjectComponentsController.java @@ -28,9 +28,15 @@ public class ProjectComponentsController implements ProjectComponentsApi { @Override public ResponseEntity createProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { - componentsFacade.provisionProjectComponent(projectId, createComponentRequest); - log.info("Created component '{}' for project '{}'", createComponentRequest.getName(), projectId); + if (Boolean.TRUE.equals(createComponentRequest.getRegisterOnly())) { + componentsFacade.registerProjectComponent(projectId, createComponentRequest); + log.info("Registered component '{}' for project '{}'", createComponentRequest.getName(), projectId); + } else { + componentsFacade.provisionProjectComponent(projectId, createComponentRequest); + log.info("Created component '{}' for project '{}'", createComponentRequest.getName(), projectId); + } + return componentResponseMapper.toResponseEntity( ComponentsResponseFactory.entityCreated(projectId, createComponentRequest.getName()) ); diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java index 5af85796..b00f2300 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -10,6 +10,7 @@ import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.http.HttpStatus; @@ -142,6 +143,22 @@ public ResponseEntity handleComponentCreationException( return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + @ExceptionHandler(ComponentRegistrationException.class) + public ResponseEntity handleComponentRegisterException( + ComponentRegistrationException ex, + HttpServletRequest request) { + + log.error("Component registration failed: {}", ex.getMessage(), ex); + + CreateComponentResponse response = ComponentsResponseFactory.internalError( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INTERNAL_ERROR + ); + + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + @ExceptionHandler(ComponentDeletionException.class) public ResponseEntity handleComponentDeletionException( ComponentDeletionException ex, diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java new file mode 100644 index 00000000..8ae82451 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentRegistrationException.java @@ -0,0 +1,12 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentRegistrationException extends RuntimeException { + + public ComponentRegistrationException(String message) { + super(message); + } + + public ComponentRegistrationException(String message, Exception e) { + super(message, e); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 56cc6c38..01266c36 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -3,9 +3,10 @@ import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.externalservice.marketplace.service.CatalogItemOperations; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.opendevstack.apiservice.project.exception.CatalogItemNotFoundException; @@ -14,6 +15,7 @@ import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; @@ -175,13 +177,19 @@ private boolean isAccessDeniedCause(Throwable throwable) { return false; } - public boolean registerProjectComponent(String projectId, String componentId) { + public void registerProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + String componentId = createComponentRequest.getName(); + String productId = createComponentRequest.getProductId(); try { - marketplaceExternalService.registerProjectComponent(projectId, componentId); - return true; + List parameters = marketplaceMapper + .mapCreateComponentRequestToRegisterComponentParameterList(createComponentRequest); + marketplaceExternalService.registerProjectComponent(projectId, componentId, productId, parameters); + log.info("Successfully registered component '{}' for project '{}'", componentId, projectId); } catch (MarketplaceException e) { - log.error("Failed to register component in marketplace for project with id {}", projectId, e); - return false; + log.error("Failed to register component '{}' for project '{}': {}", componentId, projectId, e.getMessage(), e); + throw new ComponentRegistrationException( + String.format("Failed to register component '%s' for project '%s': %s", componentId, projectId, e.getMessage()), e + ); } } } diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java index 2a0eec4b..19887f06 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -2,11 +2,12 @@ import org.mapstruct.Mapper; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserAction; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserActionParameter; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import org.opendevstack.apiservice.project.model.EnvironmentsDTO; @@ -67,10 +68,40 @@ default List mapCreateComponentRequestToCreateComponen return parameters; } + default List mapCreateComponentRequestToRegisterComponentParameterList( + CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null) { + return List.of(); + } + + List parameters = new ArrayList<>(); + if (createComponentRequest.getParams() != null && !createComponentRequest.getParams().isEmpty()) { + createComponentRequest.getParams().forEach((name, value) -> { + parameters.add(createRegisterParameter(name, value)); + }); + } + + return parameters; + } + default ProvisionActionParameter createParameter(String name, Object value, String type) { return new ProvisionActionParameter().name(name).type(type).value(value); } + default ProvisioningStatusUpdateRequestAllOfParameters createRegisterParameter(String name, Object value) { + List values; + if (value == null) { + values = List.of(""); + } else if (value instanceof List list) { + values = list.stream() + .map(Object::toString) + .toList(); + } else { + values = List.of(value.toString()); + } + return new ProvisioningStatusUpdateRequestAllOfParameters().name(name).values(values); + } + /** * Builds a map of parameter name -> type from the catalog item user actions. * The type comes from the {@link CatalogItemUserActionParameter#getType()} value diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java index 3f69e779..9c5b200f 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/ProjectComponentsControllerTest.java @@ -66,6 +66,25 @@ void create_project_component_returns_ok_with_component_name_in_path() { verify(componentsFacade).provisionProjectComponent(projectId, request); } + @Test + void create_project_component_with_register_only_returns_ok_with_component_name_in_path() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); // name = "testcomponent" + request.setRegisterOnly(Boolean.TRUE); + + doNothing().when(componentsFacade).registerProjectComponent(eq(projectId), eq(request)); + + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.OK.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("000"); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).registerProjectComponent(projectId, request); + } + @Test void create_project_component_propagates_exception_when_facade_throws_exception() { String projectId = "testProjectId"; @@ -80,6 +99,38 @@ void create_project_component_propagates_exception_when_facade_throws_exception( verify(componentsFacade).provisionProjectComponent(projectId, request); } + @Test + void create_project_component_with_register_only_propagates_exception_when_facade_throws_exception() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + request.setRegisterOnly(Boolean.TRUE); + + org.mockito.Mockito.doThrow(new RuntimeException("boom")) + .when(componentsFacade).registerProjectComponent(eq(projectId), eq(request)); + + assertThatThrownBy(() -> projectComponentsController.createProjectComponent(projectId, request)) + .isInstanceOf(RuntimeException.class) + .hasMessage("boom"); + verify(componentsFacade).registerProjectComponent(projectId, request); + } + + @Test + void create_project_component_with_register_only_false_delegates_to_provision_project_component() { + String projectId = "testProjectId"; + CreateComponentRequest request = buildTestCreateComponentRequest(); + request.setRegisterOnly(Boolean.FALSE); + + doNothing().when(componentsFacade).provisionProjectComponent(eq(projectId), any(CreateComponentRequest.class)); + + ResponseEntity response = projectComponentsController.createProjectComponent(projectId, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getMessage()).isEqualTo("Component created"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/testProjectId/components/testcomponent"); + verify(componentsFacade).provisionProjectComponent(projectId, request); + } + @Test void get_project_component_returns_ok_when_component_exists() throws MarketplaceException { String projectId = "projectId"; diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java index 92a0601e..818fe30c 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; @@ -149,6 +150,20 @@ void handle_component_already_exists_exception_returns_conflict() { assertThat(response.getBody().getMessage()).isEqualTo("This component name already exists, please choose another name."); } + @Test + void handle_component_registration_exception_returns_internal_server_error() { + ComponentRegistrationException exception = new ComponentRegistrationException("Registration failed"); + + ResponseEntity response = handler.handleComponentRegisterException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("003"); + assertThat(response.getBody().getMessage()).isEqualTo("Registration failed"); + assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); + } + @Test void handle_generic_exception_returns_internal_server_error() { RuntimeException exception = new RuntimeException("boom"); diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 05074994..370d7845 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -7,13 +7,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; @@ -28,6 +29,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.opendevstack.apiservice.project.util.TestObjectsBuilder.buildTestCatalogItem; @@ -103,8 +105,8 @@ void create_project_component_throws_creation_exception_when_marketplace_returns verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } - @Test - void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { + @Test + void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); HttpClientErrorException conflict = HttpClientErrorException.Conflict.create( HttpStatus.CONFLICT, @@ -121,8 +123,52 @@ void create_project_component_throws_already_exists_when_marketplace_returns_con .isInstanceOf(ComponentAlreadyExistsException.class) .hasMessage("This component name already exists, please choose another name."); verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); - } + } + + @Test + void register_project_component_ends_successfully_when_marketplace_registration_succeeds() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + String projectId = "testProjectId"; + + doNothing().when(marketplaceExternalService).registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + + componentsFacade.registerProjectComponent(projectId, request); + + verify(marketplaceExternalService).registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + } + + @Test + void register_project_component_passes_correct_component_id_and_product_id_to_marketplace() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + // name = "testcomponent", productId = "testProductId" + String projectId = "testProjectId"; + + doNothing().when(marketplaceExternalService).registerProjectComponent( + eq(projectId), eq("testcomponent"), eq("testProductId"), anyList()); + + componentsFacade.registerProjectComponent(projectId, request); + verify(marketplaceExternalService).registerProjectComponent( + eq(projectId), eq("testcomponent"), eq("testProductId"), anyList()); + } + + @Test + void register_project_component_throws_registration_exception_wrapping_original_cause() throws MarketplaceException { + CreateComponentRequest request = buildTestCreateComponentRequest(); + String projectId = "testProjectId"; + MarketplaceException originalCause = new MarketplaceException("downstream error", new RuntimeException("root")); + + doThrow(originalCause) + .when(marketplaceExternalService) + .registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + + assertThatThrownBy(() -> componentsFacade.registerProjectComponent(projectId, request)) + .isInstanceOf(ComponentRegistrationException.class) + .hasMessage("Failed to register component 'testcomponent' for project 'testProjectId': downstream error") + .hasCause(originalCause); + + verify(marketplaceExternalService).registerProjectComponent(eq(projectId), anyString(), anyString(), anyList()); + } @Test void delete_project_component_ends_successfully_for_existing_component() throws MarketplaceException { diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java index 6495d055..1adf2f30 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapperTest.java @@ -2,10 +2,11 @@ import org.junit.jupiter.api.Test; import org.mapstruct.factory.Mappers; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserAction; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItemUserActionParameter; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserAction; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.project.model.CreateComponentRequest; import java.util.List; @@ -87,6 +88,94 @@ void mapCreateComponentRequestToCreateComponentParameterList_returnsEmptyWhenReq assertThat(mapper.mapCreateComponentRequestToCreateComponentParameterList(null, null)).isEmpty(); } + // ------------------------------------------------------------------------- + // mapCreateComponentRequestToRegisterComponentParameterList + // ------------------------------------------------------------------------- + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_string_values_correctly() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("my_param", "my_value")); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(1); + ProvisioningStatusUpdateRequestAllOfParameters param = + result.stream().filter(p -> "my_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(param.getValues()).containsExactly("my_value"); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_list_values_correctly() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + List listValue = List.of("value1", "value2", "value3"); + request.setParams(Map.of("list_param", listValue)); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(1); + ProvisioningStatusUpdateRequestAllOfParameters param = + result.stream().filter(p -> "list_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(param.getValues()).containsExactlyInAnyOrder("value1", "value2", "value3"); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_non_string_value_to_string() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(Map.of("bool_param", true, "int_param", 42)); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(2); + ProvisioningStatusUpdateRequestAllOfParameters boolParam = + result.stream().filter(p -> "bool_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(boolParam.getValues()).containsExactly("true"); + + ProvisioningStatusUpdateRequestAllOfParameters intParam = + result.stream().filter(p -> "int_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(intParam.getValues()).containsExactly("42"); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_maps_null_value_to_empty_string_list() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + java.util.Map params = new java.util.HashMap<>(); + params.put("null_param", null); + request.setParams(params); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).hasSize(1); + ProvisioningStatusUpdateRequestAllOfParameters param = + result.stream().filter(p -> "null_param".equals(p.getName())).findFirst().orElseThrow(); + assertThat(param.getValues()).containsExactly(""); + } + + @Test + void mapCreateComponentRequestToRegisterComponentParameterList_returns_empty_list_when_params_null() { + CreateComponentRequest request = new CreateComponentRequest(); + request.setName("test-component"); + request.setProductId("some-slug"); + request.setParams(null); + + List result = + mapper.mapCreateComponentRequestToRegisterComponentParameterList(request); + + assertThat(result).isEmpty(); + } + private static CatalogItemUserAction buildProvisionAction(CatalogItemUserActionParameter... params) { CatalogItemUserAction action = new CatalogItemUserAction(); action.setId("PROVISION"); diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java index a9c059f8..b40d9087 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -1,7 +1,7 @@ package org.opendevstack.apiservice.project.util; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; diff --git a/application.yaml b/application.yaml index deb70580..a334b88d 100644 --- a/application.yaml +++ b/application.yaml @@ -302,5 +302,8 @@ externalservices: default: project-components-base-url: ${MARKETPLACE_PROJECT_COMPONENTS_BASE_URL} provisioner-actions-base-url: ${MARKETPLACE_PROVISIONER_ACTIONS_BASE_URL:} + bitbucket-base-url: ${MARKETPLACE_BITBUCKET_BASE_URL:} obo-scope: ${MARKETPLACE_OBO_SCOPE:} trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} + username: ${MARKETPLACE_USERNAME:} + password: ${MARKETPLACE_PASSWORD:} diff --git a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml index 69e19aba..2cf2681e 100644 --- a/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml +++ b/external-service-marketplace/openapi/openapi-component_provisioner-v1.0.0.yaml @@ -164,11 +164,13 @@ paths: /provision/{projectKey}/{status}: put: + security: + - basicAuth: [ ] # Enable ONLY basicAuth tags: - ProvisionResults summary: Notify provisioning Status Update description: > - This endpoint receives provisioning status update notifications from AWX. + This endpoint receives provisioning updates. operationId: notifyProvisioningStatusUpdate parameters: - name: projectKey @@ -190,24 +192,46 @@ paths: content: application/json: schema: - type: object - properties: - componentId: - type: string - description: The componentId set by the user. - example: "any-component-id-from-backend" - catalogItemId: - type: string - description: The base64 encoded path for the catalogItem. Mind that it may include branch reference. - example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" - catalogItemSlug: - type: string - description: The slug for the provisioned component. - example: "myproject_repo_name" - componentUrl: - type: string - description: The bitbucket repository url for the provisioned component. - example: "https://bitbucket.com/projects/myproject/repos/repo_name" + $ref: '#/components/schemas/ProvisioningStatusUpdateRequest' + responses: + "200": + description: Provisioning completion notified. + "400": + description: Bad request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + "500": + description: Server error. + patch: + tags: + - ProvisionResults + summary: Notify provisioning Status Update + description: > + This endpoint receives provisioning status update notifications from AAP. + operationId: notifyProvisioningStatusUpdatePartially + parameters: + - name: projectKey + in: path + description: Project key of the provisioned component. + required: true + schema: + type: string + - name: status + in: path + description: Project key of the provisioned component. + required: true + example: CREATING + schema: + type: string + enum: [ CREATING, CREATED, FAILED, DELETING, UNKNOWN ] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProvisioningStatusPartialUpdateRequest' responses: "200": description: Provisioning completion notified. @@ -226,10 +250,10 @@ paths: - basicAuth: [ ] # Enable ONLY basicAuth tags: - ProvisionResults - summary: Delete provision status component from the file + summary: Delete project component from the file description: > - This endpoint receives provisioning status delete notifications from Component Provisioner. - operationId: deleteProvisioningStatus + This endpoint receives provisioning status delete notifications from Project Component. + operationId: deleteProjectComponent parameters: - name: projectKey in: path @@ -255,14 +279,14 @@ paths: "500": description: Server error. - /support/delete/{projectKey}/{componentId}: + /provision/delete/{projectKey}/{componentId}: post: tags: - ProvisionResults summary: Request App Support to do operations to delete provision status component (and dependencies) from the file description: > This endpoint receives project key and componentId and send an create an incident to app support. - operationId: createIncident + operationId: requestDeletion parameters: - name: projectKey in: path @@ -297,6 +321,51 @@ paths: description: Insufficient permissions for the client to access the resource. "500": description: Server error. + + /project/{projectKey}/component/{componentId}: + get: + tags: + - Project-components-with-provision-status + summary: Returns the provision status of a project component given both its project key and component ID. + operationId: getProjectComponentProvisionStatusById + parameters: + - name: projectKey + in: path + description: project key. + required: true + schema: + type: string + - name: componentId + in: path + description: component ID. + required: true + schema: + type: string + responses: + "200": + description: The provision status information of a project component, including fail reason if exists. + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectComponentProvisionStatus' + "401": + description: Invalid client token on the request. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "403": + description: Insufficient permissions for the client to access the resource. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' + "500": + description: Server error. + content: + application/json: + schema: + $ref: '#/components/schemas/RestErrorMessage' components: securitySchemes: bearerAuth: @@ -415,6 +484,61 @@ components: required: - message + ProvisioningStatusPartialUpdateRequest: + type: object + properties: + componentId: + type: string + minLength: 1 # disallows empty string "" + pattern: '^(?!\s*$).+' # reject whitespace-only + description: The componentId set by the user. + example: "any-component-id-from-backend" + catalogItemId: + type: string + description: The base64 encoded path for the catalogItem. It may include branch reference. + example: "cHJvamVjdHMvQ0FURVNUL3JlcG9zL3VzZXItYWN0aW9ucy1pdGVtL3Jhdy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy" + catalogItemSlug: + type: string + description: The slug for the provisioned component. + example: "myproject_repo_name" + componentUrl: + type: string + description: the repository url where the component was provisioned + example: "https://bitbucket.com/projects/DEVSTACK/repos/devstack-component-catalog" + nullable: true + + ProvisioningStatusUpdateRequest: + allOf: + - $ref: '#/components/schemas/ProvisioningStatusPartialUpdateRequest' + - type: object + properties: + workflowJobId: + type: string + description: the workflow job id from AWX to correlate provisioning status with AWX job status updates + example: "123456" + nullable: true + parameters: + type: array + description: List of name/value string parameters. + items: + type: object + required: + - name + - values + properties: + name: + type: string + description: Parameter name + example: "environment" + values: + type: array + description: Parameter values + items: + type: string + example: + - "production" + - "staging" + ProvisioningDeleteRequest: type: object properties: @@ -440,4 +564,50 @@ components: example: name: "workflow" type: "string" - value: "2558" \ No newline at end of file + value: "2558" + + ProjectComponentStatusParameter: + properties: + name: + type: string + example: 'environment' + values: + type: array + items: + type: string + example: + - 'dev' + - 'test' + ProjectComponentProvisionStatus: + properties: + projectKey: + type: string + example: 'simple-project-sample' + componentId: + type: string + example: 'nextjs-basic-app' + catalogItemId: + type: string + example: 'some-encoded-info' + catalogItemRef: + type: string + example: 'more-encoded-info' + status: + type: string + example: 'CREATING' + componentUrl: + type: string + example: 'https://bitbucket.com/projects/CATALOGS/repos/project-components/browse/projects' + workflowJobId: + type: string + example: '1316315' + errorTask: + type: string + example: '08-01-fail if validations or checks did not pass' + errorMessage: + type: string + example: 'JIRA_ERROR' + parameters: + type: array + items: + $ref: '#/components/schemas/ProjectComponentStatusParameter' \ No newline at end of file diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index a8799c6c..061eceb5 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -152,12 +152,12 @@ FILTER=operationId:getProjectComponents|getProjectComponentById|getCatalogItemById|getCatalogItemBySlug|getCatalogHealth java - ${project.basedir} + ${project.basedir}/target/generated-sources/openapi resttemplate ${project.basedir}/openapi/openapi-component_catalog-v1.0.0.yaml - org.opendevstack.apiservice.externalservice.marketplace.openapi.api - org.opendevstack.apiservice.externalservice.marketplace.openapi.model - org.opendevstack.apiservice.externalservice.marketplace.openapi + org.opendevstack.apiservice.externalservice.marketplace.client.api + org.opendevstack.apiservice.externalservice.marketplace.client.model + org.opendevstack.apiservice.externalservice.marketplace.client false true true @@ -186,14 +186,14 @@ generate - FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProvisioningStatus|getProvisionerHealth + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProvisionerHealth java - ${project.basedir} + ${project.basedir}/target/generated-sources/openapi resttemplate ${project.basedir}/openapi/openapi-component_provisioner-v1.0.0.yaml - org.opendevstack.apiservice.externalservice.marketplace.openapi.api - org.opendevstack.apiservice.externalservice.marketplace.openapi.model - org.opendevstack.apiservice.externalservice.marketplace.openapi + org.opendevstack.apiservice.externalservice.marketplace.client.api + org.opendevstack.apiservice.externalservice.marketplace.client.model + org.opendevstack.apiservice.externalservice.marketplace.client false true true diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java index 53d2f040..6443f560 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/client/MarketplaceApiClient.java @@ -6,8 +6,8 @@ import org.openapitools.jackson.nullable.JsonNullableModule; import org.opendevstack.apiservice.core.security.util.Base64Operations; import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.auth.HttpBearerAuth; +import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.auth.HttpBearerAuth; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.web.client.RestTemplate; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java index 9fb66a1c..c0645b03 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -14,6 +14,11 @@ public class MarketplaceInstanceConfig { */ private String provisionerActionsBaseUrl; + /** + * The Bitbucket base URL of the Marketplace project + */ + private String bitbucketBaseUrl; + /** * The username used for basic auth */ diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java index 14919d4f..f962ed8c 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java @@ -1,6 +1,6 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; import java.nio.charset.StandardCharsets; import java.util.Base64; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index 29ffdf45..30155e8c 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -2,9 +2,10 @@ import org.opendevstack.apiservice.externalservice.api.ExternalService; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import java.util.List; import java.util.Set; @@ -37,8 +38,8 @@ public interface MarketplaceService extends ExternalService { void deleteProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; - void registerProjectComponent(String projectId, String componentId) throws MarketplaceException; + void registerProjectComponent(String projectId, String componentId, String catalogItemSlug, List params) throws MarketplaceException; - void registerProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + void registerProjectComponent(String instanceName, String projectId, String componentId, String catalogItemSlug, List params) throws MarketplaceException; } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index de7e36ef..c72bcead 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -7,22 +7,23 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.CatalogHealthApi; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.CatalogItemsApi; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProjectComponentsApi; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionResultsApi; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerActionsApi; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.api.ProvisionerHealthApi; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetCatalogHealth200Response; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.GetProvisionerHealth200Response; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.NotifyProvisioningStatusUpdateRequest; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionAction; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionParameter; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisioningDeleteRequest; +import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.CatalogHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.CatalogItemsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProjectComponentsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionResultsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerActionsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerHealthApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetCatalogHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetProvisionerHealth200Response; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionAction; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningDeleteRequest; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequest; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -194,7 +195,7 @@ public void deleteProjectComponent(String instanceName, String projectId, String ProvisioningDeleteRequest provisioningDeleteRequest = new ProvisioningDeleteRequest(); provisioningDeleteRequest.setComponentId(componentId); - provisionResultsApi.deleteProvisioningStatus(projectId, provisioningDeleteRequest); + provisionResultsApi.deleteProjectComponent(projectId, provisioningDeleteRequest); } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { throw new MarketplaceException( String.format("Access denied when deleting project component '%s' in project '%s' and instance '%s'", @@ -207,23 +208,36 @@ public void deleteProjectComponent(String instanceName, String projectId, String } @Override - public void registerProjectComponent(String projectId, String componentId) throws MarketplaceException { - registerProjectComponent(getDefaultInstance(), projectId, componentId); + public void registerProjectComponent(String projectId, + String componentId, + String catalogItemSlug, + List params) + throws MarketplaceException { + registerProjectComponent(getDefaultInstance(), projectId, componentId, catalogItemSlug, params); } @Override - public void registerProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + public void registerProjectComponent(String instanceName, + String projectId, + String componentId, + String catalogItemSlug, + List params) + throws MarketplaceException { log.debug("Marketplace service REGISTER component {} for project {}: ", componentId, projectId); try { - MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); + MarketplaceApiClient marketplaceClient = clientFactory.getClient(instanceName); ApiClient apiClient = marketplaceClient.getApiClient(); ProvisionResultsApi provisionResultsApi = new ProvisionResultsApi(apiClient); apiClient.setBasePath(marketplaceClient.getConfig().getProvisionerActionsBaseUrl()); log.debug("Api client base path: {}", apiClient.getBasePath()); - NotifyProvisioningStatusUpdateRequest registerRequest = new NotifyProvisioningStatusUpdateRequest(); - registerRequest.setComponentId(componentId); + ProvisioningStatusUpdateRequest registerRequest = new ProvisioningStatusUpdateRequest() + .componentId(componentId) + .catalogItemSlug(catalogItemSlug) + .componentUrl(String.format("%s/projects/%s/repos/%s/browse", + marketplaceClient.getConfig().getBitbucketBaseUrl(), projectId, componentId)) + .parameters(params); provisionResultsApi.notifyProvisioningStatusUpdate(projectId, "CREATED", registerRequest); } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { throw new MarketplaceException( diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java index 74bce9f2..c019dbff 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java @@ -1,7 +1,7 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 03fb26e4..6ac97829 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -4,18 +4,20 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.stubbing.OngoingStubbing; import org.opendevstack.apiservice.core.security.obo.OboTokenService; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.MarketplaceApiClientFactory; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequest; import org.opendevstack.apiservice.externalservice.marketplace.config.MarketplaceInstanceConfig; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.ApiClient; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProjectComponentExtendedInfo; -import org.opendevstack.apiservice.externalservice.marketplace.openapi.model.ProvisionActionResponse; +import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; @@ -802,94 +804,113 @@ void testDeleteProjectComponent_ComponentExists_NoExceptionThrown() throws Marke // ------------------------------------------------------------------------- @Test - void testRegisterProjectComponent_Success() throws MarketplaceException { + void testRegisterProjectComponent_RestClientException() throws MarketplaceException { // Arrange String instanceName = "dev"; String projectKey = "PROJ"; - String componentId = "test-component"; + String componentId = "test-component-id"; + String catalogItemSlug = "test-catalog-item-slug"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(new RestClientException("Connection failed")); // Act — should not throw - marketplaceService.registerProjectComponent(instanceName, projectKey, componentId); + assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of())); // Assert verify(clientFactory).getClient(instanceName); + verify(marketplaceApiClient).getApiClient(); } @Test - void testRegisterProjectComponent_RestClientException_ThrowsMarketplaceException() throws MarketplaceException { + void testRegisterProjectComponent_Unauthorized_ThrowsException() throws MarketplaceException { // Arrange String instanceName = "dev"; String projectKey = "PROJ"; - String componentId = "test-component"; + String componentId = "test-component-id"; + String catalogItemSlug = "test-catalog-item-slug"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); + HttpClientErrorException unauthorizedException = HttpClientErrorException.create( + HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT) - .thenThrow(new RestClientException("Connection refused")); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(unauthorizedException); // Act & Assert - MarketplaceException exception = assertThrows(MarketplaceException.class, () -> - marketplaceService.registerProjectComponent(instanceName, projectKey, componentId)); + assertThrows(MarketplaceException.class, () -> + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of())); - assertTrue(exception.getMessage().contains("Failed to register")); + verify(clientFactory).getClient(instanceName); } @Test - void testRegisterProjectComponent_AuthError_ThrowsMarketplaceException() throws MarketplaceException { + void testRegisterProjectComponent_BuildsCorrectComponentUrl_UsingBitbucketBaseUrl() throws MarketplaceException { // Arrange String instanceName = "dev"; String projectKey = "PROJ"; - String componentId = "test-component"; + String componentId = "my-component"; + String catalogItemSlug = "test-slug"; + String bitbucketBaseUrl = "https://bitbucket.example.com"; + MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - HttpClientErrorException unauthorizedEx = HttpClientErrorException.create( - HttpStatus.UNAUTHORIZED, "Unauthorized", HttpHeaders.EMPTY, new byte[0], null); + instanceConfig.setBitbucketBaseUrl(bitbucketBaseUrl); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(Object.class); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenThrow(unauthorizedEx); + whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); - // Act & Assert - MarketplaceException exception = assertThrows(MarketplaceException.class, () -> - marketplaceService.registerProjectComponent(instanceName, projectKey, componentId)); + // Act + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of()); - assertTrue(exception.getMessage().contains("Access denied")); + // Assert — capture the body passed to invokeAPI and verify its componentUrl + verify(apiClient).invokeAPI( + eq(PATH_NOTIFY_PROVISIONING), + eq(HttpMethod.PUT), + any(), + any(), + bodyCaptor.capture(), + any(HttpHeaders.class), + any(), any(), any(), any(), any(), any()); + + ProvisioningStatusUpdateRequest capturedRequest = + (ProvisioningStatusUpdateRequest) bodyCaptor.getValue(); + + String expectedUrl = bitbucketBaseUrl + "/projects/" + projectKey + "/repos/" + componentId + "/browse"; + assertEquals(expectedUrl, capturedRequest.getComponentUrl()); } @Test - void testRegisterProjectComponent_DefaultInstance() throws MarketplaceException { + void testRegisterProjectComponent_ComponentIsRegistered_NoExceptionThrown() throws MarketplaceException { // Arrange + String instanceName = "dev"; String projectKey = "PROJ"; String componentId = "test-component"; + String catalogItemSlug = "test-catalog-item-slug"; MarketplaceInstanceConfig instanceConfig = new MarketplaceInstanceConfig(); - instanceConfig.setProvisionerActionsBaseUrl("https://example/provision-actions"); - instanceConfig.setOboScope("api://test/scope"); - when(clientFactory.getDefaultInstanceName()).thenReturn("default"); - when(clientFactory.getClient("default")).thenReturn(marketplaceApiClient); + when(clientFactory.getDefaultInstanceName()).thenReturn(instanceName); + when(clientFactory.getClient(instanceName)).thenReturn(marketplaceApiClient); when(marketplaceApiClient.getApiClient()).thenReturn(apiClient); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); whenInvokeAPI(PATH_NOTIFY_PROVISIONING, HttpMethod.PUT).thenReturn(ResponseEntity.ok(null)); - // Act — should not throw - marketplaceService.registerProjectComponent(projectKey, componentId); + // Act + marketplaceService.registerProjectComponent(projectKey, componentId, catalogItemSlug, List.of()); // Assert - verify(clientFactory).getClient("default"); + verify(clientFactory).getClient(instanceName); } } \ No newline at end of file From b95a7f376a9f7a7d3417b1765d4ea7e0df5564af Mon Sep 17 00:00:00 2001 From: Angel MP Date: Mon, 25 May 2026 15:35:06 +0200 Subject: [PATCH 19/27] Implement reserved parameter validation for project component creation (#24) --- .../ProjectComponentsCreateProperties.java | 15 ++++++++ .../ProjectComponentsExceptionHandler.java | 17 +++++++++ .../ComponentReservedParamException.java | 8 ++++ .../project/facade/ComponentsFacade.java | 32 ++++++++++++++++ ...ProjectComponentsExceptionHandlerTest.java | 16 ++++++++ .../project/facade/ComponentsFacadeTest.java | 38 ++++++++++++++----- application.yaml | 5 +++ 7 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java create mode 100644 api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java new file mode 100644 index 00000000..bc6eb47b --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/config/ProjectComponentsCreateProperties.java @@ -0,0 +1,15 @@ +package org.opendevstack.apiservice.project.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Data +@Configuration +@ConfigurationProperties(prefix = "apis.project-components.create") +public class ProjectComponentsCreateProperties { + + private List reservedParams; +} \ No newline at end of file diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java index b00f2300..b09dd64e 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandler.java @@ -10,6 +10,7 @@ import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentErrorKey; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; @@ -217,6 +218,22 @@ public ResponseEntity handleComponentBadRequestExceptio return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response); } + @ExceptionHandler(ComponentReservedParamException.class) + public ResponseEntity handleComponentReservedParamException( + ComponentReservedParamException ex, + HttpServletRequest request) { + + log.warn("Reserved parameter sent by client: {}", ex.getMessage()); + + CreateComponentResponse response = ComponentsResponseFactory.badRequest( + request.getRequestURI(), + ex.getMessage(), + ComponentErrorKey.INVALID_PARAMETERS + ); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGenericException( Exception ex, diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java new file mode 100644 index 00000000..8bc9f782 --- /dev/null +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/exception/ComponentReservedParamException.java @@ -0,0 +1,8 @@ +package org.opendevstack.apiservice.project.exception; + +public class ComponentReservedParamException extends RuntimeException { + + public ComponentReservedParamException(String message) { + super(message); + } +} diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 01266c36..57eeb7fb 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.externalservice.marketplace.service.CatalogItemOperations; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.config.ProjectComponentsCreateProperties; import org.opendevstack.apiservice.project.exception.CatalogItemNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentBadRequestException; @@ -16,6 +17,7 @@ import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.exception.ComponentRetrievalException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; @@ -24,6 +26,9 @@ import org.springframework.web.client.HttpClientErrorException; import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; @Service @AllArgsConstructor @@ -34,6 +39,8 @@ public class ComponentsFacade { private final MarketplaceMapper marketplaceMapper; + private final ProjectComponentsCreateProperties createProperties; + public Component getProjectComponent(String projectId, String componentId) { try { ProjectComponentExtendedInfo marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); @@ -63,6 +70,7 @@ public Component getProjectComponent(String projectId, String componentId) { } public void provisionProjectComponent(String projectId, CreateComponentRequest createComponentRequest) { + validateReservedParams(createComponentRequest); try { CatalogItem catalogItem = resolveCatalogItem(createComponentRequest); List createComponentParameterList = marketplaceMapper @@ -88,6 +96,30 @@ public void provisionProjectComponent(String projectId, CreateComponentRequest c } } + private void validateReservedParams(CreateComponentRequest createComponentRequest) { + if (createComponentRequest == null || createComponentRequest.getParams() == null + || createComponentRequest.getParams().isEmpty()) { + return; + } + + if (createProperties.getReservedParams() == null || createProperties.getReservedParams().isEmpty()) { + return; + } + + Set reservedParams = createProperties.getReservedParams().stream() + .map(param -> param.toLowerCase(Locale.ROOT)) + .collect(Collectors.toSet()); + + createComponentRequest.getParams().keySet().stream() + .filter(param -> param != null && reservedParams.contains(param.toLowerCase(Locale.ROOT))) + .findFirst() + .ifPresent(param -> { + throw new ComponentReservedParamException( + String.format("Parameter '%s' cannot be provided because it is managed by the system.", param) + ); + }); + } + /** * Resolves the catalog item that matches the requested {@code productId} (interpreted as the * Marketplace catalog item slug). Returns {@code null} when the request or product id is missing, diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java index 818fe30c..70eb4a30 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectComponentsExceptionHandlerTest.java @@ -9,6 +9,7 @@ import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.model.CreateComponentResponse; import org.springframework.core.MethodParameter; @@ -164,6 +165,21 @@ void handle_component_registration_exception_returns_internal_server_error() { assertThat(response.getBody().getPath()).isEqualTo("/api/pub/v0/projects/test-project/components/"); } + @Test + void handle_component_reserved_param_exception_returns_bad_request() { + ComponentReservedParamException exception = new ComponentReservedParamException( + "Parameter 'ods_namespace' cannot be provided because it is managed by the system."); + + ResponseEntity response = handler.handleComponentReservedParamException(exception, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().getHttpStatus()).isEqualTo(HttpStatus.BAD_REQUEST.name()); + assertThat(response.getBody().getErrorKey()).isEqualTo("006"); + assertThat(response.getBody().getMessage()) + .isEqualTo("Parameter 'ods_namespace' cannot be provided because it is managed by the system."); + } + @Test void handle_generic_exception_returns_internal_server_error() { RuntimeException exception = new RuntimeException("boom"); diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 370d7845..78e8f701 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -10,11 +10,14 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; +import org.opendevstack.apiservice.project.config.ProjectComponentsCreateProperties; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; import org.opendevstack.apiservice.project.exception.ComponentCreationException; import org.opendevstack.apiservice.project.exception.ComponentDeletionException; import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; +import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; @@ -23,6 +26,9 @@ import org.springframework.http.HttpStatus; import org.springframework.web.client.HttpClientErrorException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; @@ -44,11 +50,15 @@ class ComponentsFacadeTest { @Mock private MarketplaceService marketplaceExternalService; + private ProjectComponentsCreateProperties createProperties; + private ComponentsFacade componentsFacade; @BeforeEach void setup() { - componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper); + createProperties = new ProjectComponentsCreateProperties(); + createProperties.setReservedParams(List.of("workflow", "ods_namespace", "component_type", "quickstarter_repo")); + componentsFacade = new ComponentsFacade(marketplaceExternalService, marketplaceMapper, createProperties); } @Test @@ -105,23 +115,33 @@ void create_project_component_throws_creation_exception_when_marketplace_returns verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } + @Test + void create_project_component_throws_bad_request_when_request_contains_reserved_param() { + CreateComponentRequest request = buildTestCreateComponentRequest(); + request.putParamsItem("ods_namespace", "null"); + + assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) + .isInstanceOf(ComponentReservedParamException.class) + .hasMessage("Parameter 'ods_namespace' cannot be provided because it is managed by the system."); + } + @Test void create_project_component_throws_already_exists_when_marketplace_returns_conflict() throws MarketplaceException { CreateComponentRequest request = buildTestCreateComponentRequest(); HttpClientErrorException conflict = HttpClientErrorException.Conflict.create( - HttpStatus.CONFLICT, - "Conflict", - HttpHeaders.EMPTY, - new byte[0], - null + HttpStatus.CONFLICT, + "Conflict", + HttpHeaders.EMPTY, + new byte[0], + null ); when(marketplaceExternalService.provisionProjectComponent(eq("testProject"), anyList())) - .thenThrow(new MarketplaceException("This component name already exists, please choose another name.", conflict)); + .thenThrow(new MarketplaceException("This component name already exists, please choose another name.", conflict)); assertThatThrownBy(() -> componentsFacade.provisionProjectComponent("testProject", request)) - .isInstanceOf(ComponentAlreadyExistsException.class) - .hasMessage("This component name already exists, please choose another name."); + .isInstanceOf(ComponentAlreadyExistsException.class) + .hasMessage("This component name already exists, please choose another name."); verify(marketplaceExternalService).provisionProjectComponent(eq("testProject"), anyList()); } diff --git a/application.yaml b/application.yaml index a334b88d..84b0ad73 100644 --- a/application.yaml +++ b/application.yaml @@ -203,6 +203,11 @@ automation: trust-store-type: ${UIPATH_SSL_TRUST_STORE_TYPE:JKS} apis: + project-components: + create: + # Parameters reserved for backend/system use. If provided by clients in params, request is rejected. + reserved-params: ${API_PROJECT_COMPONENTS_RESERVED_PARAMS:} + project-users: # Workflow name triggered for project user automation tasks. ansible-workflow-name: ${API_PROJECT_USERS_WORKFLOW_NAME:ansible++workflow} From e875897662cc65f11f65c2b0582d59f7de7f0f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Mart=C3=ADn?= Date: Mon, 25 May 2026 15:40:22 +0200 Subject: [PATCH 20/27] Fix/project registration checks if already exists (#26) There was a bug when registering a project that it was returning ProjectAlreadyExistsException because it was not omitting checks in Jira, Bitbucket and Openshift. Code has been updated to only check in Collection when this flag is raised. It checks everything otherwise. --- .../impl/ProjectCreationCommandBuilder.java | 35 +++++++++- .../facade/impl/ProjectsFacadeImpl.java | 5 +- .../ProjectCreationCommandBuilderTest.java | 68 ++++++++++++++----- .../facade/impl/ProjectsFacadeImplTest.java | 10 +-- .../service/ProjectExistenceService.java | 2 + .../impl/ProjectExistenceServiceImpl.java | 5 ++ 6 files changed, 98 insertions(+), 27 deletions(-) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java index 15866caa..15978972 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilder.java @@ -30,15 +30,26 @@ public class ProjectCreationCommandBuilder { private final ProjectExistenceService projectExistenceService; - public ProjectCreationCommand build(CreateProjectRequest request, ClientAppEntity clientApp) { + public ProjectCreationCommand buildForCreation(CreateProjectRequest request, ClientAppEntity clientApp) { ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + String projectKey = resolveProjectKey(request.getProjectKey(), flavor); + + return build(request, flavor, projectKey, clientApp); + } + + public ProjectCreationCommand buildForRegistration(CreateProjectRequest request, ClientAppEntity clientApp) { + ClientAppProjectFlavorEntity flavor = resolveFlavor(request, clientApp); + String projectKey = resolveProjectKeyForRegistration(request.getProjectKey(), flavor); + + return build(request, flavor, projectKey, clientApp); + } + private ProjectCreationCommand build(CreateProjectRequest request, ClientAppProjectFlavorEntity flavor, String projectKey, ClientAppEntity clientApp) { String projectFlavor = firstNonBlank(request.getProjectFlavor(), flavor.getName()); String configurationItem = firstNonBlank(request.getConfigurationItem(), flavor.getConfigItem()); String owner = firstNonBlank(request.getOwner(), flavor.getProjectOwner()); String x2account = firstNonBlank(request.getX2OdsAccount(), flavor.getServiceAccount()); String location = firstNonBlank(request.getLocation(), flavor.getLocation()); - String projectKey = resolveProjectKey(request.getProjectKey(), flavor); String projectName = resolveProjectName(request.getProjectName(), projectKey); String projectDescription = firstNonBlank(request.getProjectDescription(), "project " + projectFlavor); @@ -131,6 +142,26 @@ private String resolveProjectKey(String existingProjectKey, ClientAppProjectFlav } } + private String resolveProjectKeyForRegistration(String existingProjectKey, ClientAppProjectFlavorEntity flavor) { + try { + if (Strings.isNotEmpty(existingProjectKey)) { + if (!projectExistenceService.isProjectFoundInCollection(existingProjectKey)) { + return existingProjectKey; + } + + throw new ProjectAlreadyExistsException(ErrorKey.PROJECT_ALREADY_EXISTS); + } + + String pattern = flavor.getProjectKeyPattern(); + + return generateProjectKeyService.generateProjectKey(pattern); + } catch (ProjectKeyGenerationException e) { + throw new ProjectCreationException("Error generating the project key", e); + } catch (ProjectExistenceServiceException e) { + throw new ProjectCreationException("Error checking if the generated key exists: " + e.getMessage(), e); + } + } + private boolean isAllowedConfigItem(String configurationItem, ClientAppProjectFlavorEntity flavor) { String[] allowedConfigItems = flavor.getAllowedConfigItems(); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 746fcd4f..e8df5f6c 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -65,13 +65,14 @@ public CreateProjectResponse createProject(CreateProjectRequest request, UUID cl ClientAppEntity clientApp = clientAppService.findByClientId(clientId); - ProjectCreationCommand command = projectCreationCommandBuilder.build(request, clientApp); - + ProjectCreationCommand command; ProjectResponse project; if (Boolean.TRUE.equals(request.getRegistrationOnly())) { + command = projectCreationCommandBuilder.buildForRegistration(request, clientApp); project = registerProject(command); } else { + command = projectCreationCommandBuilder.buildForCreation(request, clientApp); project = createNewProject(command); } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java index 0c71c77b..cef7943f 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectCreationCommandBuilderTest.java @@ -50,7 +50,7 @@ void tear_down() throws Exception { } @Test - void build_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { + void build_for_creation_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); @@ -59,7 +59,7 @@ void build_resolves_defaults_from_flavor_when_request_fields_are_missing() throw when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); - ProjectCreationCommand result = sut.build(request, clientApp); + ProjectCreationCommand result = sut.buildForCreation(request, clientApp); assertEquals("DLSS", result.getProjectFlavor()); assertEquals("CI-001", result.getConfigurationItem()); @@ -69,21 +69,21 @@ void build_resolves_defaults_from_flavor_when_request_fields_are_missing() throw } @Test - void build_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() throws ProjectExistenceServiceException { + void build_for_creation_resolves_flavor_from_configuration_item_when_flavor_is_not_provided() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); - ProjectCreationCommand result = sut.build(request, clientApp); + ProjectCreationCommand result = sut.buildForCreation(request, clientApp); assertEquals("DLSS", result.getProjectFlavor()); assertEquals("CI-001", result.getConfigurationItem()); } @Test - void build_generates_project_key_when_request_project_key_is_null() throws ProjectExistenceServiceException, org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException { + void build_for_creation_generates_project_key_when_request_project_key_is_null() throws ProjectExistenceServiceException, org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, null); @@ -91,14 +91,14 @@ void build_generates_project_key_when_request_project_key_is_null() throws Proje when(generateProjectKeyService.generateProjectKey("DLSS%06d")).thenReturn("DLSS000001"); when(projectExistenceService.isProjectFound("DLSS000001")).thenReturn(false); - ProjectCreationCommand result = sut.build(request, clientApp); + ProjectCreationCommand result = sut.buildForCreation(request, clientApp); assertEquals("DLSS000001", result.getProjectKey()); verify(generateProjectKeyService).generateProjectKey("DLSS%06d"); } @Test - void build_throws_project_already_exists_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + void build_for_creation_throws_project_already_exists_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); @@ -106,35 +106,35 @@ void build_throws_project_already_exists_exception_when_project_key_already_exis when(projectExistenceService.isProjectFound("KEY01")).thenReturn(true); ProjectAlreadyExistsException ex = assertThrows(ProjectAlreadyExistsException.class, - () -> sut.build(request, clientApp)); + () -> sut.buildForCreation(request, clientApp)); assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); } @Test - void build_throws_validation_exception_when_flavor_and_config_item_are_missing() { + void build_for_creation_throws_validation_exception_when_flavor_and_config_item_are_missing() { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request(null, null, "KEY01"); ProjectValidationException ex = assertThrows(ProjectValidationException.class, - () -> sut.build(request, clientApp)); + () -> sut.buildForCreation(request, clientApp)); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, ex.getErrorKey()); } @Test - void build_throws_validation_exception_when_configuration_item_matches_multiple_flavors() { + void build_for_creation_throws_validation_exception_when_configuration_item_matches_multiple_flavors() { ClientAppEntity clientApp = build_client_app(List.of( build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"), build_flavor("AMP", "CI-001", new String[] {}, "eu", "owner2"))); CreateProjectRequest request = build_request(null, "CI-001", "KEY01"); ProjectValidationException ex = assertThrows(ProjectValidationException.class, - () -> sut.build(request, clientApp)); + () -> sut.buildForCreation(request, clientApp)); assertEquals(ErrorKey.INVALID_CONFIG_ITEM, ex.getErrorKey()); } @Test - void build_throws_project_key_generation_exception_when_generation_fails() throws Exception { + void build_for_creation_throws_project_key_generation_exception_when_generation_fails() throws Exception { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, null); @@ -142,11 +142,11 @@ void build_throws_project_key_generation_exception_when_generation_fails() throw when(generateProjectKeyService.generateProjectKey("DLSS%06d")) .thenThrow(new org.opendevstack.apiservice.serviceproject.exception.ProjectKeyGenerationException("fail")); - assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); + assertThrows(ProjectCreationException.class, () -> sut.buildForCreation(request, clientApp)); } @Test - void build_throws_project_already_exists_exception_when_project_name_already_exists() throws Exception { + void build_for_creation_throws_project_already_exists_exception_when_project_name_already_exists() throws Exception { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); @@ -155,11 +155,11 @@ void build_throws_project_already_exists_exception_when_project_name_already_exi when(projectExistenceService.isProjectFound("KEY01")).thenReturn(false); when(projectExistenceService.isProjectFoundByName("Existing Project")).thenReturn(true); - assertThrows(ProjectAlreadyExistsException.class, () -> sut.build(request, clientApp)); + assertThrows(ProjectAlreadyExistsException.class, () -> sut.buildForCreation(request, clientApp)); } @Test - void build_throws_project_creation_exception_when_project_name_check_fails() throws Exception { + void build_for_creation_throws_project_creation_exception_when_project_name_check_fails() throws Exception { ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); ClientAppEntity clientApp = build_client_app(List.of(flavor)); CreateProjectRequest request = build_request("DLSS", null, "KEY01"); @@ -169,7 +169,39 @@ void build_throws_project_creation_exception_when_project_name_check_fails() thr when(projectExistenceService.isProjectFoundByName("Any Project")) .thenThrow(new ProjectExistenceServiceException("lookup failed")); - assertThrows(ProjectCreationException.class, () -> sut.build(request, clientApp)); + assertThrows(ProjectCreationException.class, () -> sut.buildForCreation(request, clientApp)); + } + + @Test + void build_for_registration_resolves_defaults_from_flavor_when_request_fields_are_missing() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + request.setOwner(null); + request.setLocation(null); + + when(projectExistenceService.isProjectFoundInCollection("KEY01")).thenReturn(false); + + ProjectCreationCommand result = sut.buildForRegistration(request, clientApp); + + assertEquals("DLSS", result.getProjectFlavor()); + assertEquals("CI-001", result.getConfigurationItem()); + assertEquals("owner1", result.getOwner()); + assertEquals("eu", result.getLocation()); + assertEquals("KEY01", result.getProjectKey()); + } + + @Test + void build_for_registration_throws_project_already_exists_exception_when_project_key_already_exists() throws ProjectExistenceServiceException { + ClientAppProjectFlavorEntity flavor = build_flavor("DLSS", "CI-001", new String[] {}, "eu", "owner1"); + ClientAppEntity clientApp = build_client_app(List.of(flavor)); + CreateProjectRequest request = build_request("DLSS", null, "KEY01"); + + when(projectExistenceService.isProjectFoundInCollection("KEY01")).thenReturn(true); + + ProjectAlreadyExistsException ex = assertThrows(ProjectAlreadyExistsException.class, + () -> sut.buildForRegistration(request, clientApp)); + assertEquals(ErrorKey.PROJECT_ALREADY_EXISTS, ex.getErrorKey()); } private CreateProjectRequest build_request(String flavor, String configItem, String projectKey) { diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index dd622efc..a81646cc 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -101,7 +101,7 @@ void create_project_returns_success_response_when_automation_is_successful() { apiResponse.setProjectFlavor("DLSS"); when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectCreationCommandBuilder.buildForCreation(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); when(automationParametersMapper.toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111")) @@ -114,7 +114,7 @@ void create_project_returns_success_response_when_automation_is_successful() { assertEquals("Pending", result.getStatus()); assertEquals("DLSS", result.getProjectFlavor()); - verify(projectCreationCommandBuilder).build(request, clientApp); + verify(projectCreationCommandBuilder).buildForCreation(request, clientApp); verify(projectMapper).toServiceRequest(command); verify(automationParametersMapper) .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); @@ -139,7 +139,7 @@ void create_project_with_registrationOnly_returns_success_response_when_automati apiResponse.setProjectFlavor("REGULAR"); when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectCreationCommandBuilder.buildForRegistration(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(serviceRequest); when(projectService.saveProject(serviceRequest)).thenReturn(projectResponse); when(projectCreationResponseMapper.toSuccessResponse(command, projectResponse)).thenReturn(apiResponse); @@ -148,7 +148,7 @@ void create_project_with_registrationOnly_returns_success_response_when_automati assertEquals("Running", result.getStatus()); assertEquals("REGULAR", result.getProjectFlavor()); - verify(projectCreationCommandBuilder).build(request, clientApp); + verify(projectCreationCommandBuilder).buildForRegistration(request, clientApp); verify(projectMapper).toServiceRequest(command); verify(automationParametersMapper, never()) .toWorkflowParameters(command, "11111111-1111-1111-1111-111111111111"); @@ -164,7 +164,7 @@ void create_project_throws_project_creation_exception_when_automation_is_not_suc "DLSS01", "name", "desc", "DLSS", "CI-001", "eu", "x2test", "owner", CLIENT_ID); when(clientAppService.findByClientId(CLIENT_ID)).thenReturn(clientApp); - when(projectCreationCommandBuilder.build(request, clientApp)).thenReturn(command); + when(projectCreationCommandBuilder.buildForCreation(request, clientApp)).thenReturn(command); when(projectMapper.toServiceRequest(command)).thenReturn(new ProjectRequest()); when(projectService.saveProject(any(ProjectRequest.class))) .thenReturn(ProjectResponse.builder() diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java index 39330437..7ab8dd5b 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectExistenceService.java @@ -7,4 +7,6 @@ public interface ProjectExistenceService { boolean isProjectFound(String projectKey) throws ProjectExistenceServiceException; boolean isProjectFoundByName(String projecName) throws ProjectExistenceServiceException; + + boolean isProjectFoundInCollection(String projectKey) throws ProjectExistenceServiceException; } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java index c5762567..76e02e0f 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectExistenceServiceImpl.java @@ -58,6 +58,11 @@ public boolean isProjectFoundByName(String projecName) throws ProjectExistenceSe return !projectService.findProjectsByName(projecName).isEmpty(); } + @Override + public boolean isProjectFoundInCollection(String projectKey) throws ProjectExistenceServiceException { + return existsInCollection(projectKey); + } + private boolean existsInCollection(String projectKey) { return projectService.getProject(projectKey) != null; } From 424df55b099b40621820750f4a05eec4a5c497a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Mart=C3=ADn?= Date: Tue, 26 May 2026 17:22:15 +0200 Subject: [PATCH 21/27] Fixed Bitbucket URL format on component registration (#32) --- .../marketplace/service/impl/MarketplaceServiceImpl.java | 5 +++-- .../marketplace/service/MarketplaceServiceImplTest.java | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index c72bcead..40badb86 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -235,8 +235,9 @@ public void registerProjectComponent(String instanceName, ProvisioningStatusUpdateRequest registerRequest = new ProvisioningStatusUpdateRequest() .componentId(componentId) .catalogItemSlug(catalogItemSlug) - .componentUrl(String.format("%s/projects/%s/repos/%s/browse", - marketplaceClient.getConfig().getBitbucketBaseUrl(), projectId, componentId)) + .componentUrl(String.format("%s/projects/%s/repos/%s-%s/browse", + marketplaceClient.getConfig().getBitbucketBaseUrl(), + projectId.toUpperCase(), projectId.toLowerCase(), componentId)) .parameters(params); provisionResultsApi.notifyProvisioningStatusUpdate(projectId, "CREATED", registerRequest); } catch (HttpClientErrorException.Unauthorized | HttpClientErrorException.Forbidden e) { diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 6ac97829..adb5573e 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -887,7 +887,8 @@ void testRegisterProjectComponent_BuildsCorrectComponentUrl_UsingBitbucketBaseUr ProvisioningStatusUpdateRequest capturedRequest = (ProvisioningStatusUpdateRequest) bodyCaptor.getValue(); - String expectedUrl = bitbucketBaseUrl + "/projects/" + projectKey + "/repos/" + componentId + "/browse"; + String expectedUrl = bitbucketBaseUrl + "/projects/" + projectKey.toUpperCase() + "/repos/" + + projectKey.toLowerCase() + "-" + componentId + "/browse"; assertEquals(expectedUrl, capturedRequest.getComponentUrl()); } From 07c31edb5fd876bf47430e524dcfee3bb4f486e9 Mon Sep 17 00:00:00 2001 From: Angel MP Date: Wed, 27 May 2026 09:49:32 +0200 Subject: [PATCH 22/27] Use project description from request when creating project entity (#31) --- .../apiservice/project/facade/impl/ProjectsFacadeImpl.java | 1 - .../serviceproject/service/impl/ProjectServiceImpl.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index e8df5f6c..8e3b80d6 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -88,7 +88,6 @@ private ProjectResponse registerProject(ProjectCreationCommand command) { ProjectRequest projectRequest = projectMapper.toServiceRequest(command); projectRequest.setStatus(Status.RUNNING); - projectRequest.setProjectFlavor("REGULAR"); return projectService.saveProject(projectRequest); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index a7e09956..7673925b 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -41,7 +41,7 @@ public ProjectResponse saveProject(ProjectRequest request) { entity.setProjectName(request.getProjectName()); entity.setProjectFlavor(request.getProjectFlavor()); entity.setConfigurationItem(request.getConfigurationItem()); - entity.setDescription(request.getProjectFlavor() + " project"); + entity.setDescription(request.getProjectDescription()); entity.setLdapGroupManager(getLdapGroup(MANAGER_ROLE, request.getProjectKey())); entity.setLdapGroupTeam(getLdapGroup(TEAM_ROLE, request.getProjectKey())); entity.setLdapGroupStakeholder(getLdapGroup(STAKEHOLDER_ROLE, request.getProjectKey())); From 8d446567337ba5481431e526a6445527b0858dce Mon Sep 17 00:00:00 2001 From: Angel MP Date: Thu, 4 Jun 2026 16:41:50 +0200 Subject: [PATCH 23/27] Fix project key validation regex to enforce correct format (#33) --- .../openapi/api-project-component-v0-internal.yaml | 2 +- api-project/openapi/api-project.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api-project-component-v0/openapi/api-project-component-v0-internal.yaml b/api-project-component-v0/openapi/api-project-component-v0-internal.yaml index a903d6e1..1dd71346 100644 --- a/api-project-component-v0/openapi/api-project-component-v0-internal.yaml +++ b/api-project-component-v0/openapi/api-project-component-v0-internal.yaml @@ -30,7 +30,7 @@ paths: description: Project key schema: type: string - pattern: "^[A-Z]{2}[A-Z0-9]{1,8}$" + pattern: "^[A-Z][A-Z0-9]{2,9}$" minLength: 3 maxLength: 10 - name: componentId diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index efb1c04c..9a02630d 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -124,7 +124,7 @@ components: projectKey: type: string description: Optional project key. If not provided, a unique key will be generated. - pattern: "^[A-Z]{2}[A-Z0-9]{1,8}$" + pattern: "^[A-Z][A-Z0-9]{2,9}$" minLength: 3 maxLength: 10 projectName: From 6df52cd289f9e24166912ad11cd760e11130a920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Mart=C3=ADn?= Date: Wed, 10 Jun 2026 10:10:23 +0200 Subject: [PATCH 24/27] Updated GetComponent marketplace endpoint call (#34) --- .../project/facade/ComponentsFacade.java | 4 ++-- .../project/mapper/MarketplaceMapper.java | 4 ++-- .../project/facade/ComponentsFacadeTest.java | 6 ++---- .../project/util/TestObjectsBuilder.java | 6 +++--- external-service-marketplace/pom.xml | 4 ++-- .../service/CatalogItemOperations.java | 4 ++-- .../service/MarketplaceService.java | 6 +++--- .../service/impl/MarketplaceServiceImpl.java | 12 ++++++------ .../service/CatalogItemOperationsTest.java | 18 +++++++++--------- .../service/MarketplaceServiceImplTest.java | 8 ++++---- 10 files changed, 35 insertions(+), 37 deletions(-) diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java index 57eeb7fb..80bce520 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/facade/ComponentsFacade.java @@ -4,7 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.externalservice.marketplace.service.CatalogItemOperations; @@ -43,7 +43,7 @@ public class ComponentsFacade { public Component getProjectComponent(String projectId, String componentId) { try { - ProjectComponentExtendedInfo marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); + ProjectComponentProvisionStatus marketplaceComponent = marketplaceExternalService.getProjectComponent(projectId, componentId); if (marketplaceComponent == null) { log.info("Marketplace component with id {} not found", componentId); throw new ComponentNotFoundException( diff --git a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java index 19887f06..718a9398 100644 --- a/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java +++ b/api-project-component-v0/src/main/java/org/opendevstack/apiservice/project/mapper/MarketplaceMapper.java @@ -5,7 +5,7 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserAction; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItemUserActionParameter; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; import org.opendevstack.apiservice.project.model.Component; @@ -22,7 +22,7 @@ public interface MarketplaceMapper { String DEFAULT_PARAMETER_TYPE = "string"; - default Component mapMarketplaceComponentToV0Component(ProjectComponentExtendedInfo source, CatalogItem catalogItem) throws MarketplaceException { + default Component mapMarketplaceComponentToV0Component(ProjectComponentProvisionStatus source, CatalogItem catalogItem) throws MarketplaceException { Component component = new Component(); component.setId(source.getComponentId()); component.setEnvironment(EnvironmentsDTO.DEV); // Env is always DEV so we hardcode it as such diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java index 78e8f701..3af04053 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/facade/ComponentsFacadeTest.java @@ -8,7 +8,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.service.MarketplaceService; import org.opendevstack.apiservice.project.config.ProjectComponentsCreateProperties; import org.opendevstack.apiservice.project.exception.ComponentAlreadyExistsException; @@ -17,7 +17,6 @@ import org.opendevstack.apiservice.project.exception.ComponentNotFoundException; import org.opendevstack.apiservice.project.exception.ComponentRegistrationException; import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; -import org.opendevstack.apiservice.project.exception.ComponentReservedParamException; import org.opendevstack.apiservice.project.mapper.MarketplaceMapper; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; @@ -30,7 +29,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -63,7 +61,7 @@ void setup() { @Test void get_project_component_returns_mapped_component_when_marketplace_returns_data() throws MarketplaceException { - ProjectComponentExtendedInfo marketplaceComponent = buildTestMarketplaceComponent(); + ProjectComponentProvisionStatus marketplaceComponent = buildTestMarketplaceComponent(); CatalogItem testCatalogItem = buildTestCatalogItem(); when(marketplaceExternalService.getProjectComponent("testProject", "testComponent")) diff --git a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java index b40d9087..b0ed3515 100644 --- a/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java +++ b/api-project-component-v0/src/test/java/org/opendevstack/apiservice/project/util/TestObjectsBuilder.java @@ -1,7 +1,7 @@ package org.opendevstack.apiservice.project.util; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.project.model.Component; import org.opendevstack.apiservice.project.model.ComponentsStatusDTO; import org.opendevstack.apiservice.project.model.CreateComponentRequest; @@ -26,8 +26,8 @@ public static Component buildTestComponent() { return component; } - public static ProjectComponentExtendedInfo buildTestMarketplaceComponent() { - ProjectComponentExtendedInfo component = new ProjectComponentExtendedInfo(); + public static ProjectComponentProvisionStatus buildTestMarketplaceComponent() { + ProjectComponentProvisionStatus component = new ProjectComponentProvisionStatus(); component.setComponentId(UUID.randomUUID().toString()); component.setStatus("CREATING"); component.setComponentUrl("http://test.component.url"); diff --git a/external-service-marketplace/pom.xml b/external-service-marketplace/pom.xml index 061eceb5..d3b19911 100644 --- a/external-service-marketplace/pom.xml +++ b/external-service-marketplace/pom.xml @@ -150,7 +150,7 @@ generate - FILTER=operationId:getProjectComponents|getProjectComponentById|getCatalogItemById|getCatalogItemBySlug|getCatalogHealth + FILTER=operationId:getCatalogItemById|getCatalogItemBySlug|getCatalogHealth java ${project.basedir}/target/generated-sources/openapi resttemplate @@ -186,7 +186,7 @@ generate - FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProvisionerHealth + FILTER=operationId:triggerProvisionAction|notifyProvisioningStatusUpdate|deleteProjectComponent|getProjectComponentProvisionStatusById|getProvisionerHealth java ${project.basedir}/target/generated-sources/openapi resttemplate diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java index f962ed8c..d4d14e9f 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperations.java @@ -1,6 +1,6 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import java.nio.charset.StandardCharsets; import java.util.Base64; @@ -18,7 +18,7 @@ public static byte[] decodeId(String id) { return Base64.getUrlDecoder().decode(id); } - public static String buildCatalogItemId(ProjectComponentExtendedInfo component) { + public static String buildCatalogItemId(ProjectComponentProvisionStatus component) { if (component == null || component.getCatalogItemId() == null || component.getCatalogItemRef() == null) { return null; } diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java index 30155e8c..98fc9341 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceService.java @@ -3,7 +3,7 @@ import org.opendevstack.apiservice.externalservice.api.ExternalService; import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisioningStatusUpdateRequestAllOfParameters; @@ -18,9 +18,9 @@ public interface MarketplaceService extends ExternalService { String getDefaultInstance() throws MarketplaceException; - ProjectComponentExtendedInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException; + ProjectComponentProvisionStatus getProjectComponent(String projectId, String componentId) throws MarketplaceException; - ProjectComponentExtendedInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; + ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException; CatalogItem getCatalogItem(String catalogItemId) throws MarketplaceException; diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 40badb86..87b41226 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -10,14 +10,14 @@ import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.api.CatalogHealthApi; import org.opendevstack.apiservice.externalservice.marketplace.client.api.CatalogItemsApi; -import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProjectComponentsApi; +import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProjectComponentsWithProvisionStatusApi; import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionResultsApi; import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerActionsApi; import org.opendevstack.apiservice.externalservice.marketplace.client.api.ProvisionerHealthApi; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetCatalogHealth200Response; import org.opendevstack.apiservice.externalservice.marketplace.client.model.GetProvisionerHealth200Response; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionAction; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionParameter; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; @@ -107,19 +107,19 @@ public CatalogItem getCatalogItemBySlug(String instanceName, String slug) throws } @Override - public ProjectComponentExtendedInfo getProjectComponent(String projectId, String componentId) throws MarketplaceException { + public ProjectComponentProvisionStatus getProjectComponent(String projectId, String componentId) throws MarketplaceException { return getProjectComponent(getDefaultInstance(), projectId, componentId); } @Override - public ProjectComponentExtendedInfo getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { + public ProjectComponentProvisionStatus getProjectComponent(String instanceName, String projectId, String componentId) throws MarketplaceException { log.debug("Marketplace service GET component with id {} for project {} in instance {} ", componentId, projectId, instanceName); try { MarketplaceApiClient marketplaceClient = getOboAuthenticatedClient(instanceName); ApiClient apiClient = marketplaceClient.getApiClient(); - ProjectComponentsApi projectComponentsApi = new ProjectComponentsApi(apiClient); + ProjectComponentsWithProvisionStatusApi projectComponentsApi = new ProjectComponentsWithProvisionStatusApi(apiClient); apiClient.setBasePath(marketplaceClient.getConfig().getProjectComponentsBaseUrl()); - return projectComponentsApi.getProjectComponentById(projectId, componentId); + return projectComponentsApi.getProjectComponentProvisionStatusById(projectId, componentId); } catch (HttpClientErrorException.NotFound e) { log.debug("Component with id '{}' not found in Marketplace instance '{}' for project '{}'", componentId, instanceName, projectId); diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java index c019dbff..54b849af 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/CatalogItemOperationsTest.java @@ -1,7 +1,7 @@ package org.opendevstack.apiservice.externalservice.marketplace.service; import org.junit.jupiter.api.Test; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -17,21 +17,21 @@ void testBuildCatalogItemId_whenNullComponent_ReturnNull() { @Test void testBuildCatalogItemId_whenNullValues_ReturnNull() { - ProjectComponentExtendedInfo testComponentExtendedInfo = new ProjectComponentExtendedInfo(); - testComponentExtendedInfo.setCatalogItemId(null); - testComponentExtendedInfo.setCatalogItemRef(null); + ProjectComponentProvisionStatus testComponentProvisionStatus = new ProjectComponentProvisionStatus(); + testComponentProvisionStatus.setCatalogItemId(null); + testComponentProvisionStatus.setCatalogItemRef(null); - String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentExtendedInfo); + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentProvisionStatus); assertNull(catalogItemId); } @Test void testBuildCatalogItemId_whenCorrectValues_ReturnCorrectlyBuiltId() { - ProjectComponentExtendedInfo testComponentExtendedInfo = new ProjectComponentExtendedInfo(); - testComponentExtendedInfo.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); - testComponentExtendedInfo.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); + ProjectComponentProvisionStatus testComponentProvisionStatus = new ProjectComponentProvisionStatus(); + testComponentProvisionStatus.setCatalogItemId("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1s"); + testComponentProvisionStatus.setCatalogItemRef("P2F0PXJlZnMvaGVhZHMvbWFzdGVy"); - String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentExtendedInfo); + String catalogItemId = CatalogItemOperations.buildCatalogItemId(testComponentProvisionStatus); assertNotNull(catalogItemId); assertEquals("cHJvamVjdHMvVEVTVC9yZXBvcy9DYXRhbG9nSXRlbS55YW1sP2F0PXJlZnMvaGVhZHMvbWFzdGVy", catalogItemId); } diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index adb5573e..69a5e0a9 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -16,7 +16,7 @@ import org.opendevstack.apiservice.externalservice.marketplace.exception.MarketplaceException; import org.opendevstack.apiservice.externalservice.marketplace.client.ApiClient; import org.opendevstack.apiservice.externalservice.marketplace.client.model.CatalogItem; -import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentExtendedInfo; +import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProjectComponentProvisionStatus; import org.opendevstack.apiservice.externalservice.marketplace.client.model.ProvisionActionResponse; import org.opendevstack.apiservice.externalservice.marketplace.service.impl.MarketplaceServiceImpl; import org.springframework.core.ParameterizedTypeReference; @@ -199,7 +199,7 @@ void testGetProjectComponent_NotFound_ReturnsNull() throws MarketplaceException when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); // Act - ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(instanceName, projectKey, componentId); + ProjectComponentProvisionStatus result = marketplaceService.getProjectComponent(instanceName, projectKey, componentId); // Assert assertNull(result); @@ -348,7 +348,7 @@ void testGetProjectComponent_NoInstanceArg_UsesDefaultClient() throws Marketplac whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenReturn(ResponseEntity.ok(null)); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + ProjectComponentProvisionStatus result = marketplaceService.getProjectComponent(projectKey, componentId); assertNull(result); verify(clientFactory).getClient("default"); @@ -402,7 +402,7 @@ void testGetProjectComponent_NoInstanceArg_NotFound_ReturnsFalse() throws Market whenInvokeAPI(PATH_PROJECT_COMPONENT, HttpMethod.GET).thenThrow(notFoundEx); when(marketplaceApiClient.getConfig()).thenReturn(instanceConfig); - ProjectComponentExtendedInfo result = marketplaceService.getProjectComponent(projectKey, componentId); + ProjectComponentProvisionStatus result = marketplaceService.getProjectComponent(projectKey, componentId); assertNull(result); } From 8669f7865a70767f8f7bd531f0bde2808996a0a1 Mon Sep 17 00:00:00 2001 From: Angel MP Date: Tue, 23 Jun 2026 14:34:00 +0200 Subject: [PATCH 25/27] =?UTF-8?q?Add=20audience=20extraction=20and=20valid?= =?UTF-8?q?ation=20methods=20for=20JWT=20tokens=20and=20Ref=E2=80=A6=20(#3?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …actor authentication token handling in MarketplaceServiceImpl to streamline authentication process. --------- Co-authored-by: Jorge Romero --- CHANGELOG.md | 17 +++ README.md | 44 +++++- application.yaml | 27 ++-- .../core/security/config/SecurityConfig.java | 64 +++++++- .../security/config/SecurityProperties.java | 27 +++- .../ClientCredentialFlowValidator.java | 4 +- .../core/security/jwt/JwtUtils.java | 72 +++++++++ .../security/config/SecurityConfigTest.java | 28 +++- .../ClientCredentialFlowValidatorTest.java | 139 ++++++++++++++++++ .../core/security/jwt/JwtUtilsTest.java | 131 +++++++++++++++++ .../config/MarketplaceInstanceConfig.java | 23 +++ .../service/impl/MarketplaceServiceImpl.java | 17 ++- .../service/MarketplaceServiceImplTest.java | 1 + 13 files changed, 568 insertions(+), 26 deletions(-) create mode 100644 core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index df729a5f..ed331834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to the DevStack API Service project will be documented in th The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +#### Security (`core-security`) +- Support for **multiple JWT issuers**: each trusted issuer pairs an issuer URI (`iss` claim) with a JWK set URI, selected by the incoming token's `iss` claim (`OAUTH2_ISSUER_V1`/`OAUTH2_JWK_SET_URI_V1`, `OAUTH2_ISSUER_V2`/`OAUTH2_JWK_SET_URI_V2`). +- Shared **audience validation** across all configured issuers (`OAUTH2_AUDIENCE`, `OAUTH2_AUDIENCE2`); a valid token must carry at least one accepted `aud` value. +- `JwtUtils` audience and scope extraction helpers, including `tokenMatchesScopeAudience` to detect tokens that already target a given audience and scope. + +#### External Service Marketplace (`external-service-marketplace`) +- **OBO exchange bypass**: when the incoming token already targets the configured bypass audience and scope, the token is forwarded as-is instead of performing an On-Behalf-Of exchange (`MARKETPLACE_BYPASS_AUDIENCE`, `MARKETPLACE_BYPASS_SCOPE`). + +### Changed +- Refactored `SecurityConfig` to resolve the authentication manager per issuer using `JwtIssuerAuthenticationManagerResolver`. +- Moved OAuth2 resource server configuration from Spring's `spring.security.oauth2.resourceserver` properties to application-managed `app` security properties. +- `ClientCredentialFlowValidator` now accepts the client identifier from either the `appid` (v1 tokens) or `azp` (v2 tokens) claim. + ## [0.0.3] - 2026-03-03 ### Added diff --git a/README.md b/README.md index 195ffcd3..bfda2162 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ The system will expose RESTful APIs for third-party client applications and futu - [Quick Start](#quick-start) - [Build Options](#build-options) - [Running the Application](#running-the-application) +- [Security & Authentication](#security--authentication) - [Development Workflow](#development-workflow) - [Docker Support](#docker-support) - [Advanced Usage](#advanced-usage) @@ -112,7 +113,48 @@ Once running, the application is available at: - **Swagger UI**: http://localhost:8080/swagger-ui.html - **Actuator Health**: http://localhost:8080/actuator/health -## 💻 Development Workflow +## � Security & Authentication + +The service is an OAuth2 Resource Server that validates incoming JWT access tokens. +Authentication is configured entirely through environment variables. + +### Multiple JWT Issuers + +The API accepts tokens from more than one trusted identity provider. Each issuer +pairs an issuer URI (the token `iss` claim) with the JWK set URI used to validate +the token signature. The incoming token's `iss` claim selects the matching issuer. + +| Variable | Description | +|----------|-------------| +| `OAUTH2_ISSUER_V1` | Issuer URI (`iss` claim) of the first trusted identity provider | +| `OAUTH2_JWK_SET_URI_V1` | JWK set URI used to verify signatures for issuer V1 | +| `OAUTH2_ISSUER_V2` | Issuer URI (`iss` claim) of the second trusted identity provider | +| `OAUTH2_JWK_SET_URI_V2` | JWK set URI used to verify signatures for issuer V2 | + +### Allowed Audiences + +Audiences are shared across all configured issuers. A valid token must carry at +least one of these values in its `aud` claim. + +| Variable | Description | +|----------|-------------| +| `OAUTH2_AUDIENCE` | First accepted audience value | +| `OAUTH2_AUDIENCE2` | Second accepted audience value | + +### Marketplace OBO Bypass + +Calls to the Marketplace service normally perform an On-Behalf-Of (OBO) token +exchange. When the incoming token already targets the configured bypass audience +and scope, the OBO exchange is skipped and the token is forwarded as-is. + +| Variable | Description | +|----------|-------------| +| `MARKETPLACE_BYPASS_AUDIENCE` | Audience that must be present in the token `aud` claim to skip the OBO exchange | +| `MARKETPLACE_BYPASS_SCOPE` | Scope that must be present in the token `scp` claim to skip the OBO exchange | + +> If either bypass value is unset, the OBO exchange is always performed. + +## �💻 Development Workflow ### Quick Development Cycle Clean, compile, and test in one command: diff --git a/application.yaml b/application.yaml index 84b0ad73..f276531c 100644 --- a/application.yaml +++ b/application.yaml @@ -18,18 +18,6 @@ logging: # Spring Boot infrastructure configuration. # Most values here should be supplied from environment variables or a secret store. spring: - security: - oauth2: - resourceserver: - jwt: - # URL of the identity provider JWK set used to validate JWT signatures. - jwk-set-uri: ${OAUTH2_JWK_SET_URI:} - # Expected issuer claim of incoming JWTs. - issuer-uri: ${OAUTH2_ISSUER:} - audiences: - # Allowed audience values for access tokens accepted by this API. - - ${OAUTH2_AUDIENCE:} - - ${OAUTH2_AUDIENCE2:99999} datasource: # JDBC connection string for the PostgreSQL database. # Example: jdbc:postgresql://localhost:5432/ods_api_service @@ -87,6 +75,18 @@ app: client-id: ${OBO_CLIENT_ID} # Client secret for OBO exchange. Must be injected via environment variable or secret store. client-secret: ${OBO_CLIENT_SECRET} + # Audience values accepted by this API. A valid token must carry at least one of these. + # These are shared across all configured issuers. + audiences: + - ${OAUTH2_AUDIENCE:} + - ${OAUTH2_AUDIENCE2:} + # Trusted JWT issuers. Each entry pairs an issuer URI (iss claim) with the JWK set URI + # used to validate token signatures. The incoming token's iss claim selects the entry. + issuers: + - issuer-uri: ${OAUTH2_ISSUER_V1:} + jwk-set-uri: ${OAUTH2_JWK_SET_URI_V1:} + - issuer-uri: ${OAUTH2_ISSUER_V2:} + jwk-set-uri: ${OAUTH2_JWK_SET_URI_V2:} # Spring Boot Actuator configuration. # Restrict these endpoints in production if they expose operational details. @@ -309,6 +309,9 @@ externalservices: provisioner-actions-base-url: ${MARKETPLACE_PROVISIONER_ACTIONS_BASE_URL:} bitbucket-base-url: ${MARKETPLACE_BITBUCKET_BASE_URL:} obo-scope: ${MARKETPLACE_OBO_SCOPE:} + bypass: + audience: ${MARKETPLACE_BYPASS_AUDIENCE:} + scope: ${MARKETPLACE_BYPASS_SCOPE:} trust-all-certificates: ${MARKETPLACE_TRUST_ALL_CERTS:false} username: ${MARKETPLACE_USERNAME:} password: ${MARKETPLACE_PASSWORD:} diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java index 149be549..91afd028 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityConfig.java @@ -5,13 +5,29 @@ import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; 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.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.core.OAuth2TokenValidator; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimNames; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtValidators; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationProvider; +import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; import org.springframework.security.web.access.intercept.AuthorizationFilter; import org.springframework.security.web.SecurityFilterChain; import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; @Slf4j @Configuration @@ -36,7 +52,45 @@ public SecurityConfig(SecurityProperties securityProperties, } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public JwtIssuerAuthenticationManagerResolver jwtIssuerAuthenticationManagerResolver() { + List audiences = securityProperties.getAudiences(); + Map managers = new HashMap<>(); + + for (SecurityProperties.IssuerConfig issuerConfig : securityProperties.getIssuers()) { + String issuerUri = issuerConfig.getIssuerUri(); + AuthenticationManager manager = buildAuthenticationManager(issuerConfig, audiences); + managers.put(issuerUri, manager); + log.info("Registered JWT issuer: {}", issuerUri); + } + + return new JwtIssuerAuthenticationManagerResolver(managers::get); + } + + private AuthenticationManager buildAuthenticationManager( + SecurityProperties.IssuerConfig issuerConfig, List audiences) { + + NimbusJwtDecoder decoder = NimbusJwtDecoder + .withJwkSetUri(issuerConfig.getJwkSetUri()) + .build(); + + List> validators = new ArrayList<>(); + validators.add(JwtValidators.createDefaultWithIssuer(issuerConfig.getIssuerUri())); + if (audiences != null && !audiences.isEmpty()) { + validators.add(new JwtClaimValidator>( + JwtClaimNames.AUD, + aud -> aud != null && !Collections.disjoint(aud, audiences))); + } + decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators)); + + JwtAuthenticationProvider provider = new JwtAuthenticationProvider(decoder); + provider.setJwtAuthenticationConverter(azureJwtAuthenticationConverter); + return provider::authenticate; + } + + @Bean + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + JwtIssuerAuthenticationManagerResolver jwtIssuerAuthenticationManagerResolver) throws Exception { http .authorizeHttpRequests(authz -> { // Allow public endpoints from security properties @@ -49,12 +103,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti } authz.anyRequest().access(policyAuthorizationManager); }) - .oauth2ResourceServer((oauth2) -> - oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(azureJwtAuthenticationConverter)) + .oauth2ResourceServer(oauth2 -> + oauth2.authenticationManagerResolver(jwtIssuerAuthenticationManagerResolver) ) .headers(headers -> headers.frameOptions(frame -> frame.disable())) .csrf(csrf -> csrf.disable()); + // Body-caching filter must run before AuthorizationFilter so that + // PolicyAuthorizationManager can read the request body for policy evaluation, + // and the CachedBodyHttpServletRequest wrapper propagates to later servlet + // filters (including BypassRoutingFilter) for transparent body replay. http.addFilterBefore(cachedBodyRequestFilter, AuthorizationFilter.class); return http.build(); diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java index e9957240..f15e7499 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/config/SecurityProperties.java @@ -5,6 +5,9 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; + @Getter @Setter @Component @@ -14,7 +17,29 @@ public class SecurityProperties { private boolean enabled = true; /** - * Map of endpoints that don't require authentication + * Endpoints that don't require authentication. */ private String[] publicEndpoints; + + /** + * Audience values accepted by this API. A valid token must carry at least one of these. + * Applies to every configured issuer. + */ + private List audiences = new ArrayList<>(); + + /** + * Trusted issuers. Each entry maps an issuer URI to a JWK set URI used for + * signature validation. The {@code iss} claim of the incoming token selects + * which entry is used. + */ + private List issuers = new ArrayList<>(); + + @Getter + @Setter + public static class IssuerConfig { + /** Expected value of the {@code iss} claim. */ + private String issuerUri; + /** URL of the JWK set used to verify token signatures for this issuer. */ + private String jwkSetUri; + } } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java index 531577ca..6a5082d9 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidator.java @@ -15,8 +15,10 @@ public AuthType getSupportedFlow() { @Override public boolean validate(Jwt jwt) { + // v1 tokens use "appid", v2 tokens use "azp"; either is acceptable String appid = jwt.getClaimAsString("appid"); - if (appid == null || appid.isBlank()) { + String azp = jwt.getClaimAsString("azp"); + if ((appid == null || appid.isBlank()) && (azp == null || azp.isBlank())) { return false; } diff --git a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java index 3c903192..64c4ddf9 100644 --- a/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java +++ b/core-security/src/main/java/org/opendevstack/apiservice/core/security/jwt/JwtUtils.java @@ -4,6 +4,9 @@ import org.springframework.security.oauth2.jwt.Jwt; import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; import java.util.UUID; public final class JwtUtils { @@ -65,4 +68,73 @@ public static UUID extractClientId(Jwt jwt) { } return UUID.fromString(clientId); } + + public static List extractAudiences(Jwt jwt) { + List audiences = jwt.getAudience(); + if (audiences == null || audiences.isEmpty()) { + throw new InvalidBearerTokenException("Audience not found in token claims"); + } + return audiences; + } + + + public static List getAudiences() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractAudiences(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Determines whether the current SecurityContext JWT already targets the configured bypass + * audience and scope, meaning the incoming token can be forwarded as-is without an OBO exchange. + * + * @param bypassAudience the configured bypass audience that must be present in the token {@code aud} claim + * @param bypassScope the configured bypass scope that must be present in the token {@code scp} claim + * @return {@code true} only when both the bypass audience and scope match the token claims + */ + public static boolean tokenMatchesScopeAudience(String bypassAudience, String bypassScope) { + if (bypassAudience == null || bypassAudience.isBlank() + || bypassScope == null || bypassScope.isBlank()) { + return false; + } + Set tokenAudiences = new LinkedHashSet<>(getAudiences()); + if (!tokenAudiences.contains(bypassAudience)) { + return false; + } + return getScopes().contains(bypassScope); + } + + /** + * Extracts the delegated scopes from the current SecurityContext JWT {@code scp} claim. + * + * @return the set of scopes, empty when the {@code scp} claim is absent + * @throws InvalidBearerTokenException if the principal is not a JWT + */ + public static Set getScopes() { + Object principal = currentPrincipal(); + if (principal instanceof Jwt jwt) { + return extractScopes(jwt); + } + throw new InvalidBearerTokenException("Invalid authentication token"); + } + + /** + * Extracts the delegated scopes from the given JWT {@code scp} claim. + * Entra ID exposes delegated permissions as a space-separated {@code scp} claim; + * there is no dedicated accessor for it in Spring's {@code JwtClaimAccessor}. + */ + public static Set extractScopes(Jwt jwt) { + String scp = jwt.getClaimAsString("scp"); + Set scopes = new LinkedHashSet<>(); + if (scp != null && !scp.isBlank()) { + for (String scope : scp.trim().split("\\s+")) { + if (!scope.isBlank()) { + scopes.add(scope); + } + } + } + return scopes; + } } diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java index 5a49c23d..48f4a2a0 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/config/SecurityConfigTest.java @@ -6,8 +6,11 @@ import org.opendevstack.apiservice.core.security.filter.CachedBodyRequestFilter; import org.opendevstack.apiservice.core.security.jwt.AzureJwtAuthenticationConverter; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.oauth2.server.resource.authentication.JwtIssuerAuthenticationManagerResolver; import org.springframework.security.web.SecurityFilterChain; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; @@ -19,6 +22,7 @@ class SecurityConfigTest { private PolicyAuthorizationManager policyAuthorizationManager; private AzureJwtAuthenticationConverter azureJwtAuthenticationConverter; private CachedBodyRequestFilter cachedBodyRequestFilter; + private JwtIssuerAuthenticationManagerResolver resolver; private SecurityConfig securityConfig; @BeforeEach @@ -27,6 +31,7 @@ void setUp() { policyAuthorizationManager = mock(PolicyAuthorizationManager.class); azureJwtAuthenticationConverter = new AzureJwtAuthenticationConverter(); cachedBodyRequestFilter = mock(CachedBodyRequestFilter.class); + resolver = mock(JwtIssuerAuthenticationManagerResolver.class); securityConfig = new SecurityConfig( securityProperties, policyAuthorizationManager, @@ -40,7 +45,7 @@ void securityFilterChain_withPublicEndpoints() throws Exception { when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{"/health", "/info"}); HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - SecurityFilterChain chain = securityConfig.securityFilterChain(http); + SecurityFilterChain chain = securityConfig.securityFilterChain(http, resolver); assertNotNull(chain); } @@ -49,7 +54,7 @@ void securityFilterChain_withNullPublicEndpoints() throws Exception { when(securityProperties.getPublicEndpoints()).thenReturn(null); HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - SecurityFilterChain chain = securityConfig.securityFilterChain(http); + SecurityFilterChain chain = securityConfig.securityFilterChain(http, resolver); assertNotNull(chain); } @@ -58,7 +63,24 @@ void securityFilterChain_withEmptyPublicEndpoints() throws Exception { when(securityProperties.getPublicEndpoints()).thenReturn(new String[]{}); HttpSecurity http = mock(HttpSecurity.class, RETURNS_DEEP_STUBS); - SecurityFilterChain chain = securityConfig.securityFilterChain(http); + SecurityFilterChain chain = securityConfig.securityFilterChain(http, resolver); assertNotNull(chain); } + + @Test + void jwtIssuerAuthenticationManagerResolver_registersConfiguredIssuers() { + SecurityProperties.IssuerConfig v1 = new SecurityProperties.IssuerConfig(); + v1.setIssuerUri("https://sts.windows.net/tenant/"); + v1.setJwkSetUri("https://login.microsoftonline.com/common/discovery/keys"); + + SecurityProperties.IssuerConfig v2 = new SecurityProperties.IssuerConfig(); + v2.setIssuerUri("https://login.microsoftonline.com/tenant/v2.0"); + v2.setJwkSetUri("https://login.microsoftonline.com/tenant/discovery/v2.0/keys"); + + when(securityProperties.getIssuers()).thenReturn(List.of(v1, v2)); + when(securityProperties.getAudiences()).thenReturn(List.of("api://my-app")); + + JwtIssuerAuthenticationManagerResolver built = securityConfig.jwtIssuerAuthenticationManagerResolver(); + assertNotNull(built); + } } diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java new file mode 100644 index 00000000..1cc0b3c4 --- /dev/null +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/flow/validator/ClientCredentialFlowValidatorTest.java @@ -0,0 +1,139 @@ +package org.opendevstack.apiservice.core.security.flow.validator; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.oauth2.jwt.Jwt; + +import java.time.Instant; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ClientCredentialFlowValidatorTest { + + private ClientCredentialFlowValidator validator; + + @BeforeEach + void setUp() { + validator = new ClientCredentialFlowValidator(); + } + + // --- v1 token (appid) --- + + @Test + void validate_v1Token_valid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "oid", "same-as-sub" + ), "same-as-sub"); + assertTrue(validator.validate(jwt)); + } + + @Test + void validate_v1Token_withScp_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "scp", "api.read", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v1Token_withUpn_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "upn", "user@example.com", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v1Token_subNotEqualOid_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "aud", "api://my-app", + "oid", "different-oid" + ), "subject"); + assertFalse(validator.validate(jwt)); + } + + // --- v2 token (azp, no appid) --- + + @Test + void validate_v2Token_valid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "oid", "same-as-sub" + ), "same-as-sub"); + assertTrue(validator.validate(jwt)); + } + + @Test + void validate_v2Token_withScp_invalid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "scp", "api.read", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v2Token_withPreferredUsername_invalid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "preferred_username", "user@example.com", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_v2Token_subNotEqualOid_invalid() { + Jwt jwt = buildJwt(Map.of( + "azp", "client-id-v2", + "aud", "api://my-app", + "oid", "different-oid" + ), "subject"); + assertFalse(validator.validate(jwt)); + } + + // --- no client ID at all --- + + @Test + void validate_noClientIdClaim_invalid() { + Jwt jwt = buildJwt(Map.of( + "aud", "api://my-app", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + @Test + void validate_missingAud_invalid() { + Jwt jwt = buildJwt(Map.of( + "appid", "client-id-v1", + "oid", "same-as-sub" + ), "same-as-sub"); + assertFalse(validator.validate(jwt)); + } + + private Jwt buildJwt(Map extraClaims, String subject) { + return Jwt.withTokenValue("token") + .header("alg", "RS256") + .claim("sub", subject) + .claims(c -> c.putAll(extraClaims)) + .issuedAt(Instant.now()) + .expiresAt(Instant.now().plusSeconds(300)) + .build(); + } +} diff --git a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java index 76903b44..386edd91 100644 --- a/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java +++ b/core-security/src/test/java/org/opendevstack/apiservice/core/security/jwt/JwtUtilsTest.java @@ -9,11 +9,14 @@ import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -114,6 +117,134 @@ void extract_client_id_from_jwt_with_appid_fallback() { assertEquals(UUID.fromString(clientId), result); } + @Test + void extract_audiences_supports_single_string_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("aud", "aud-1")); + + // WHEN + List audiences = JwtUtils.extractAudiences(jwt); + + // THEN + assertEquals(List.of("aud-1"), audiences); + } + + @Test + void extract_audiences_supports_list_claim() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("aud", List.of("aud-1", "aud-2"))); + + // WHEN + List audiences = JwtUtils.extractAudiences(jwt); + + // THEN + assertEquals(List.of("aud-1", "aud-2"), audiences); + } + + @Test + void token_matches_scope_audience_when_audience_and_scope_match_bypass() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "Api.Access")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertTrue(result); + } + + @Test + void token_matches_scope_audience_when_scope_is_one_of_several_scp_values() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "api://6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "User.Read Api.Access Files.Read")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "api://6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertTrue(result); + } + + @Test + void token_matches_scope_audience_returns_false_when_audience_does_not_match() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "api://another-app", + "scp", "Api.Access")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertFalse(result); + } + + @Test + void token_matches_scope_audience_returns_false_when_scope_does_not_match() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "User.Read")); + setSecurityContext(jwt); + + // WHEN + boolean result = JwtUtils.tokenMatchesScopeAudience( + "6d81fd84-9b38-4aed-9403-ed95f6395cd7", "Api.Access"); + + // THEN + assertFalse(result); + } + + @Test + void token_matches_scope_audience_returns_false_when_bypass_values_are_blank() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of( + "aud", "6d81fd84-9b38-4aed-9403-ed95f6395cd7", + "scp", "Api.Access")); + setSecurityContext(jwt); + + // WHEN / THEN + assertFalse(JwtUtils.tokenMatchesScopeAudience("", "Api.Access")); + assertFalse(JwtUtils.tokenMatchesScopeAudience("6d81fd84-9b38-4aed-9403-ed95f6395cd7", "")); + assertFalse(JwtUtils.tokenMatchesScopeAudience(null, null)); + } + + @Test + void extract_scopes_splits_scp_claim_by_whitespace() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("scp", "User.Read Api.Access")); + + // WHEN + var scopes = JwtUtils.extractScopes(jwt); + + // THEN + assertTrue(scopes.contains("User.Read")); + assertTrue(scopes.contains("Api.Access")); + assertEquals(2, scopes.size()); + } + + @Test + void extract_scopes_returns_empty_set_when_scp_claim_absent() { + // GIVEN + Jwt jwt = buildJwt("token", Map.of("aud", "some-audience")); + + // WHEN + var scopes = JwtUtils.extractScopes(jwt); + + // THEN + assertTrue(scopes.isEmpty()); + } + private Jwt buildJwt(String tokenValue, Map claims) { Jwt.Builder builder = Jwt.withTokenValue(tokenValue) .header("alg", "RS256") diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java index c0645b03..5f0302fb 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/config/MarketplaceInstanceConfig.java @@ -50,4 +50,27 @@ public class MarketplaceInstanceConfig { * Example: {@code api:///Api.Access} */ private String oboScope; + + /** + * Bypass configuration. When the incoming token already targets the configured bypass + * audience and scope, the OBO token exchange is skipped and the token is forwarded as-is. + */ + private Bypass bypass = new Bypass(); + + /** + * Audience and scope used to decide whether the incoming token can be forwarded without + * an OBO exchange. + */ + @Data + public static class Bypass { + /** + * Audience that must be present in the token {@code aud} claim to allow the bypass. + */ + private String audience; + + /** + * Scope that must be present in the token {@code scp} claim to allow the bypass. + */ + private String scope; + } } \ No newline at end of file diff --git a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java index 87b41226..74464ae7 100644 --- a/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java +++ b/external-service-marketplace/src/main/java/org/opendevstack/apiservice/externalservice/marketplace/service/impl/MarketplaceServiceImpl.java @@ -346,14 +346,23 @@ public boolean hasInstance(String instanceName) { private MarketplaceApiClient getOboAuthenticatedClient(String instanceName) throws MarketplaceException { MarketplaceApiClient client = clientFactory.getClient(instanceName); String oboScope = client.getConfig().getOboScope(); + String assertion = JwtUtils.getTokenValue(); if (oboScope == null || oboScope.isBlank()) { throw new MarketplaceException( String.format("OBO scope not configured for Marketplace instance '%s'", instanceName)); } - String assertion = JwtUtils.getTokenValue(); - final String oboToken; + MarketplaceInstanceConfig.Bypass bypass = client.getConfig().getBypass(); + if (bypass != null && JwtUtils.tokenMatchesScopeAudience(bypass.getAudience(), bypass.getScope())) { + client.setBearerToken(assertion); + } else { + client.setBearerToken(getOboToken(instanceName, assertion, oboScope)); + } + return client; + } + + private String getOboToken(String instanceName, String assertion, String oboScope) throws MarketplaceException { try { - oboToken = oboTokenService.exchangeToken(assertion, oboScope); + return oboTokenService.exchangeToken(assertion, oboScope); } catch (RuntimeException ex) { throw new MarketplaceException( String.format( @@ -361,7 +370,5 @@ private MarketplaceApiClient getOboAuthenticatedClient(String instanceName) thro instanceName, oboScope), ex); } - client.setBearerToken(oboToken); - return client; } } diff --git a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java index 69a5e0a9..417cb8f8 100644 --- a/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java +++ b/external-service-marketplace/src/test/java/org/opendevstack/apiservice/externalservice/marketplace/service/MarketplaceServiceImplTest.java @@ -88,6 +88,7 @@ void setUp() { Jwt jwt = Jwt.withTokenValue("test-jwt-assertion") .header("alg", "RS256") .claim("azp", "test-client-id") + .claim("aud", "api://different-app") .issuedAt(Instant.now()) .expiresAt(Instant.now().plusSeconds(3600)) .build(); From de88c035bae864fbfc89dfcc5ff99a0fc8e03cf6 Mon Sep 17 00:00:00 2001 From: Angel MP Date: Tue, 23 Jun 2026 16:22:26 +0200 Subject: [PATCH 26/27] Fix typo in httpStatus field in API schema and response mapper (#38) --- api-project/openapi/api-project.yaml | 2 +- .../project/mapper/ProjectCreationResponseMapper.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 9a02630d..14a63993 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -166,7 +166,7 @@ components: properties: projectKey: type: string - httStatus: + httpStatus: type: string status: type: string diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java index 0eb9a531..1f13f358 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/mapper/ProjectCreationResponseMapper.java @@ -11,7 +11,7 @@ public interface ProjectCreationResponseMapper { @Mapping(target = "message", constant = "The project creation process has been successfully initiated.") @Mapping(target = "status", ignore = true) - @Mapping(target = "httStatus", constant = "OK") + @Mapping(target = "httpStatus", constant = "OK") @Mapping(target = "errorKey", constant = "000") @Mapping(target = "projectKey", source = "project.projectKey") @Mapping(target = "projectFlavor", ignore = true) From 836641220c0af7cfb808bcada1b04892b2d82b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Mart=C3=ADn?= Date: Tue, 23 Jun 2026 18:14:02 +0200 Subject: [PATCH 27/27] Feature/update project status endpoint (#39) --- api-project/openapi/api-project-internal.yaml | 63 ++++++++++++++ api-project/openapi/api-project.yaml | 2 +- api-project/pom.xml | 29 +++++++ .../project/controller/ProjectController.java | 2 +- .../controller/ProjectInternalController.java | 50 +++++++++++ .../ProjectInternalExceptionHandler.java | 22 +++++ .../project/exception/ErrorKey.java | 3 +- .../project/exception/ErrorMessage.java | 1 + .../ProjectUpdateValidationException.java | 19 ++++ .../project/facade/ProjectsFacade.java | 3 + .../facade/impl/ProjectsFacadeImpl.java | 6 ++ .../validation/ProjectRequestValidator.java | 26 +++++- .../controller/ProjectControllerTest.java | 2 +- .../ProjectInternalControllerTest.java | 86 +++++++++++++++++++ .../ProjectInternalExceptionHandlerTest.java | 53 ++++++++++++ .../facade/impl/ProjectsFacadeImplTest.java | 17 ++++ .../ProjectRequestValidatorTest.java | 86 +++++++++++++++---- .../006_add_projects_internal_api.sql | 12 +++ .../service/ProjectService.java | 2 + .../service/impl/ProjectServiceImpl.java | 11 +++ .../service/impl/ProjectServiceImplTest.java | 45 +++++++++- 21 files changed, 516 insertions(+), 24 deletions(-) create mode 100644 api-project/openapi/api-project-internal.yaml create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java create mode 100644 api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java create mode 100644 api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java create mode 100644 persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql diff --git a/api-project/openapi/api-project-internal.yaml b/api-project/openapi/api-project-internal.yaml new file mode 100644 index 00000000..74a1bb1b --- /dev/null +++ b/api-project/openapi/api-project-internal.yaml @@ -0,0 +1,63 @@ +openapi: 3.0.3 +info: + title: ODS API Server + description: API documentation for ODS (Open DevStack) API Service + contact: + name: ODS Team + version: v0.0.1 +servers: + - url: http://{baseurl}/api/pub/v0 + variables: + baseurl: + default: localhost:8080 + description: Development environment +tags: +- name: Projects Internal + description: API for manage EDP projects with extended methods +paths: + /projects/{projectKey}: + patch: + tags: + - Projects Internal + summary: Updates projects by project key. + description: Updates the current status and details of the project identified by the given project key. + operationId: updateProject + parameters: + - name: projectKey + in: path + required: true + schema: + type: string + description: Project key to retrieve information. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProjectRequest' + responses: + '204': + description: Project information updated successfully or no project found for the provided projectKey. + "400": + description: Bad Request. + "401": + description: Invalid client token on the request. + "403": + description: Insufficient permissions for the client to access the resource. + '500': + description: Internal server error. +components: + schemas: + RestErrorMessage: + properties: + message: + type: string + required: + - message + UpdateProjectRequest: + type: object + properties: + status: + type: string + description: Status of the project. If not in the allowed list, error INVALID_STATUS (030) is returned. + additionalProperties: false \ No newline at end of file diff --git a/api-project/openapi/api-project.yaml b/api-project/openapi/api-project.yaml index 14a63993..07cbb7e5 100644 --- a/api-project/openapi/api-project.yaml +++ b/api-project/openapi/api-project.yaml @@ -12,7 +12,7 @@ servers: default: localhost:8080 description: Development environment tags: -- name: Project +- name: Projects description: API for manage EDP projects. paths: /projects: diff --git a/api-project/pom.xml b/api-project/pom.xml index 7172641e..799043f6 100644 --- a/api-project/pom.xml +++ b/api-project/pom.xml @@ -168,6 +168,35 @@ + + generate-api-project-extended + + generate + + + spring + ${project.basedir} + spring-boot + ${project.basedir}/openapi/api-project-internal.yaml + org.opendevstack.apiservice.project.api + org.opendevstack.apiservice.project.model + org.opendevstack.apiservice.project + false + false + false + false + false + false + + true + true + springdoc + true + true + true + + + diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java index 54c33275..beea15b4 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectController.java @@ -37,7 +37,7 @@ public class ProjectController implements ProjectsApi { @PostMapping @Override public ResponseEntity createProject(@Valid @RequestBody CreateProjectRequest createProjectRequest) { - projectRequestValidator.validate(createProjectRequest); + projectRequestValidator.validateCreateRequest(createProjectRequest); UUID clientId = JwtUtils.getClientId(); CreateProjectResponse projectResponse = projectsFacade.createProject(createProjectRequest, clientId); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java new file mode 100644 index 00000000..af68367e --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/ProjectInternalController.java @@ -0,0 +1,50 @@ +package org.opendevstack.apiservice.project.controller; + +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.core.security.jwt.JwtUtils; +import org.opendevstack.apiservice.project.api.ProjectsInternalApi; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping(ProjectInternalController.API_BASE_PATH) +@AllArgsConstructor +@Slf4j +public class ProjectInternalController implements ProjectsInternalApi { + + public static final String API_BASE_PATH = "/api/pub/v1/projects"; + + private static final String HTTP_HEADER_LOCATION = "Location"; + + private final ProjectsFacade projectsFacade; + + private final ProjectRequestValidator projectRequestValidator; + + @PatchMapping("/{projectKey}") + @Override + public ResponseEntity updateProject(@PathVariable String projectKey, + @Valid @RequestBody UpdateProjectRequest updateProjectRequest) { + String location = API_BASE_PATH + "/" + projectKey; + projectRequestValidator.validateUpdateRequest(updateProjectRequest); + + projectsFacade.updateProject(projectKey, updateProjectRequest); + + return ResponseEntity + .status(HttpStatus.NO_CONTENT) + .header(HTTP_HEADER_LOCATION, location) + .build(); + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java new file mode 100644 index 00000000..ab8faab9 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandler.java @@ -0,0 +1,22 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import lombok.extern.slf4j.Slf4j; +import org.opendevstack.apiservice.project.controller.ProjectInternalController; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice(assignableTypes = ProjectInternalController.class) +@Slf4j +public class ProjectInternalExceptionHandler { + + @ExceptionHandler(ProjectUpdateValidationException.class) + public ResponseEntity handleUpdateValidationException(ProjectUpdateValidationException ex) { + log.warn("Validation error: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + + +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java index 2b1718a0..a7755a78 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorKey.java @@ -31,7 +31,8 @@ public enum ErrorKey { PROJECT_SAME_PROJECT_NAME_ALREADY_EXISTS("026", ErrorMessage.PROJECT_WITH_SAME_PROJECT_NAME_ALREADY_EXISTS), CLIENT_APP_NOT_REGISTERED("027", ErrorMessage.CLIENT_APP_NOT_REGISTERED_MANUAL_REGISTRATION_REQUIRED), INVALID_PROJECT_FLAVOR("028", ErrorMessage.BAD_REQUEST), - INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST); + INVALID_CONFIG_ITEM("029", ErrorMessage.BAD_REQUEST), + INVALID_STATUS("030", ErrorMessage.INVALID_STATUS); private String key; private String message; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java index 5547bbdd..8c8ed276 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ErrorMessage.java @@ -9,6 +9,7 @@ public class ErrorMessage { public static final String SUCCESS = "Success"; public static final String INVALID_LOCATION = "Incorrect location. Valid locations are:"; + public static final String INVALID_STATUS = "Incorrect status. Valid status are:"; public static final String RECORD_ALREADY_EXISTS = "Record already exists"; public static final String PROJECT_KEY_NOT_MET_THE_PATTERN = "projectKey not met the pattern ^[A-Z] {2}[A-Z0-9] {1,8}$"; public static final String PROJECT_NAME_NOT_MET_THE_PATTERN = "projectName not met the pattern ^[A-Za-z0-9 ] {0,80}$"; diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java new file mode 100644 index 00000000..56f0b5b2 --- /dev/null +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/exception/ProjectUpdateValidationException.java @@ -0,0 +1,19 @@ +package org.opendevstack.apiservice.project.exception; + +import lombok.Getter; + +@Getter +public class ProjectUpdateValidationException extends RuntimeException { + + private final ErrorKey errorKey; + + public ProjectUpdateValidationException(ErrorKey errorKey) { + super(errorKey.getMessage()); + this.errorKey = errorKey; + } + + public ProjectUpdateValidationException(ErrorKey errorKey, String additionalMessage) { + super(errorKey.getMessage() + " " + additionalMessage); + this.errorKey = errorKey; + } +} diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java index f8b0112e..f36aa8dc 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/ProjectsFacade.java @@ -2,6 +2,7 @@ import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; import java.util.UUID; @@ -10,4 +11,6 @@ public interface ProjectsFacade { CreateProjectResponse createProject(CreateProjectRequest request, UUID clientId); CreateProjectResponse getProject(String projectKey); + + void updateProject(String projectKey, UpdateProjectRequest request); } \ No newline at end of file diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java index 8e3b80d6..afa845df 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImpl.java @@ -11,6 +11,7 @@ import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; @@ -84,6 +85,11 @@ public CreateProjectResponse getProject(String projectKey) { return projectMapper.toApiResponse(projectService.getProject(projectKey)); } + @Override + public void updateProject(String projectKey, UpdateProjectRequest request) { + projectService.updateProjectStatus(projectKey, request.getStatus()); + } + private ProjectResponse registerProject(ProjectCreationCommand command) { ProjectRequest projectRequest = projectMapper.toServiceRequest(command); diff --git a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java index f3ea98e7..c96d32f9 100644 --- a/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java +++ b/api-project/src/main/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidator.java @@ -1,11 +1,15 @@ package org.opendevstack.apiservice.project.validation; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.serviceproject.model.Status; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import java.util.Arrays; import java.util.List; @Component @@ -14,11 +18,15 @@ public class ProjectRequestValidator { @Value("${apis.projects.locations}") private List locations; - public void validate(CreateProjectRequest request) { + public void validateCreateRequest(CreateProjectRequest request) { validateFlavorOrConfigItem(request); validateOwnerIfX2AccountIsPresent(request); validateLocation(request); } + + public void validateUpdateRequest(UpdateProjectRequest request) { + validateStatus(request); + } private void validateFlavorOrConfigItem(CreateProjectRequest request) { String projectFlavor = request.getProjectFlavor(); @@ -60,4 +68,20 @@ private void validateLocation(CreateProjectRequest request) { String.join(", ", locations)); } } + + private void validateStatus(UpdateProjectRequest request) { + String status = request.getStatus(); + + if (status == null || status.trim().isEmpty()) { + return; + } + + List statusList = Arrays.stream(Status.values()) + .map(e -> e.getDbValue()) + .toList(); + if (!statusList.contains(status)) { + throw new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS, + String.join(", ", statusList)); + } + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java index d184ef9c..2e1d53f8 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectControllerTest.java @@ -75,7 +75,7 @@ void create_project_returns_ok_when_creation_succeeds() { assertThat(result.getBody().getError()).isNull(); assertThat(result.getBody().getErrorKey()).isEqualTo("000"); assertThat(result.getBody().getErrorDescription()).isNull(); - verify(projectRequestValidator).validate(request); + verify(projectRequestValidator).validateCreateRequest(request); verify(projectsFacade).createProject(any(CreateProjectRequest.class), any(UUID.class)); } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java new file mode 100644 index 00000000..efcfc848 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/ProjectInternalControllerTest.java @@ -0,0 +1,86 @@ +package org.opendevstack.apiservice.project.controller; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectValidationException; +import org.opendevstack.apiservice.project.facade.ProjectsFacade; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; +import org.opendevstack.apiservice.project.validation.ProjectRequestValidator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ProjectInternalControllerTest { + + @Mock + private ProjectsFacade projectsFacade; + + @Mock + private ProjectRequestValidator projectRequestValidator; + + private ProjectInternalController sut; + private AutoCloseable mocks; + + @BeforeEach + void setup() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectInternalController(projectsFacade, projectRequestValidator); + + Jwt jwtToken = Jwt.withTokenValue("dummy-token") + .claim("appid", UUID.randomUUID().toString()) + .claim("sub", "test-user") + .header("alg", "none") + .build(); + JwtAuthenticationToken authentication = new JwtAuthenticationToken(jwtToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void update_project_returns_no_content() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("Running"); + + ResponseEntity result = sut.updateProject("PROJ01", request); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(result.getBody()).isNull(); + assertThat(result.getHeaders().getFirst("Location")) + .isEqualTo(ProjectInternalController.API_BASE_PATH + "/PROJ01"); + verify(projectRequestValidator).validateUpdateRequest(request); + verify(projectsFacade).updateProject("PROJ01", request); + } + + @Test + void update_project_propagates_validation_exception_when_validator_throws() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("INVALID_STATUS"); + + doThrow(new ProjectValidationException(ErrorKey.INVALID_STATUS)) + .when(projectRequestValidator).validateUpdateRequest(request); + + assertThrows(ProjectValidationException.class, () -> sut.updateProject("PROJ01", request)); + + verify(projectRequestValidator).validateUpdateRequest(request); + verify(projectsFacade, never()).updateProject("PROJ01", request); + } +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java new file mode 100644 index 00000000..968c7670 --- /dev/null +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/controller/advice/ProjectInternalExceptionHandlerTest.java @@ -0,0 +1,53 @@ +package org.opendevstack.apiservice.project.controller.advice; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +public class ProjectInternalExceptionHandlerTest { + + private ProjectInternalExceptionHandler sut; + private AutoCloseable mocks; + + @BeforeEach + void setUp() { + mocks = MockitoAnnotations.openMocks(this); + sut = new ProjectInternalExceptionHandler(); + } + + @AfterEach + void tearDown() throws Exception { + mocks.close(); + } + + @Test + void handle_update_validation_exception_returns_bad_request_with_no_body() { + ProjectUpdateValidationException exception = + new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS); + + ResponseEntity result = sut.handleUpdateValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNull(result.getBody()); + } + + @Test + void handle_update_validation_exception_with_additional_message_returns_bad_request_with_no_body() { + ProjectUpdateValidationException exception = + new ProjectUpdateValidationException(ErrorKey.INVALID_STATUS, "Pending,Running,Failed"); + + ResponseEntity result = sut.handleUpdateValidationException(exception); + + assertEquals(HttpStatus.BAD_REQUEST, result.getStatusCode()); + assertNull(result.getBody()); + } + +} diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java index a81646cc..abf00d34 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/facade/impl/ProjectsFacadeImplTest.java @@ -14,6 +14,7 @@ import org.opendevstack.apiservice.project.mapper.ProjectMapper; import org.opendevstack.apiservice.project.model.CreateProjectRequest; import org.opendevstack.apiservice.project.model.CreateProjectResponse; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; import org.opendevstack.apiservice.project.service.ClientAppService; import org.opendevstack.apiservice.serviceproject.model.ProjectRequest; import org.opendevstack.apiservice.serviceproject.model.ProjectResponse; @@ -208,4 +209,20 @@ void get_project_returns_null_when_service_returns_null() { assertNull(result); } + + @Test + void update_project_calls_update_status() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("Running"); + ProjectResponse projectResponse = ProjectResponse.builder() + .projectKey("PROJ01") + .status(Status.PENDING) + .build(); + + when(projectService.getProject("PROJ01")).thenReturn(projectResponse); + + sut.updateProject("PROJ01", request); + + verify(projectService).updateProjectStatus("PROJ01", "Running"); + } } diff --git a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java index 596f39cd..e2fed8c2 100644 --- a/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java +++ b/api-project/src/test/java/org/opendevstack/apiservice/project/validation/ProjectRequestValidatorTest.java @@ -5,8 +5,10 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.opendevstack.apiservice.project.exception.ErrorKey; +import org.opendevstack.apiservice.project.exception.ProjectUpdateValidationException; import org.opendevstack.apiservice.project.exception.ProjectValidationException; import org.opendevstack.apiservice.project.model.CreateProjectRequest; +import org.opendevstack.apiservice.project.model.UpdateProjectRequest; import java.lang.reflect.Field; import java.util.List; @@ -30,7 +32,7 @@ void setUp() throws NoSuchFieldException, IllegalAccessException { } @Test - void validate_throws_exception_when_project_flavor_and_config_item_both_null() { + void validateCreateRequest_throws_exception_when_project_flavor_and_config_item_both_null() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid Name"); request.setProjectFlavor(null); @@ -38,14 +40,14 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_null() { ProjectValidationException exception = assertThrows( ProjectValidationException.class, - () -> sut.validate(request) + () -> sut.validateCreateRequest(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); } @Test - void validate_throws_exception_when_project_flavor_and_config_item_both_empty() { + void validateCreateRequest_throws_exception_when_project_flavor_and_config_item_both_empty() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid Name"); request.setProjectFlavor(""); @@ -53,7 +55,7 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() ProjectValidationException exception = assertThrows( ProjectValidationException.class, - () -> sut.validate(request) + () -> sut.validateCreateRequest(request) ); assertEquals(ErrorKey.BAD_REQUEST_FLAVOR_CONFIG_ITEM, exception.getErrorKey()); @@ -65,17 +67,17 @@ void validate_throws_exception_when_project_flavor_and_config_item_both_empty() "null, JIRA", "STANDARD, JIRA" }) - void validate_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { + void validateCreateRequest_succeeds_when_flavor_or_config_item_provided(String projectFlavor, String configurationItem) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid Name"); request.setProjectFlavor("null".equals(projectFlavor) ? null : projectFlavor); request.setConfigurationItem("null".equals(configurationItem) ? null : configurationItem); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); } @Test - void validate_throws_exception_when_x2account_present_but_owner_missing() { + void validateCreateRequest_throws_exception_when_x2account_present_but_owner_missing() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); @@ -83,7 +85,7 @@ void validate_throws_exception_when_x2account_present_but_owner_missing() { request.setOwner(null); ProjectValidationException exception = - assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + assertThrows(ProjectValidationException.class, () -> sut.validateCreateRequest(request)); assertEquals(ErrorKey.MANDATORY_OWNER, exception.getErrorKey()); } @@ -95,7 +97,7 @@ void validate_throws_exception_when_x2account_present_but_owner_missing() { "null, owner1", "x2valid, ownerX" }) - void validate_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { + void validateCreateRequest_succeeds_when_owner_present_or_x2account_missing(String x2, String owner) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); @@ -103,27 +105,27 @@ void validate_succeeds_when_owner_present_or_x2account_missing(String x2, String request.setX2OdsAccount("null".equals(x2) ? null : x2); request.setOwner(owner); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); } @Test - void validate_succeeds_when_location_is_null_or_empty() { + void validateCreateRequest_succeeds_when_location_is_null_or_empty() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); request.setLocation(null); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); request.setLocation(""); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); request.setLocation(" "); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); } @Test - void validate_throws_exception_when_location_not_in_allowed_list() { + void validateCreateRequest_throws_exception_when_location_not_in_allowed_list() { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); @@ -131,7 +133,7 @@ void validate_throws_exception_when_location_not_in_allowed_list() { request.setLocation("INVALID_LOCATION_NOT_ALLOWED"); ProjectValidationException exception = - assertThrows(ProjectValidationException.class, () -> sut.validate(request)); + assertThrows(ProjectValidationException.class, () -> sut.validateCreateRequest(request)); assertEquals(ErrorKey.INVALID_LOCATION, exception.getErrorKey()); } @@ -142,12 +144,60 @@ void validate_throws_exception_when_location_not_in_allowed_list() { "BARCELONA", "SANT_CUGAT" }) - void validate_succeeds_when_location_is_allowed(String location) { + void validateCreateRequest_succeeds_when_location_is_allowed(String location) { CreateProjectRequest request = new CreateProjectRequest(); request.setProjectName("Valid"); request.setProjectFlavor("STANDARD"); request.setLocation(location); - assertDoesNotThrow(() -> sut.validate(request)); + assertDoesNotThrow(() -> sut.validateCreateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_null() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(null); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_empty_string() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(""); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_succeeds_when_status_is_blank() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(" "); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @ParameterizedTest + @CsvSource({ + "Pending", + "Running", + "Failed" + }) + void validateUpdateRequest_succeeds_when_status_is_valid(String status) { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus(status); + + assertDoesNotThrow(() -> sut.validateUpdateRequest(request)); + } + + @Test + void validateUpdateRequest_throws_exception_with_error_key_INVALID_STATUS_when_status_is_invalid() { + UpdateProjectRequest request = new UpdateProjectRequest(); + request.setStatus("INVALID_STATUS"); + + ProjectUpdateValidationException exception = + assertThrows(ProjectUpdateValidationException.class, () -> sut.validateUpdateRequest(request)); + + assertEquals(ErrorKey.INVALID_STATUS, exception.getErrorKey()); } } \ No newline at end of file diff --git a/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql b/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql new file mode 100644 index 00000000..d5daa7de --- /dev/null +++ b/persistence/src/main/resources/db/changelog/migrations/006_add_projects_internal_api.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +-- ============================================================================ +-- changeset ods:009-add-project-v1-api-definition +-- Description: Seed API definitions for Project API v1. +-- ============================================================================ +INSERT INTO api_definitions (api_id, name, base_path, version, auth_types, is_public, enabled) +VALUES + ('project-v1', 'Project API v1', 'projects', 'v1', ARRAY['CLIENT_CREDENTIALS'], FALSE, TRUE) + ON CONFLICT (api_id) DO NOTHING; + +--rollback DELETE FROM api_definitions WHERE api_id IN ('project-v1'); \ No newline at end of file diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java index 3a23a8a4..896e7c6d 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/ProjectService.java @@ -10,6 +10,8 @@ public interface ProjectService { ProjectResponse saveProject(ProjectRequest request); ProjectResponse getProject(String projectKey); + + void updateProjectStatus(String projectKey, String status); List findProjectsByName(String projectName); } diff --git a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java index 7673925b..fe9b34e3 100644 --- a/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java +++ b/service-projects/src/main/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImpl.java @@ -62,6 +62,17 @@ public ProjectResponse getProject(String projectKey) { return null; } + @Override + public void updateProjectStatus(String projectKey, String status) { + Optional project = projectRepository.findByProjectKeyIgnoreCase(projectKey); + + if (project.isPresent()) { + ProjectEntity entity = project.get(); + entity.setStatus(status); + projectRepository.save(entity); + } + } + @Override public List findProjectsByName(String projectName) { List projects = projectRepository.findByProjectNameIgnoreCase(projectName); diff --git a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java index cadb492f..ca30c712 100644 --- a/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java +++ b/service-projects/src/test/java/org/opendevstack/apiservice/serviceproject/service/impl/ProjectServiceImplTest.java @@ -3,6 +3,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.MockitoAnnotations; import org.opendevstack.apiservice.persistence.entity.ProjectEntity; import org.opendevstack.apiservice.persistence.repository.ProjectRepository; @@ -189,4 +190,46 @@ void find_projects_by_name_returns_empty_list_when_project_does_not_exist() { verify(projectRepository).findByProjectNameIgnoreCase(projectName); verify(projectResponseMapper).toCreateProjectResponse(anyList()); } -} \ No newline at end of file + + @Test + void update_project_status_saves_entity_with_new_status_when_project_exists() { + String projectKey = "PROJ01"; + ProjectEntity entity = ProjectEntity.builder() + .id(UUID.randomUUID()) + .projectKey(projectKey) + .projectName("My Project") + .status("Pending") + .build(); + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.of(entity)); + + projectService.updateProjectStatus(projectKey, "Running"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ProjectEntity.class); + verify(projectRepository).save(captor.capture()); + assertEquals("Running", captor.getValue().getStatus()); + } + + @Test + void update_project_status_does_nothing_when_project_not_found() { + String projectKey = "UNKNOWN"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)).thenReturn(Optional.empty()); + + projectService.updateProjectStatus(projectKey, "Running"); + + verify(projectRepository, never()).save(any(ProjectEntity.class)); + } + + @Test + void update_project_status_propagates_repository_exception() { + String projectKey = "ERROR"; + + when(projectRepository.findByProjectKeyIgnoreCase(projectKey)) + .thenThrow(new RuntimeException("DB error")); + + assertThrows(RuntimeException.class, () -> projectService.updateProjectStatus(projectKey, "Running")); + + verify(projectRepository).findByProjectKeyIgnoreCase(projectKey); + } +}