diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b83c2b32ec5fe5eb22fabebbc025242009c84a46 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM openjdk:21-jdk-slim + +WORKDIR /app + +# Install required packages +RUN apt-get update && apt-get install -y \ + curl \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# Copy application files +COPY . . + +# Build application (if build.gradle.kts exists) +RUN if [ -f "build.gradle.kts" ]; then \ + ./gradlew build -x test; \ + fi + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run application +CMD ["java", "-jar", "build/libs/da-admin-service.jar"] diff --git a/README.md b/README.md index b8b32ed11cd407d5e00dc2c71173341a25ce173c..285aea855d67cb4ee9fc539af8c3804b46edced2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,38 @@ --- -title: Da Admin Service Dev -emoji: 🚀 -colorFrom: indigo -colorTo: red +title: da-admin-service (dev) +emoji: 🔧 +colorFrom: blue +colorTo: green sdk: docker -pinned: false +app_port: 8080 --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# da-admin-service - dev Environment + +This is the da-admin-service microservice deployed in the dev environment. + +## Features + +- RESTful API endpoints +- Health monitoring via Actuator +- JWT authentication integration +- PostgreSQL database connectivity + +## API Documentation + +Once deployed, API documentation will be available at: +- Swagger UI: https://huggingface.co/spaces/dalabsai/da-admin-service-dev/swagger-ui.html +- Health Check: https://huggingface.co/spaces/dalabsai/da-admin-service-dev/actuator/health + +## Environment + +- **Environment**: dev +- **Port**: 8080 +- **Java Version**: 21 +- **Framework**: Spring Boot + +## Deployment + +This service is automatically deployed via the DALab CI/CD pipeline. + +Last updated: 2025-06-16 23:39:48 diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000000000000000000000000000000000000..3be0c3ef67ab8bd122790608b5d95d5059dc964f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,70 @@ +plugins { + java + id("org.springframework.boot") version "3.2.5" + id("io.spring.dependency-management") version "1.1.4" +} + +group = "com.dalab" +version = "0.0.1-SNAPSHOT" + +java { + sourceCompatibility = JavaVersion.VERSION_21 +} + +configurations { + compileOnly { + extendsFrom(configurations.annotationProcessor.get()) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // da-protos common entities and utilities + implementation(project(":da-protos")) + + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.kafka:spring-kafka") + implementation("org.springframework.cloud:spring-cloud-starter-openfeign:4.1.1") // For Feign clients + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0") // OpenAPI + + // MapStruct for DTO mapping + implementation("org.mapstruct:mapstruct:1.5.5.Final") + annotationProcessor("org.mapstruct:mapstruct-processor:1.5.5.Final") + + // Lombok for reduced boilerplate + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") // If using Lombok with MapStruct + + // Hypersistence Utils for JSONB and other advanced types + implementation("io.hypersistence:hypersistence-utils-hibernate-62:3.7.0") // For Hibernate 6.2.x used by Spring Boot 3.x + + runtimeOnly("org.postgresql:postgresql") + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("org.springframework.security:spring-security-test") + + // Keycloak Admin Client (if direct Keycloak interaction is needed for users/roles) + implementation("org.keycloak:keycloak-admin-client:24.0.4") +} + +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:2023.0.1") + } +} + +tasks.withType { + useJUnitPlatform() +} + +tasks.named("bootJar") { + archiveFileName.set("${project.name}.jar") +} \ No newline at end of file diff --git a/src/main/docker/Dockerfile b/src/main/docker/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..1f2f6d9951db641b03968f2b8b5f5bd2dd288796 --- /dev/null +++ b/src/main/docker/Dockerfile @@ -0,0 +1,23 @@ +# Ultra-lean container using Google Distroless +# Expected final size: ~120-180MB (minimal base + JRE + JAR only) + +FROM gcr.io/distroless/java21-debian12:nonroot + +# Set working directory +WORKDIR /app + +# Copy JAR file +COPY build/libs/da-admin-service.jar app.jar + +# Expose standard Spring Boot port +EXPOSE 8080 + +# Run application (distroless has no shell, so use exec form) +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+UseG1GC", \ + "-XX:+UseStringDeduplication", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-Dspring.backgroundpreinitializer.ignore=true", \ + "-jar", "app.jar"] diff --git a/src/main/docker/Dockerfile.alpine-jlink b/src/main/docker/Dockerfile.alpine-jlink new file mode 100644 index 0000000000000000000000000000000000000000..ffb0b6cf87260a96fb83ac7a111d2fc905fc3e3b --- /dev/null +++ b/src/main/docker/Dockerfile.alpine-jlink @@ -0,0 +1,43 @@ +# Ultra-minimal Alpine + Custom JRE +# Expected size: ~120-160MB + +# Stage 1: Create custom JRE with only needed modules +FROM eclipse-temurin:21-jdk-alpine as jre-builder +WORKDIR /app + +# Analyze JAR to find required modules +COPY build/libs/*.jar app.jar +RUN jdeps --ignore-missing-deps --print-module-deps app.jar > modules.txt + +# Create minimal JRE with only required modules +RUN jlink \ + --add-modules $(cat modules.txt),java.logging,java.xml,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument \ + --strip-debug \ + --no-man-pages \ + --no-header-files \ + --compress=2 \ + --output /custom-jre + +# Stage 2: Production image +FROM alpine:3.19 +RUN apk add --no-cache tzdata && \ + addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Copy custom JRE +COPY --from=jre-builder /custom-jre /opt/java +ENV JAVA_HOME=/opt/java +ENV PATH="$JAVA_HOME/bin:$PATH" + +WORKDIR /app +COPY build/libs/*.jar app.jar +RUN chown appuser:appgroup app.jar + +USER appuser +EXPOSE 8080 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=70.0", \ + "-XX:+UseG1GC", \ + "-jar", "app.jar"] diff --git a/src/main/docker/Dockerfile.layered b/src/main/docker/Dockerfile.layered new file mode 100644 index 0000000000000000000000000000000000000000..59231523ddffaa9aa5ce1f62b1df71600bae6d7f --- /dev/null +++ b/src/main/docker/Dockerfile.layered @@ -0,0 +1,34 @@ +# Ultra-optimized layered build using Distroless +# Expected size: ~180-220MB with better caching + +FROM gcr.io/distroless/java21-debian12:nonroot as base + +# Stage 1: Extract JAR layers for optimal caching +FROM eclipse-temurin:21-jdk-alpine as extractor +WORKDIR /app +COPY build/libs/*.jar app.jar +RUN java -Djarmode=layertools -jar app.jar extract + +# Stage 2: Production image with extracted layers +FROM base +WORKDIR /app + +# Copy layers in dependency order (best caching) +COPY --from=extractor /app/dependencies/ ./ +COPY --from=extractor /app/spring-boot-loader/ ./ +COPY --from=extractor /app/snapshot-dependencies/ ./ +COPY --from=extractor /app/application/ ./ + +EXPOSE 8080 + +# Optimized JVM settings for micro-containers +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=70.0", \ + "-XX:+UseG1GC", \ + "-XX:+UseStringDeduplication", \ + "-XX:+CompactStrings", \ + "-Xshare:on", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-Dspring.backgroundpreinitializer.ignore=true", \ + "org.springframework.boot.loader.JarLauncher"] diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000000000000000000000000000000000000..5135bb6fe05c48308f7b3f756fec66c7525aa6a8 --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,20 @@ +# GraalVM Native Image - Ultra-fast startup, tiny size +# Expected size: ~50-80MB, startup <100ms +# Note: Requires native compilation support in Spring Boot + +# Stage 1: Native compilation +FROM ghcr.io/graalvm/graalvm-ce:ol9-java21 as native-builder +WORKDIR /app + +# Install native-image +RUN gu install native-image + +# Copy source and build native executable +COPY . . +RUN ./gradlew nativeCompile + +# Stage 2: Minimal runtime +FROM scratch +COPY --from=native-builder /app/build/native/nativeCompile/app /app +EXPOSE 8080 +ENTRYPOINT ["/app"] diff --git a/src/main/java/com/dalab/adminservice/DaAdminServiceApplication.java b/src/main/java/com/dalab/adminservice/DaAdminServiceApplication.java new file mode 100644 index 0000000000000000000000000000000000000000..c15649c9de8d493c06bf171b1fbe9349d384bf5f --- /dev/null +++ b/src/main/java/com/dalab/adminservice/DaAdminServiceApplication.java @@ -0,0 +1,15 @@ +package com.dalab.adminservice; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; + +@SpringBootApplication +@EnableFeignClients // Important for enabling Feign clients +public class DaAdminServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(DaAdminServiceApplication.class, args); + } + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/client/IAutoarchivalTaskApiClient.java b/src/main/java/com/dalab/adminservice/client/IAutoarchivalTaskApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..3ba5cbd690aa29e26ee89cadc85aa4a50ac7f29b --- /dev/null +++ b/src/main/java/com/dalab/adminservice/client/IAutoarchivalTaskApiClient.java @@ -0,0 +1,22 @@ +package com.dalab.adminservice.client; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +import com.dalab.adminservice.dto.JobStatusDTO; + +/** + * Feign client for Autoarchival Task API + */ +@FeignClient(name = "da-autoarchival", url = "${dalab.services.autoarchival.url:http://localhost:8086}") +public interface IAutoarchivalTaskApiClient { + + /** + * Get all archival tasks status + * @return List of job status DTOs + */ + @GetMapping("/api/v1/autoarchival/archival/tasks") + List getArchivalTasks(); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/client/IAutocomplianceJobApiClient.java b/src/main/java/com/dalab/adminservice/client/IAutocomplianceJobApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..1700979e48b8c19a206d2ce9fb1f69887b0b6760 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/client/IAutocomplianceJobApiClient.java @@ -0,0 +1,19 @@ +package com.dalab.adminservice.client; + +import com.dalab.adminservice.dto.JobStatusDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; + +@FeignClient(name = "autocompliance-service", url = "${feign.client.config.autocompliance-service.url:/api/v1/compliance}") // Placeholder URL +public interface IAutocomplianceJobApiClient { + + // Assuming an endpoint like /api/v1/compliance/reports/jobs exists or will be created + @GetMapping("/reports/jobs") + List getComplianceReportJobs(); + + @GetMapping("/reports/jobs/{jobId}") + JobStatusDTO getComplianceReportJobById(@PathVariable("jobId") String jobId); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/client/IAutodeleteTaskApiClient.java b/src/main/java/com/dalab/adminservice/client/IAutodeleteTaskApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..ab4eb2e1dfa34958e3150538dafe0a89766df513 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/client/IAutodeleteTaskApiClient.java @@ -0,0 +1,22 @@ +package com.dalab.adminservice.client; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +import com.dalab.adminservice.dto.JobStatusDTO; + +/** + * Feign client for Autodelete Task API + */ +@FeignClient(name = "da-autodelete", url = "${dalab.services.autodelete.url:http://localhost:8087}") +public interface IAutodeleteTaskApiClient { + + /** + * Get all deletion tasks status + * @return List of job status DTOs + */ + @GetMapping("/api/v1/autodelete/deletion/tasks") + List getDeletionTasks(); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/client/IAutolabelJobApiClient.java b/src/main/java/com/dalab/adminservice/client/IAutolabelJobApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..da7474b01f5bfc8f7e9bc32965e32d578238c4e7 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/client/IAutolabelJobApiClient.java @@ -0,0 +1,22 @@ +package com.dalab.adminservice.client; + +import java.util.List; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +import com.dalab.adminservice.dto.JobStatusDTO; + +/** + * Feign client for Autolabel Job API + */ +@FeignClient(name = "da-autolabel", url = "${dalab.services.autolabel.url:http://localhost:8085}") +public interface IAutolabelJobApiClient { + + /** + * Get all labeling jobs status + * @return List of job status DTOs + */ + @GetMapping("/api/v1/autolabel/labeling/jobs") + List getAutolabelJobs(); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/client/IDiscoveryJobApiClient.java b/src/main/java/com/dalab/adminservice/client/IDiscoveryJobApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..f01ec468430e7fd6daa8293031600d76f8ef49f1 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/client/IDiscoveryJobApiClient.java @@ -0,0 +1,23 @@ +package com.dalab.adminservice.client; + +import com.dalab.adminservice.dto.JobStatusDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; + +// The name "discovery-service" should match a Feign client configuration in application.properties +// e.g., feign.client.config.discovery-service.url=http://da-discovery-service-host:port/api/v1/discovery +// The path here is relative to the URL configured for the Feign client. +@FeignClient(name = "discovery-service", url = "${feign.client.config.discovery-service.url:/api/v1/discovery}") // Placeholder URL +public interface IDiscoveryJobApiClient { + + // Assuming da-discovery exposes an endpoint like /api/v1/discovery/jobs that returns a list of its jobs + // and that its Job DTO is compatible or can be mapped to our common JobStatusDTO + @GetMapping("/jobs") // This path is relative to the Feign client's configured URL + List getDiscoveryJobs(); + + @GetMapping("/jobs/{jobId}") + JobStatusDTO getDiscoveryJobById(@PathVariable("jobId") String jobId); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/config/KeycloakAdminClientConfig.java b/src/main/java/com/dalab/adminservice/config/KeycloakAdminClientConfig.java new file mode 100644 index 0000000000000000000000000000000000000000..633b5ed4e4ccbb7afe83d7277aa7f3c7b45024eb --- /dev/null +++ b/src/main/java/com/dalab/adminservice/config/KeycloakAdminClientConfig.java @@ -0,0 +1,58 @@ +package com.dalab.adminservice.config; + +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.KeycloakBuilder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class KeycloakAdminClientConfig { + + @Value("${keycloak.auth-server-url}") + private String serverUrl; + + @Value("${keycloak.realm}") + private String realm; + + @Value("${keycloak.client-id}") + private String clientId; + + @Value("${keycloak.client-secret:#{null}}") // Default to null if not provided + private String clientSecret; + + // Optional: If using password credentials grant type for admin client + @Value("${keycloak.admin.username:#{null}}") + private String adminUsername; + + @Value("${keycloak.admin.password:#{null}}") + private String adminPassword; + + @Bean + public Keycloak keycloakAdminClient() { + KeycloakBuilder builder = KeycloakBuilder.builder() + .serverUrl(serverUrl) + .realm(realm) + .clientId(clientId); + // ResteasyClient can be configured here if needed, e.g., for connection pooling + // .resteasyClient(ResteasyClientBuilder.newBuilder().build()); // Basic Resteasy client + + if (clientSecret != null && !clientSecret.isEmpty()) { + builder.clientSecret(clientSecret); + // Ensure the client (e.g., admin-cli or your dedicated client) has "Service Accounts Enabled" + // and appropriate "Service Account Roles" (e.g., realm-management -> manage-users, view-users) + builder.grantType("client_credentials"); + } else if (adminUsername != null && !adminUsername.isEmpty() && adminPassword != null && !adminPassword.isEmpty()){ + // This is typically for direct admin user login, ensure this user has necessary permissions. + // The 'admin-cli' client itself might not allow password grant for other users. + // Prefer service account with client_credentials for backend services. + builder.username(adminUsername); + builder.password(adminPassword); + builder.grantType("password"); + } else { + throw new IllegalStateException("Keycloak admin client not properly configured: Missing clientSecret or admin username/password."); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/config/SpringSecurityAuditorAware.java b/src/main/java/com/dalab/adminservice/config/SpringSecurityAuditorAware.java new file mode 100644 index 0000000000000000000000000000000000000000..0519ecba6ea913e21689ec692e81e9e4973fbf73 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/config/SpringSecurityAuditorAware.java @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/controller/CloudConnectionController.java b/src/main/java/com/dalab/adminservice/controller/CloudConnectionController.java new file mode 100644 index 0000000000000000000000000000000000000000..504d40d3db3cfaf1dffaaf7e7ef2e61fcf4cd5ed --- /dev/null +++ b/src/main/java/com/dalab/adminservice/controller/CloudConnectionController.java @@ -0,0 +1,97 @@ +package com.dalab.adminservice.controller; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dalab.adminservice.dto.CloudConnectionDTO; +import com.dalab.adminservice.dto.CloudConnectionTestResultDTO; +import com.dalab.adminservice.service.ICloudConnectionService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * REST controller for managing cloud connections + */ +@RestController +@RequestMapping("/api/v1/admin/cloud-connections") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Cloud Connection Management", description = "APIs for managing cloud connections") +public class CloudConnectionController { + + private final ICloudConnectionService cloudConnectionService; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get all cloud connections") + public ResponseEntity> getAllCloudConnections() { + log.debug("Getting all cloud connections"); + List connections = cloudConnectionService.getAllCloudConnections(); + return ResponseEntity.ok(connections); + } + + @GetMapping("/{connectionId}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get cloud connection by ID") + public ResponseEntity getCloudConnectionById(@PathVariable String connectionId) { + log.debug("Getting cloud connection with id: {}", connectionId); + Optional connection = cloudConnectionService.getCloudConnectionById(connectionId); + return connection.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create new cloud connection") + public ResponseEntity createCloudConnection(@Valid @RequestBody CloudConnectionDTO cloudConnectionDTO) { + log.debug("Creating new cloud connection: {}", cloudConnectionDTO.getName()); + CloudConnectionDTO created = cloudConnectionService.createCloudConnection(cloudConnectionDTO); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{connectionId}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update cloud connection") + public ResponseEntity updateCloudConnection( + @PathVariable String connectionId, + @Valid @RequestBody CloudConnectionDTO cloudConnectionDTO) { + log.debug("Updating cloud connection with id: {}", connectionId); + CloudConnectionDTO updated = cloudConnectionService.updateCloudConnection(connectionId, cloudConnectionDTO); + return ResponseEntity.ok(updated); + } + + @DeleteMapping("/{connectionId}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete cloud connection") + public ResponseEntity deleteCloudConnection(@PathVariable String connectionId) { + log.debug("Deleting cloud connection with id: {}", connectionId); + cloudConnectionService.deleteCloudConnection(connectionId); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/{connectionId}/test") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Test cloud connection") + public ResponseEntity testCloudConnection(@PathVariable String connectionId) { + log.debug("Testing cloud connection with id: {}", connectionId); + CloudConnectionTestResultDTO result = cloudConnectionService.testCloudConnection(connectionId); + return ResponseEntity.ok(result); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/controller/JobStatusController.java b/src/main/java/com/dalab/adminservice/controller/JobStatusController.java new file mode 100644 index 0000000000000000000000000000000000000000..fed3f1d6150859c9adbb8ddfc31114bbf9202a7f --- /dev/null +++ b/src/main/java/com/dalab/adminservice/controller/JobStatusController.java @@ -0,0 +1,35 @@ +package com.dalab.adminservice.controller; + +import com.dalab.adminservice.dto.AggregatedJobStatusDTO; +import com.dalab.adminservice.service.IJobStatusService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/admin/job-statuses") +@RequiredArgsConstructor +@Tag(name = "Job Status Management", description = "APIs for viewing aggregated job statuses across services.") +public class JobStatusController { + + private final IJobStatusService jobStatusService; + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN', 'VIEWER')") // Or more specific roles as needed + @Operation(summary = "Get Aggregated Job Statuses", + description = "Retrieves a list of all jobs from various microservices and their statuses.", + responses = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved job statuses"), + @ApiResponse(responseCode = "500", description = "Internal server error while fetching statuses") + }) + public ResponseEntity getAggregatedJobStatuses() { + AggregatedJobStatusDTO aggregatedStatus = jobStatusService.getAggregatedJobStatuses(); + return ResponseEntity.ok(aggregatedStatus); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/controller/RoleController.java b/src/main/java/com/dalab/adminservice/controller/RoleController.java new file mode 100644 index 0000000000000000000000000000000000000000..e311e6ca5d59580bc472de45df8c270e7ca376ee --- /dev/null +++ b/src/main/java/com/dalab/adminservice/controller/RoleController.java @@ -0,0 +1,33 @@ +package com.dalab.adminservice.controller; + +import com.dalab.adminservice.dto.RoleDTO; +import com.dalab.adminservice.service.IRoleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/roles") +@RequiredArgsConstructor +@Tag(name = "Role Management", description = "APIs for viewing available roles.") +@PreAuthorize("hasRole('ADMIN')") +public class RoleController { + + private final IRoleService roleService; + + @GetMapping + @Operation(summary = "Get all available realm roles", + description = "Retrieves a list of all available roles in the configured Keycloak realm.", + responses = @ApiResponse(responseCode = "200", description = "Successfully retrieved roles")) + public ResponseEntity> getAllRealmRoles() { + return ResponseEntity.ok(roleService.getAllRealmRoles()); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/controller/ServiceConfigController.java b/src/main/java/com/dalab/adminservice/controller/ServiceConfigController.java new file mode 100644 index 0000000000000000000000000000000000000000..faffd31374b143da13e06c8bd9c2b4ca4ba9df4d --- /dev/null +++ b/src/main/java/com/dalab/adminservice/controller/ServiceConfigController.java @@ -0,0 +1,76 @@ +package com.dalab.adminservice.controller; + +import java.util.List; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dalab.adminservice.dto.ServiceConfigDTO; +import com.dalab.adminservice.service.IServiceConfigService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * REST controller for managing service configurations + */ +@RestController +@RequestMapping("/api/v1/admin/config/services") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "Service Configuration Management", description = "APIs for managing service configurations") +public class ServiceConfigController { + + private final IServiceConfigService serviceConfigService; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get all service configurations") + public ResponseEntity> getAllServiceConfigs() { + log.debug("Getting all service configurations"); + List configs = serviceConfigService.getAllServiceConfigs(); + return ResponseEntity.ok(configs); + } + + @GetMapping("/{serviceId}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get service configuration by service ID") + public ResponseEntity getServiceConfigById(@PathVariable String serviceId) { + log.debug("Getting service configuration for service: {}", serviceId); + Optional config = serviceConfigService.getServiceConfigById(serviceId); + return config.map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create new service configuration") + public ResponseEntity createServiceConfig(@Valid @RequestBody ServiceConfigDTO serviceConfigDTO) { + log.debug("Creating new service configuration for service: {}", serviceConfigDTO.getServiceId()); + ServiceConfigDTO created = serviceConfigService.createServiceConfig(serviceConfigDTO); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @PutMapping("/{serviceId}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update service configuration") + public ResponseEntity updateServiceConfig( + @PathVariable String serviceId, + @Valid @RequestBody ServiceConfigDTO serviceConfigDTO) { + log.debug("Updating service configuration for service: {}", serviceId); + ServiceConfigDTO updated = serviceConfigService.updateServiceConfig(serviceId, serviceConfigDTO); + return ResponseEntity.ok(updated); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/controller/UserController.java b/src/main/java/com/dalab/adminservice/controller/UserController.java new file mode 100644 index 0000000000000000000000000000000000000000..3fe6985a4db0f2021bae82a717a7109fef9cefeb --- /dev/null +++ b/src/main/java/com/dalab/adminservice/controller/UserController.java @@ -0,0 +1,126 @@ +package com.dalab.adminservice.controller; + +import com.dalab.adminservice.dto.UserDTO; +import com.dalab.adminservice.service.IUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.keycloak.representations.idm.RoleRepresentation; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +@Tag(name = "User Management", description = "APIs for managing user accounts.") +@PreAuthorize("hasRole('ADMIN')") // All user management operations require ADMIN role +public class UserController { + + private final IUserService userService; + + @GetMapping + @Operation(summary = "Get all users", + description = "Retrieves a paginated list of all users.", + responses = @ApiResponse(responseCode = "200", description = "Successfully retrieved users")) + public ResponseEntity> getAllUsers( + @Parameter(description = "Offset for pagination") @RequestParam(required = false) Integer firstResult, + @Parameter(description = "Maximum number of results per page") @RequestParam(required = false) Integer maxResults) { + return ResponseEntity.ok(userService.getAllUsers(firstResult, maxResults)); + } + + @GetMapping("/{userId}") + @Operation(summary = "Get user by ID", + description = "Retrieves a specific user by their ID.", + responses = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved user"), + @ApiResponse(responseCode = "404", description = "User not found") + }) + public ResponseEntity getUserById( + @Parameter(description = "ID of the user to retrieve", example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") + @PathVariable String userId) { + return userService.getUserById(userId) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @GetMapping("/username/{username}") + @Operation(summary = "Get user by username", + description = "Retrieves a specific user by their username.", + responses = { + @ApiResponse(responseCode = "200", description = "Successfully retrieved user"), + @ApiResponse(responseCode = "404", description = "User not found") + }) + public ResponseEntity getUserByUsername( + @Parameter(description = "Username of the user to retrieve", example = "johndoe") + @PathVariable String username) { + return userService.getUserByUsername(username) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } + + @PostMapping + @Operation(summary = "Create a new user", + description = "Creates a new user account.", + responses = { + @ApiResponse(responseCode = "201", description = "User created successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data"), + @ApiResponse(responseCode = "409", description = "User already exists") + }) + public ResponseEntity createUser(@Valid @RequestBody UserDTO userDTO) { + UserDTO createdUser = userService.createUser(userDTO); + return ResponseEntity.status(HttpStatus.CREATED).body(createdUser); + } + + @PutMapping("/{userId}") + @Operation(summary = "Update an existing user", + description = "Updates an existing user account.", + responses = { + @ApiResponse(responseCode = "200", description = "User updated successfully"), + @ApiResponse(responseCode = "400", description = "Invalid input data"), + @ApiResponse(responseCode = "404", description = "User not found") + }) + public ResponseEntity updateUser( + @Parameter(description = "ID of the user to update") @PathVariable String userId, + @Valid @RequestBody UserDTO userDTO) { + UserDTO updatedUser = userService.updateUser(userId, userDTO); + return ResponseEntity.ok(updatedUser); + } + + @DeleteMapping("/{userId}") + @Operation(summary = "Delete a user", + description = "Deletes a user account by their ID.", + responses = { + @ApiResponse(responseCode = "204", description = "User deleted successfully"), + @ApiResponse(responseCode = "404", description = "User not found") + }) + public ResponseEntity deleteUser( + @Parameter(description = "ID of the user to delete") @PathVariable String userId) { + userService.deleteUser(userId); + return ResponseEntity.noContent().build(); + } + + // Role Management specific to a user might be better here than a top-level /roles endpoint if it's always user-centric + @GetMapping("/{userId}/roles") + @Operation(summary = "Get user's realm roles", + description = "Retrieves the realm roles assigned to a specific user.") + public ResponseEntity> getUserRealmRoles(@PathVariable String userId) { + return ResponseEntity.ok(userService.getUserRealmRoles(userId)); + } + + @PostMapping("/{userId}/roles") + @Operation(summary = "Assign realm roles to user", + description = "Assigns a list of realm roles to a specific user. Replaces existing roles if not additive.") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void assignRealmRolesToUser( + @PathVariable String userId, + @Parameter(description = "List of role names to assign") @RequestBody List roleNames) { + userService.assignRealmRolesToUser(userId, roleNames); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/AggregatedJobStatusDTO.java b/src/main/java/com/dalab/adminservice/dto/AggregatedJobStatusDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..72fff54619a54f9adf077d138ca9e49b0eb0e61f --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/AggregatedJobStatusDTO.java @@ -0,0 +1,22 @@ +package com.dalab.adminservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AggregatedJobStatusDTO { + private List jobs; + private int totalJobs; + private int pendingJobs; + private int runningJobs; + private int completedSuccessJobs; + private int completedFailedJobs; + // Add any other summary fields as needed +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/CloudConnectionDTO.java b/src/main/java/com/dalab/adminservice/dto/CloudConnectionDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..163ffd2169b734fc8a4cc6de973d23010607be0c --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/CloudConnectionDTO.java @@ -0,0 +1,53 @@ +package com.dalab.adminservice.dto; + +import java.util.Map; + +import com.dalab.adminservice.model.enums.CloudProviderType; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Represents a cloud connection configuration.") +public class CloudConnectionDTO { + + @Schema(description = "Unique identifier of the cloud connection, generated by the system.", example = "f47ac10b-58cc-4372-a567-0e02b2c3d479", accessMode = Schema.AccessMode.READ_ONLY) + private String id; + + @NotBlank + @Size(max = 100) + @Schema(description = "User-defined name for this cloud connection.", example = "My Production GCP Connection") + private String name; + + @Schema(description = "Optional description for the cloud connection.", example = "Main GCP project for production workloads") + private String description; + + @NotNull + @Schema(description = "Type of the cloud provider.", example = "GCP") + private CloudProviderType providerType; + + @Schema(description = "Key-value pairs for connection credentials and parameters (e.g., projectId, region, accessKey). Specific keys depend on the providerType. Sensitive values are write-only.") + private Map connectionParameters; // e.g., { "projectId": "my-gcp-project", "region": "us-central1" } + + @Schema(description = "Service Account Key JSON or other sensitive credential details. This field is write-only and will not be returned in GET requests.", accessMode = Schema.AccessMode.WRITE_ONLY) + private String sensitiveCredentials; // e.g., GCP Service Account JSON, AWS Secret Key + + @Schema(description = "Indicates if the connection is currently enabled.", example = "true", accessMode = Schema.AccessMode.READ_ONLY) + @Builder.Default + private boolean enabled = true; + + @Schema(description = "Timestamp of the last successful connection test.", accessMode = Schema.AccessMode.READ_ONLY) + private String lastConnectionTestAt; // Using String for simplicity, can be LocalDateTime + + @Schema(description = "Status of the last connection test (e.g., SUCCESS, FAILED).", accessMode = Schema.AccessMode.READ_ONLY) + private String lastConnectionTestStatus; +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/CloudConnectionTestResultDTO.java b/src/main/java/com/dalab/adminservice/dto/CloudConnectionTestResultDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..b06e014704277539008ca19050e8fe324e986472 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/CloudConnectionTestResultDTO.java @@ -0,0 +1,24 @@ +package com.dalab.adminservice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Result of a cloud connection test operation.") +public class CloudConnectionTestResultDTO { + + @Schema(description = "Indicates whether the connection test was successful.", example = "true") + private boolean success; + + @Schema(description = "A message detailing the test result, including error information if applicable.", example = "Connection to GCP project 'my-gcp-project' successful.") + private String message; + + @Schema(description = "Timestamp of when the test was performed.") + private String testedAt; // Can be LocalDateTime +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/JobStatusDTO.java b/src/main/java/com/dalab/adminservice/dto/JobStatusDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..f746dc2efbbac95aedbc71f193adfabf27c8577f --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/JobStatusDTO.java @@ -0,0 +1,25 @@ +package com.dalab.adminservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobStatusDTO { + private String jobId; + private String serviceName; // Name of the service that owns the job + private String jobType; // e.g., "DISCOVERY_SCAN", "AUTOLABEL_TASK", "ARCHIVAL_JOB" + private String status; // e.g., "PENDING", "RUNNING", "COMPLETED_SUCCESS", "COMPLETED_FAILED", "UNKNOWN" + private LocalDateTime submittedAt; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private Map details; // Any service-specific details about the job + private String errorDetails; // Error message if the job failed +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/RoleDTO.java b/src/main/java/com/dalab/adminservice/dto/RoleDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..e264267c58304e4558d9b2ed718682be03891a3e --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/RoleDTO.java @@ -0,0 +1,21 @@ +package com.dalab.adminservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for role management operations. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RoleDTO { + + private String id; + private String name; + private String description; + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/ServiceConfigDTO.java b/src/main/java/com/dalab/adminservice/dto/ServiceConfigDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..88bdbbc15b2bbd663be728538ec83ecd8f0b4671 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/ServiceConfigDTO.java @@ -0,0 +1,29 @@ +package com.dalab.adminservice.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for service configuration management. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ServiceConfigDTO { + + @NotBlank + private String serviceId; + + private String displayName; + private String description; + private String version; + private String endpoint; + + @Builder.Default + private boolean enabled = true; + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/dto/UserDTO.java b/src/main/java/com/dalab/adminservice/dto/UserDTO.java new file mode 100644 index 0000000000000000000000000000000000000000..8e8bfea2e6cb258996aab3052e94f694d45c2241 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/dto/UserDTO.java @@ -0,0 +1,38 @@ +package com.dalab.adminservice.dto; + +import java.util.List; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * DTO for user management operations. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDTO { + + private String id; + + @NotBlank + private String username; + + @Email + private String email; + + private String firstName; + private String lastName; + private String password; + + @Builder.Default + private boolean enabled = true; + + private List roles; + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/exception/ConflictException.java b/src/main/java/com/dalab/adminservice/exception/ConflictException.java new file mode 100644 index 0000000000000000000000000000000000000000..3019966c3b6f933349525560768b588fc20c1dd2 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/exception/ConflictException.java @@ -0,0 +1,15 @@ +package com.dalab.adminservice.exception; + +/** + * Exception thrown when a conflict occurs during operations. + */ +public class ConflictException extends RuntimeException { + + public ConflictException(String message) { + super(message); + } + + public ConflictException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/exception/KeycloakAdminException.java b/src/main/java/com/dalab/adminservice/exception/KeycloakAdminException.java new file mode 100644 index 0000000000000000000000000000000000000000..c58dcde848cb24a16dbcf1c51ce1da09074fa040 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/exception/KeycloakAdminException.java @@ -0,0 +1,8 @@ +package com.dalab.adminservice.exception; + +// Can be a generic runtime exception or extend a more specific one if needed +public class KeycloakAdminException extends RuntimeException { + public KeycloakAdminException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/exception/NotFoundException.java b/src/main/java/com/dalab/adminservice/exception/NotFoundException.java new file mode 100644 index 0000000000000000000000000000000000000000..056aa6fbe51f8719b3e72dc916162e478f43a969 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/exception/NotFoundException.java @@ -0,0 +1,11 @@ +package com.dalab.adminservice.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.NOT_FOUND) +public class NotFoundException extends RuntimeException { + public NotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/mapper/CloudConnectionMapper.java b/src/main/java/com/dalab/adminservice/mapper/CloudConnectionMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..61d2984c8a669bdfb4b7d6b15be67369a41233ac --- /dev/null +++ b/src/main/java/com/dalab/adminservice/mapper/CloudConnectionMapper.java @@ -0,0 +1,53 @@ +package com.dalab.adminservice.mapper; + +import com.dalab.adminservice.dto.CloudConnectionDTO; +import com.dalab.adminservice.model.CloudConnectionEntity; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.NullValuePropertyMappingStrategy; +import org.mapstruct.ReportingPolicy; +import org.mapstruct.factory.Mappers; + +import java.util.List; + +/** + * Mapper interface for converting between CloudConnectionEntity and CloudConnectionDTO. + */ +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE, + nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE +) +public interface CloudConnectionMapper { + + CloudConnectionMapper INSTANCE = Mappers.getMapper(CloudConnectionMapper.class); + + // When mapping from Entity to DTO, ignore encryptedCredentials + @Mapping(target = "sensitiveCredentials", ignore = true) + CloudConnectionDTO toDto(CloudConnectionEntity entity); + + List toDtoList(List entities); + + // When mapping from DTO to Entity for creation/update, sensitiveCredentials from DTO will be handled by service for encryption + // The encryptedCredentials field in entity will be set by the service, not directly by mapper from DTO.sensitiveCredentials + @Mapping(target = "id", ignore = true) // ID is generated or path param + @Mapping(target = "encryptedCredentials", ignore = true) // Handled by service + @Mapping(target = "createdBy", ignore = true) + @Mapping(target = "createdDate", ignore = true) + @Mapping(target = "lastModifiedBy", ignore = true) + @Mapping(target = "lastModifiedDate", ignore = true) + @Mapping(target = "lastConnectionTestAt", ignore = true) // These are set by the system + @Mapping(target = "lastConnectionTestStatus", ignore = true) + void updateEntityFromDto(CloudConnectionDTO dto, @MappingTarget CloudConnectionEntity entity); + + @Mapping(target = "id", ignore = true) // ID is generated + @Mapping(target = "encryptedCredentials", ignore = true) // Handled by service + @Mapping(target = "createdBy", ignore = true) + @Mapping(target = "createdDate", ignore = true) + @Mapping(target = "lastModifiedBy", ignore = true) + @Mapping(target = "lastModifiedDate", ignore = true) + @Mapping(target = "lastConnectionTestAt", ignore = true) + @Mapping(target = "lastConnectionTestStatus", ignore = true) + CloudConnectionEntity toEntity(CloudConnectionDTO dto); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/mapper/RoleMapper.java b/src/main/java/com/dalab/adminservice/mapper/RoleMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..fcdf5a07869591a720ec7102c8abf3d261e4a6e4 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/mapper/RoleMapper.java @@ -0,0 +1,19 @@ +package com.dalab.adminservice.mapper; + +import com.dalab.adminservice.dto.RoleDTO; +import org.keycloak.representations.idm.RoleRepresentation; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import java.util.List; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE +) +public interface RoleMapper { + + RoleDTO toDto(RoleRepresentation roleRepresentation); + + List toDtoList(List roleRepresentations); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/mapper/ServiceConfigMapper.java b/src/main/java/com/dalab/adminservice/mapper/ServiceConfigMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..fe124a274f905c92e9316b60d2fdda08c1fa42d5 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/mapper/ServiceConfigMapper.java @@ -0,0 +1,29 @@ +package com.dalab.adminservice.mapper; + +import java.util.List; + +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; +import org.mapstruct.factory.Mappers; + +import com.dalab.adminservice.dto.ServiceConfigDTO; +import com.dalab.adminservice.model.ServiceConfigEntity; + +/** + * Mapper interface for converting between ServiceConfigEntity and ServiceConfigDTO. + */ +@Mapper +public interface ServiceConfigMapper { + + ServiceConfigMapper INSTANCE = Mappers.getMapper(ServiceConfigMapper.class); + + ServiceConfigDTO toDto(ServiceConfigEntity entity); + + List toDtoList(List entities); + + @Mapping(target = "serviceId", ignore = true) + void updateEntityFromDto(ServiceConfigDTO dto, @MappingTarget ServiceConfigEntity entity); + + ServiceConfigEntity toEntity(ServiceConfigDTO dto); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/mapper/UserMapper.java b/src/main/java/com/dalab/adminservice/mapper/UserMapper.java new file mode 100644 index 0000000000000000000000000000000000000000..7823abbaa0315878eea050818c652d0348d5c0e4 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/mapper/UserMapper.java @@ -0,0 +1,41 @@ +package com.dalab.adminservice.mapper; + +import com.dalab.adminservice.dto.UserDTO; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.Named; +import org.mapstruct.ReportingPolicy; + +import java.util.Collections; +import java.util.List; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.IGNORE +) +public interface UserMapper { + + @Mapping(target = "roles", source = "realmRoles") // Assuming roles are stored in realmRoles + UserDTO toDto(UserRepresentation userRepresentation); + + List toDtoList(List userRepresentations); + + // For creating/updating users in Keycloak + @Mapping(target = "realmRoles", source = "roles") + @Mapping(target = "credentials", source = "password", qualifiedByName = "passwordToCredentials") + UserRepresentation toRepresentation(UserDTO userDTO); + + @Named("passwordToCredentials") + default List passwordToCredentials(String password) { + if (password == null || password.isEmpty()) { + return Collections.emptyList(); + } + CredentialRepresentation credential = new CredentialRepresentation(); + credential.setType(CredentialRepresentation.PASSWORD); + credential.setValue(password); + credential.setTemporary(false); // Set to true if password reset is required on first login + return Collections.singletonList(credential); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/model/AbstractAuditableEntity.java b/src/main/java/com/dalab/adminservice/model/AbstractAuditableEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..9a88d55449d3bbf111673bfbc0d524fa33b8ca48 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/model/AbstractAuditableEntity.java @@ -0,0 +1,41 @@ +package com.dalab.adminservice.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Data; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.io.Serializable; +import java.time.Instant; + +/** + * Abstract base class for auditable entities. + */ +@Data +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class AbstractAuditableEntity implements Serializable { + + private static final long serialVersionUID = 1L; + + @CreatedBy + @Column(name = "created_by", nullable = false, length = 50, updatable = false) + private String createdBy; + + @CreatedDate + @Column(name = "created_date", nullable = false, updatable = false) + private Instant createdDate = Instant.now(); + + @LastModifiedBy + @Column(name = "last_modified_by", length = 50) + private String lastModifiedBy; + + @LastModifiedDate + @Column(name = "last_modified_date") + private Instant lastModifiedDate = Instant.now(); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/model/CloudConnectionEntity.java b/src/main/java/com/dalab/adminservice/model/CloudConnectionEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..771d615af47a877bf939b523969c0a88edade7d7 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/model/CloudConnectionEntity.java @@ -0,0 +1,52 @@ +package com.dalab.adminservice.model; + +import com.dalab.adminservice.model.enums.CloudProviderType; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.GenericGenerator; +import org.hibernate.annotations.Type; + +import java.time.LocalDateTime; +import java.util.Map; + +@Getter +@Setter +@Entity +@Table(name = "dalab_cloud_connection") +public class CloudConnectionEntity extends AbstractAuditableEntity { + + @Id + @GeneratedValue(generator = "uuid2") + @GenericGenerator(name = "uuid2", strategy = "uuid2") + @Column(name = "id", updatable = false, nullable = false, columnDefinition = "VARCHAR(36)") + private String id; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "description", length = 500) + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "provider_type", nullable = false, length = 50) + private CloudProviderType providerType; + + @Type(JsonType.class) + @Column(name = "connection_parameters", columnDefinition = "jsonb") + private Map connectionParameters; + + @Lob // Or @Column(length = large_enough_value) depending on DB and encrypted size + @Column(name = "encrypted_credentials", nullable = true) // Storing as byte[] might be better for encrypted data + private String encryptedCredentials; // Placeholder: In a real app, this would be byte[] and encrypted + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "last_connection_test_at") + private LocalDateTime lastConnectionTestAt; + + @Column(name = "last_connection_test_status", length = 50) + private String lastConnectionTestStatus; // e.g., SUCCESS, FAILED +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/model/ServiceConfigEntity.java b/src/main/java/com/dalab/adminservice/model/ServiceConfigEntity.java new file mode 100644 index 0000000000000000000000000000000000000000..8e10aea2d2d11e49e1b0da040e8336a3b3b54347 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/model/ServiceConfigEntity.java @@ -0,0 +1,42 @@ +package com.dalab.adminservice.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * Entity for service configuration management. + */ +@Entity +@Table(name = "service_configs") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ServiceConfigEntity { + + @Id + @Column(nullable = false, unique = true, length = 100) + private String serviceId; + + @Column(length = 255) + private String displayName; + + @Column(length = 1000) + private String description; + + @Column(length = 50) + private String version; + + @Column(length = 500) + private String endpoint; + + @Builder.Default + private boolean enabled = true; + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/model/enums/CloudProviderType.java b/src/main/java/com/dalab/adminservice/model/enums/CloudProviderType.java new file mode 100644 index 0000000000000000000000000000000000000000..d93432cb6c3aec10dd42f8fa28ee1c3fe1ac5023 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/model/enums/CloudProviderType.java @@ -0,0 +1,11 @@ +package com.dalab.adminservice.model.enums; + +/** + * Enumeration of supported cloud provider types. + */ +public enum CloudProviderType { + AWS, + AZURE, + GCP, + OTHER +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/repository/CloudConnectionRepository.java b/src/main/java/com/dalab/adminservice/repository/CloudConnectionRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..fbbfef2268bef630d60c15381ac80071817a367c --- /dev/null +++ b/src/main/java/com/dalab/adminservice/repository/CloudConnectionRepository.java @@ -0,0 +1,12 @@ +package com.dalab.adminservice.repository; + +import com.dalab.adminservice.model.CloudConnectionEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface CloudConnectionRepository extends JpaRepository { + Optional findByName(String name); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/repository/ServiceConfigRepository.java b/src/main/java/com/dalab/adminservice/repository/ServiceConfigRepository.java new file mode 100644 index 0000000000000000000000000000000000000000..2e50263b08cc38f45152fbd5e4add3b725e7c7d0 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/repository/ServiceConfigRepository.java @@ -0,0 +1,23 @@ +package com.dalab.adminservice.repository; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import com.dalab.adminservice.model.ServiceConfigEntity; + +/** + * Repository interface for ServiceConfig entities + */ +@Repository +public interface ServiceConfigRepository extends JpaRepository { + + /** + * Find service configuration by service ID + * @param serviceId the service identifier + * @return Optional containing the service config if found + */ + Optional findByServiceId(String serviceId); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/ICloudConnectionService.java b/src/main/java/com/dalab/adminservice/service/ICloudConnectionService.java new file mode 100644 index 0000000000000000000000000000000000000000..f521662d8534ce8c7ef7f9885f4ccb1115033660 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/ICloudConnectionService.java @@ -0,0 +1,18 @@ +package com.dalab.adminservice.service; + +import com.dalab.adminservice.dto.CloudConnectionDTO; +import com.dalab.adminservice.dto.CloudConnectionTestResultDTO; + +import java.util.List; +import java.util.Optional; + +public interface ICloudConnectionService { + List getAllCloudConnections(); + Optional getCloudConnectionById(String id); + CloudConnectionDTO createCloudConnection(CloudConnectionDTO cloudConnectionDTO); + CloudConnectionDTO updateCloudConnection(String id, CloudConnectionDTO cloudConnectionDTO); + void deleteCloudConnection(String id); + CloudConnectionTestResultDTO testCloudConnection(String id); + // Method to get decrypted credentials - internal use, not exposed via API directly + String getDecryptedCredentials(String id); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/IEncryptionService.java b/src/main/java/com/dalab/adminservice/service/IEncryptionService.java new file mode 100644 index 0000000000000000000000000000000000000000..16f04b49599d4f8b648e95f802467b009e6ca663 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/IEncryptionService.java @@ -0,0 +1,22 @@ +package com.dalab.adminservice.service; + +/** + * Service interface for encryption and decryption operations. + */ +public interface IEncryptionService { + + /** + * Encrypt the given data. + * @param data The data to encrypt + * @return The encrypted data as a string + */ + String encrypt(String data); + + /** + * Decrypt the given encrypted data. + * @param encryptedData The encrypted data to decrypt + * @return The decrypted data as a string + */ + String decrypt(String encryptedData); + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/IJobStatusService.java b/src/main/java/com/dalab/adminservice/service/IJobStatusService.java new file mode 100644 index 0000000000000000000000000000000000000000..f3f037caee03d6f2d81f050d28de02419dc9c553 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/IJobStatusService.java @@ -0,0 +1,16 @@ +package com.dalab.adminservice.service; + +import com.dalab.adminservice.dto.AggregatedJobStatusDTO; + +/** + * Service interface for job status aggregation operations. + */ +public interface IJobStatusService { + + /** + * Get aggregated job statuses from all microservices. + * @return Aggregated job status DTO + */ + AggregatedJobStatusDTO getAggregatedJobStatuses(); + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/IRoleService.java b/src/main/java/com/dalab/adminservice/service/IRoleService.java new file mode 100644 index 0000000000000000000000000000000000000000..5ea68731093e5f1c7f3abb3b4964f60b1e9c1933 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/IRoleService.java @@ -0,0 +1,18 @@ +package com.dalab.adminservice.service; + +import java.util.List; + +import com.dalab.adminservice.dto.RoleDTO; + +/** + * Service interface for role management operations. + */ +public interface IRoleService { + + /** + * Get all available realm roles. + * @return List of role DTOs + */ + List getAllRealmRoles(); + +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/IServiceConfigService.java b/src/main/java/com/dalab/adminservice/service/IServiceConfigService.java new file mode 100644 index 0000000000000000000000000000000000000000..a823a615278b0fea3e9f183d2587c29d1316da97 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/IServiceConfigService.java @@ -0,0 +1,14 @@ +package com.dalab.adminservice.service; + +import com.dalab.adminservice.dto.ServiceConfigDTO; + +import java.util.List; +import java.util.Optional; + +public interface IServiceConfigService { + List getAllServiceConfigs(); + Optional getServiceConfigById(String serviceId); + ServiceConfigDTO createServiceConfig(ServiceConfigDTO serviceConfigDTO); + ServiceConfigDTO updateServiceConfig(String serviceId, ServiceConfigDTO serviceConfigDTO); + // No delete operation defined for now, services are typically not "deleted" but maybe disabled. +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/IUserService.java b/src/main/java/com/dalab/adminservice/service/IUserService.java new file mode 100644 index 0000000000000000000000000000000000000000..764a3a104070f374844819cce76045e314399f46 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/IUserService.java @@ -0,0 +1,19 @@ +package com.dalab.adminservice.service; + +import com.dalab.adminservice.dto.UserDTO; +import org.keycloak.representations.idm.RoleRepresentation; + +import java.util.List; +import java.util.Optional; + +public interface IUserService { + List getAllUsers(Integer firstResult, Integer maxResults); + Optional getUserById(String userId); + Optional getUserByUsername(String username); + UserDTO createUser(UserDTO userDTO); + UserDTO updateUser(String userId, UserDTO userDTO); + void deleteUser(String userId); + void assignRealmRolesToUser(String userId, List roleNames); + List getAvailableRealmRoles(); // Helper to get roles for UI + List getUserRealmRoles(String userId); +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/impl/BasicEncryptionServiceImpl.java b/src/main/java/com/dalab/adminservice/service/impl/BasicEncryptionServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..cf2bb17bec0a539cb0bca9d5964ab31b0187d168 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/impl/BasicEncryptionServiceImpl.java @@ -0,0 +1,59 @@ +package com.dalab.adminservice.service.impl; + +import com.dalab.adminservice.service.IEncryptionService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +@Service +@Slf4j +public class BasicEncryptionServiceImpl implements IEncryptionService { + + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding"; // ECB is simple but less secure for some uses. + + @Value("${app.encryption.key:DefaultTestEncrKey}") // 16, 24, or 32 bytes for AES-128, AES-192, or AES-256. IMPORTANT: Store securely, not hardcoded/defaulted in prod. + private String encryptionKeyString; + + private SecretKeySpec getSecretKeySpec() { + // Ensure the key is the correct length (e.g., 16 bytes for AES-128) + byte[] keyBytes = new byte[16]; + byte[] providedKeyBytes = encryptionKeyString.getBytes(StandardCharsets.UTF_8); + System.arraycopy(providedKeyBytes, 0, keyBytes, 0, Math.min(providedKeyBytes.length, keyBytes.length)); + return new SecretKeySpec(keyBytes, ALGORITHM); + } + + @Override + public String encrypt(String data) { + if (data == null) return null; + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, getSecretKeySpec()); + byte[] encryptedBytes = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encryptedBytes); + } catch (Exception e) { + log.error("Error encrypting data", e); + // In a real app, throw a specific EncryptionException + throw new RuntimeException("Error encrypting data", e); + } + } + + @Override + public String decrypt(String encryptedData) { + if (encryptedData == null) return null; + try { + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, getSecretKeySpec()); + byte[] originalBytes = cipher.doFinal(Base64.getDecoder().decode(encryptedData)); + return new String(originalBytes, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("Error decrypting data", e); + // In a real app, throw a specific EncryptionException or return null/empty based on policy + throw new RuntimeException("Error decrypting data", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/impl/CloudConnectionServiceImpl.java b/src/main/java/com/dalab/adminservice/service/impl/CloudConnectionServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..22e95d6caf80ec9fc9050a5977c1d9b04b7c905e --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/impl/CloudConnectionServiceImpl.java @@ -0,0 +1,166 @@ +package com.dalab.adminservice.service.impl; + +import com.dalab.adminservice.dto.CloudConnectionDTO; +import com.dalab.adminservice.dto.CloudConnectionTestResultDTO; +import com.dalab.adminservice.exception.ConflictException; +import com.dalab.adminservice.exception.NotFoundException; +import com.dalab.adminservice.mapper.CloudConnectionMapper; +import com.dalab.adminservice.model.CloudConnectionEntity; +import com.dalab.adminservice.repository.CloudConnectionRepository; +import com.dalab.adminservice.service.ICloudConnectionService; +import com.dalab.adminservice.service.IEncryptionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CloudConnectionServiceImpl implements ICloudConnectionService { + + private final CloudConnectionRepository cloudConnectionRepository; + private final CloudConnectionMapper cloudConnectionMapper; + private final IEncryptionService encryptionService; // For encrypting/decrypting sensitiveCredentials + + @Override + @Transactional(readOnly = true) + public List getAllCloudConnections() { + log.debug("Fetching all cloud connections"); + return cloudConnectionMapper.toDtoList(cloudConnectionRepository.findAll()); + } + + @Override + @Transactional(readOnly = true) + public Optional getCloudConnectionById(String id) { + log.debug("Fetching cloud connection by ID: {}", id); + return cloudConnectionRepository.findById(id) + .map(cloudConnectionMapper::toDto); + } + + @Override + @Transactional + public CloudConnectionDTO createCloudConnection(CloudConnectionDTO cloudConnectionDTO) { + log.info("Creating new cloud connection with name: {}", cloudConnectionDTO.getName()); + cloudConnectionRepository.findByName(cloudConnectionDTO.getName()).ifPresent(entity -> { + throw new ConflictException("Cloud connection with name '" + cloudConnectionDTO.getName() + "' already exists."); + }); + + CloudConnectionEntity entity = cloudConnectionMapper.toEntity(cloudConnectionDTO); + if (StringUtils.hasText(cloudConnectionDTO.getSensitiveCredentials())) { + entity.setEncryptedCredentials(encryptionService.encrypt(cloudConnectionDTO.getSensitiveCredentials())); + } + CloudConnectionEntity savedEntity = cloudConnectionRepository.save(entity); + return cloudConnectionMapper.toDto(savedEntity); + } + + @Override + @Transactional + public CloudConnectionDTO updateCloudConnection(String id, CloudConnectionDTO cloudConnectionDTO) { + log.info("Updating cloud connection with ID: {}", id); + CloudConnectionEntity existingEntity = cloudConnectionRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Cloud connection with ID '" + id + "' not found.")); + + // Check if name is being changed and if the new name already exists for another entity + if (StringUtils.hasText(cloudConnectionDTO.getName()) && !existingEntity.getName().equals(cloudConnectionDTO.getName())) { + cloudConnectionRepository.findByName(cloudConnectionDTO.getName()).ifPresent(entity -> { + if (!entity.getId().equals(id)) { // If it's not the same entity + throw new ConflictException("Cloud connection with name '" + cloudConnectionDTO.getName() + "' already exists."); + } + }); + } + + cloudConnectionMapper.updateEntityFromDto(cloudConnectionDTO, existingEntity); + if (StringUtils.hasText(cloudConnectionDTO.getSensitiveCredentials())) { + log.debug("Updating encrypted credentials for cloud connection ID: {}", id); + existingEntity.setEncryptedCredentials(encryptionService.encrypt(cloudConnectionDTO.getSensitiveCredentials())); + } else { + // If sensitiveCredentials is null or empty in DTO, it means user doesn't want to update them. + // If user wants to clear them, an explicit mechanism should be used. + // For now, we just don't update if it's not provided. + } + + CloudConnectionEntity updatedEntity = cloudConnectionRepository.save(existingEntity); + return cloudConnectionMapper.toDto(updatedEntity); + } + + @Override + @Transactional + public void deleteCloudConnection(String id) { + log.info("Deleting cloud connection with ID: {}", id); + if (!cloudConnectionRepository.existsById(id)) { + throw new NotFoundException("Cloud connection with ID '" + id + "' not found."); + } + cloudConnectionRepository.deleteById(id); + } + + @Override + @Transactional + public CloudConnectionTestResultDTO testCloudConnection(String id) { + log.info("Testing cloud connection with ID: {}", id); + CloudConnectionEntity entity = cloudConnectionRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Cloud connection with ID '" + id + "' not found for testing.")); + + // Placeholder for actual connection testing logic + // This would involve: + // 1. Decrypting entity.getEncryptedCredentials() using encryptionService.decrypt() + // 2. Using the decrypted credentials and entity.getConnectionParameters() + // to attempt a connection to the specified cloudProviderType (e.g., using GCP/AWS SDKs). + boolean testSuccess = false; + String message = "Test not implemented for provider: " + entity.getProviderType(); + + // Simulate a test + try { + String decryptedCreds = encryptionService.decrypt(entity.getEncryptedCredentials()); + if (decryptedCreds != null && !decryptedCreds.isEmpty()) { + // Simulate success if credentials exist for the sake of example + message = "Simulated connection test successful for " + entity.getName(); + testSuccess = true; + log.info("Simulated connection test successful for ID: {}. Decrypted (sample): {}", id, decryptedCreds.substring(0, Math.min(decryptedCreds.length(), 10))); + } else { + message = "Simulated connection test failed: No credentials to test for " + entity.getName(); + log.warn("Simulated connection test failed for ID: {}. No credentials found.", id); + } + } catch (Exception e) { + log.error("Error during simulated connection test for ID: {}", id, e); + message = "Simulated connection test failed: " + e.getMessage(); + } + + entity.setLastConnectionTestAt(LocalDateTime.now()); + entity.setLastConnectionTestStatus(testSuccess ? "SUCCESS" : "FAILED"); + cloudConnectionRepository.save(entity); + + return CloudConnectionTestResultDTO.builder() + .success(testSuccess) + .message(message) + .testedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) + .build(); + } + + @Override + @Transactional(readOnly = true) + public String getDecryptedCredentials(String id) { + log.debug("Retrieving decrypted credentials for cloud connection ID: {} (internal use)", id); + CloudConnectionEntity entity = cloudConnectionRepository.findById(id) + .orElseThrow(() -> new NotFoundException("Cloud connection with ID '" + id + "' not found.")); + + if (!StringUtils.hasText(entity.getEncryptedCredentials())) { + log.warn("No encrypted credentials found for cloud connection ID: {}", id); + return null; + } + try { + return encryptionService.decrypt(entity.getEncryptedCredentials()); + } catch (Exception e) { + log.error("Failed to decrypt credentials for cloud connection ID: {}. Error: {}", id, e.getMessage()); + // Depending on policy, you might re-throw, or return null, or a specific error marker. + // Throwing an exception is safer to indicate failure clearly. + throw new RuntimeException("Failed to decrypt credentials for ID: " + id, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/impl/JobStatusServiceImpl.java b/src/main/java/com/dalab/adminservice/service/impl/JobStatusServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..734b366e94c669c46186e266a4c061366a5a283b --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/impl/JobStatusServiceImpl.java @@ -0,0 +1,108 @@ +package com.dalab.adminservice.service.impl; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.dalab.adminservice.client.IAutoarchivalTaskApiClient; +import com.dalab.adminservice.client.IAutocomplianceJobApiClient; +import com.dalab.adminservice.client.IAutodeleteTaskApiClient; +import com.dalab.adminservice.client.IAutolabelJobApiClient; +import com.dalab.adminservice.client.IDiscoveryJobApiClient; +import com.dalab.adminservice.dto.AggregatedJobStatusDTO; +import com.dalab.adminservice.dto.JobStatusDTO; +import com.dalab.adminservice.service.IJobStatusService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service implementation for aggregating job statuses from all DALab microservices + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class JobStatusServiceImpl implements IJobStatusService { + + private final IDiscoveryJobApiClient discoveryJobApiClient; + private final IAutolabelJobApiClient autolabelJobApiClient; + private final IAutoarchivalTaskApiClient autoarchivalTaskApiClient; + private final IAutodeleteTaskApiClient autodeleteTaskApiClient; + private final IAutocomplianceJobApiClient autocomplianceJobApiClient; + + @Override + public AggregatedJobStatusDTO getAggregatedJobStatuses() { + log.debug("Aggregating job statuses from all DALab services"); + + List allJobs = new ArrayList<>(); + + // Aggregate jobs from all services with error handling + aggregateFromService("Discovery", () -> discoveryJobApiClient.getDiscoveryJobs(), allJobs); + aggregateFromService("Autolabel", () -> autolabelJobApiClient.getAutolabelJobs(), allJobs); + aggregateFromService("Autoarchival", () -> autoarchivalTaskApiClient.getArchivalTasks(), allJobs); + aggregateFromService("Autodelete", () -> autodeleteTaskApiClient.getDeletionTasks(), allJobs); + aggregateFromService("Autocompliance", () -> autocomplianceJobApiClient.getComplianceReportJobs(), allJobs); + + return buildAggregatedResult(allJobs); + } + + private void aggregateFromService(String serviceName, ServiceJobProvider provider, List allJobs) { + try { + List serviceJobs = provider.getJobs(); + if (serviceJobs != null) { + allJobs.addAll(serviceJobs); + log.debug("Aggregated {} jobs from {} service", serviceJobs.size(), serviceName); + } + } catch (Exception e) { + log.warn("Failed to retrieve jobs from {} service: {}", serviceName, e.getMessage()); + } + } + + private AggregatedJobStatusDTO buildAggregatedResult(List allJobs) { + int totalJobs = allJobs.size(); + int pendingJobs = 0; + int runningJobs = 0; + int completedSuccessJobs = 0; + int completedFailedJobs = 0; + + for (JobStatusDTO job : allJobs) { + String status = job.getStatus(); + if (status == null) continue; + + switch (status.toUpperCase()) { + case "PENDING": + pendingJobs++; + break; + case "RUNNING": + case "IN_PROGRESS": + runningJobs++; + break; + case "COMPLETED_SUCCESS": + case "COMPLETED": + case "SUCCESS": + completedSuccessJobs++; + break; + case "COMPLETED_FAILED": + case "FAILED": + case "ERROR": + completedFailedJobs++; + break; + } + } + + return AggregatedJobStatusDTO.builder() + .totalJobs(totalJobs) + .pendingJobs(pendingJobs) + .runningJobs(runningJobs) + .completedSuccessJobs(completedSuccessJobs) + .completedFailedJobs(completedFailedJobs) + .jobs(allJobs) + .build(); + } + + @FunctionalInterface + private interface ServiceJobProvider { + List getJobs(); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/impl/RoleServiceImpl.java b/src/main/java/com/dalab/adminservice/service/impl/RoleServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..9135e1db50247b59a9842ae2fbe9dcbeadc54114 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/impl/RoleServiceImpl.java @@ -0,0 +1,44 @@ +package com.dalab.adminservice.service.impl; + +import com.dalab.adminservice.dto.RoleDTO; +import com.dalab.adminservice.exception.KeycloakAdminException; +import com.dalab.adminservice.mapper.RoleMapper; +import com.dalab.adminservice.service.IRoleService; +import jakarta.ws.rs.WebApplicationException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.representations.idm.RoleRepresentation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RoleServiceImpl implements IRoleService { + + private final Keycloak keycloakAdminClient; + private final RoleMapper roleMapper; + + @Value("${keycloak.realm}") + private String realmName; + + private RealmResource getRealmResource() { + return keycloakAdminClient.realm(realmName); + } + + @Override + public List getAllRealmRoles() { + log.debug("Fetching all available realm roles from Keycloak realm: {}", realmName); + try { + List roleRepresentations = getRealmResource().roles().list(); + return roleMapper.toDtoList(roleRepresentations); + } catch (WebApplicationException e) { + log.error("Keycloak admin error fetching available realm roles: {}", e.getMessage(), e); + throw new KeycloakAdminException("Error fetching available realm roles: " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/impl/ServiceConfigServiceImpl.java b/src/main/java/com/dalab/adminservice/service/impl/ServiceConfigServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..0cc10ded5340d2394f94352a23bb3bd0a3a8a33d --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/impl/ServiceConfigServiceImpl.java @@ -0,0 +1,65 @@ +package com.dalab.adminservice.service.impl; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.dalab.adminservice.dto.ServiceConfigDTO; +import com.dalab.adminservice.exception.NotFoundException; +import com.dalab.adminservice.mapper.ServiceConfigMapper; +import com.dalab.adminservice.model.ServiceConfigEntity; +import com.dalab.adminservice.repository.ServiceConfigRepository; +import com.dalab.adminservice.service.IServiceConfigService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Service implementation for managing service configurations + */ +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional +public class ServiceConfigServiceImpl implements IServiceConfigService { + + private final ServiceConfigRepository serviceConfigRepository; + private final ServiceConfigMapper serviceConfigMapper; + + @Override + @Transactional(readOnly = true) + public List getAllServiceConfigs() { + log.debug("Retrieving all service configurations"); + List entities = serviceConfigRepository.findAll(); + return serviceConfigMapper.toDtoList(entities); + } + + @Override + @Transactional(readOnly = true) + public Optional getServiceConfigById(String serviceId) { + log.debug("Retrieving service configuration for service: {}", serviceId); + return serviceConfigRepository.findByServiceId(serviceId) + .map(serviceConfigMapper::toDto); + } + + @Override + public ServiceConfigDTO createServiceConfig(ServiceConfigDTO serviceConfigDTO) { + log.debug("Creating new service configuration for service: {}", serviceConfigDTO.getServiceId()); + ServiceConfigEntity entity = serviceConfigMapper.toEntity(serviceConfigDTO); + ServiceConfigEntity savedEntity = serviceConfigRepository.save(entity); + return serviceConfigMapper.toDto(savedEntity); + } + + @Override + public ServiceConfigDTO updateServiceConfig(String serviceId, ServiceConfigDTO serviceConfigDTO) { + log.debug("Updating service configuration for service: {}", serviceId); + ServiceConfigEntity existingEntity = serviceConfigRepository.findByServiceId(serviceId) + .orElseThrow(() -> new NotFoundException("Service configuration not found for service: " + serviceId)); + + serviceConfigMapper.updateEntityFromDto(serviceConfigDTO, existingEntity); + ServiceConfigEntity updatedEntity = serviceConfigRepository.save(existingEntity); + return serviceConfigMapper.toDto(updatedEntity); + } +} \ No newline at end of file diff --git a/src/main/java/com/dalab/adminservice/service/impl/UserServiceImpl.java b/src/main/java/com/dalab/adminservice/service/impl/UserServiceImpl.java new file mode 100644 index 0000000000000000000000000000000000000000..ae795af009ca408e9a83aa783e8a28ed85080230 --- /dev/null +++ b/src/main/java/com/dalab/adminservice/service/impl/UserServiceImpl.java @@ -0,0 +1,265 @@ +package com.dalab.adminservice.service.impl; + +import com.dalab.adminservice.dto.UserDTO; +import com.dalab.adminservice.exception.ConflictException; +import com.dalab.adminservice.exception.KeycloakAdminException; +import com.dalab.adminservice.exception.NotFoundException; +import com.dalab.adminservice.mapper.UserMapper; +import com.dalab.adminservice.service.IUserService; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.core.Response; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RoleMappingResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserServiceImpl implements IUserService { + + private final Keycloak keycloakAdminClient; + private final UserMapper userMapper; + + @Value("${keycloak.realm}") + private String realmName; + + private RealmResource getRealmResource() { + return keycloakAdminClient.realm(realmName); + } + + private UsersResource getUsersResource() { + return getRealmResource().users(); + } + + @Override + public List getAllUsers(Integer firstResult, Integer maxResults) { + log.debug("Fetching all users from Keycloak. First result: {}, Max results: {}", firstResult, maxResults); + try { + List userRepresentations = getUsersResource().list(firstResult, maxResults); + return userMapper.toDtoList(userRepresentations); + } catch (WebApplicationException e) { + log.error("Keycloak admin error while fetching all users: {}", e.getMessage(), e); + throw new KeycloakAdminException("Error fetching users from Keycloak: " + e.getMessage(), e); + } + } + + @Override + public Optional getUserById(String userId) { + log.debug("Fetching user by ID: {}", userId); + try { + UserResource userResource = getUsersResource().get(userId); + UserRepresentation userRepresentation = userResource.toRepresentation(); + // Enhance with realm roles + List realmRoles = userResource.roles().realmLevel().listEffective(); + userRepresentation.setRealmRoles(realmRoles.stream().map(RoleRepresentation::getName).collect(Collectors.toList())); + return Optional.of(userMapper.toDto(userRepresentation)); + } catch (jakarta.ws.rs.NotFoundException e) { + log.warn("User not found in Keycloak with ID: {}", userId); + return Optional.empty(); + } catch (WebApplicationException e) { + log.error("Keycloak admin error while fetching user by ID {}: {}", userId, e.getMessage(), e); + throw new KeycloakAdminException("Error fetching user " + userId + " from Keycloak: " + e.getMessage(), e); + } + } + + @Override + public Optional getUserByUsername(String username) { + log.debug("Fetching user by username: {}", username); + try { + List users = getUsersResource().searchByUsername(username, true); + if (users.isEmpty()) { + return Optional.empty(); + } + // Assuming username is unique, take the first one. + UserRepresentation userRepresentation = users.get(0); + UserResource userResource = getUsersResource().get(userRepresentation.getId()); + List realmRoles = userResource.roles().realmLevel().listEffective(); + userRepresentation.setRealmRoles(realmRoles.stream().map(RoleRepresentation::getName).collect(Collectors.toList())); + return Optional.of(userMapper.toDto(userRepresentation)); + } catch (WebApplicationException e) { + log.error("Keycloak admin error while fetching user by username {}: {}", username, e.getMessage(), e); + throw new KeycloakAdminException("Error fetching user " + username + " from Keycloak: " + e.getMessage(), e); + } + } + + @Override + public UserDTO createUser(UserDTO userDTO) { + log.info("Creating new user in Keycloak with username: {}", userDTO.getUsername()); + UserRepresentation userRepresentation = userMapper.toRepresentation(userDTO); + userRepresentation.setEnabled(userDTO.isEnabled()); // Ensure enabled status is set + + try (Response response = getUsersResource().create(userRepresentation)) { + if (response.getStatus() == Response.Status.CREATED.getStatusCode()) { + String createdUserId = response.getLocation().getPath().replaceAll(".*/([^/]+)$", "$1"); + log.info("User created successfully in Keycloak with ID: {}", createdUserId); + + // Assign roles if provided + if (userDTO.getRoles() != null && !userDTO.getRoles().isEmpty()) { + assignRealmRolesToUser(createdUserId, userDTO.getRoles().stream().collect(Collectors.toList())); + } + return getUserById(createdUserId).orElseThrow(() -> + new KeycloakAdminException("Failed to retrieve newly created user " + createdUserId, null)); + } else if (response.getStatus() == Response.Status.CONFLICT.getStatusCode()) { + log.warn("User creation conflict in Keycloak (username/email might exist): {}", userDTO.getUsername()); + throw new ConflictException("User with username '" + userDTO.getUsername() + "' or email '" + userDTO.getEmail() + "' may already exist."); + } else { + String errorDetails = response.readEntity(String.class); + log.error("Failed to create user in Keycloak. Status: {}, Details: {}", response.getStatus(), errorDetails); + throw new KeycloakAdminException("Failed to create user in Keycloak. Status: " + response.getStatus() + ", Details: " + errorDetails, null); + } + } catch (WebApplicationException e) { + log.error("Keycloak admin error during user creation: {}", e.getMessage(), e); + throw new KeycloakAdminException("Error creating user in Keycloak: " + e.getMessage(), e); + } + } + + @Override + public UserDTO updateUser(String userId, UserDTO userDTO) { + log.info("Updating user in Keycloak with ID: {}", userId); + UserResource userResource = getUsersResource().get(userId); + try { + UserRepresentation existingUserRep = userResource.toRepresentation(); // Fetch current state + + // Map DTO to representation, but don't overwrite username or credentials directly unless password is set + UserRepresentation updatedRep = userMapper.toRepresentation(userDTO); + updatedRep.setUsername(existingUserRep.getUsername()); // Username cannot be changed this way usually + + // Only update password if provided + if (userDTO.getPassword() != null && !userDTO.getPassword().isEmpty()) { + userResource.resetPassword(updatedRep.getCredentials().get(0)); + log.debug("Password updated for user ID: {}", userId); + } + + // Update other attributes + existingUserRep.setFirstName(userDTO.getFirstName()); + existingUserRep.setLastName(userDTO.getLastName()); + existingUserRep.setEmail(userDTO.getEmail()); + existingUserRep.setEnabled(userDTO.isEnabled()); + // Keycloak might require emailVerified to be true if email is changed + // existingUserRep.setEmailVerified(true); + + userResource.update(existingUserRep); + log.info("User attributes updated for ID: {}", userId); + + // Update roles + if (userDTO.getRoles() != null) { + // Get current roles + List currentRealmRoles = userResource.roles().realmLevel().listEffective(); + List currentRoleNames = currentRealmRoles.stream().map(RoleRepresentation::getName).collect(Collectors.toList()); + + // Roles to add + List rolesToAdd = userDTO.getRoles().stream() + .filter(roleName -> !currentRoleNames.contains(roleName)) + .map(roleName -> getRealmResource().roles().get(roleName).toRepresentation()) + .collect(Collectors.toList()); + if (!rolesToAdd.isEmpty()) { + userResource.roles().realmLevel().add(rolesToAdd); + log.debug("Added roles {} to user ID: {}", rolesToAdd.stream().map(RoleRepresentation::getName).collect(Collectors.toList()), userId); + } + + // Roles to remove + List rolesToRemove = currentRealmRoles.stream() + .filter(roleRep -> !userDTO.getRoles().contains(roleRep.getName())) + .collect(Collectors.toList()); + if (!rolesToRemove.isEmpty()) { + userResource.roles().realmLevel().remove(rolesToRemove); + log.debug("Removed roles {} from user ID: {}", rolesToRemove.stream().map(RoleRepresentation::getName).collect(Collectors.toList()), userId); + } + } + + return getUserById(userId).orElseThrow(() -> + new KeycloakAdminException("Failed to retrieve updated user " + userId, null)); + } catch (jakarta.ws.rs.NotFoundException e) { + log.warn("User not found in Keycloak for update, ID: {}", userId); + throw new NotFoundException("User with ID '" + userId + "' not found."); + } catch (WebApplicationException e) { + log.error("Keycloak admin error during user {} update: {}", userId, e.getMessage(), e); + throw new KeycloakAdminException("Error updating user " + userId + " in Keycloak: " + e.getMessage(), e); + } + } + + @Override + public void deleteUser(String userId) { + log.info("Deleting user from Keycloak with ID: {}", userId); + try { + getUsersResource().get(userId).remove(); + log.info("User with ID: {} deleted successfully from Keycloak.", userId); + } catch (jakarta.ws.rs.NotFoundException e) { + log.warn("User not found in Keycloak for deletion, ID: {}", userId); + // Optionally throw NotFoundException or just log if delete is idempotent + throw new NotFoundException("User with ID '" + userId + "' not found for deletion."); + } catch (WebApplicationException e) { + log.error("Keycloak admin error during user {} deletion: {}", userId, e.getMessage(), e); + throw new KeycloakAdminException("Error deleting user " + userId + " from Keycloak: " + e.getMessage(), e); + } + } + + @Override + public void assignRealmRolesToUser(String userId, List roleNames) { + log.info("Assigning realm roles {} to user ID: {}", roleNames, userId); + UserResource userResource = getUsersResource().get(userId); + if (userResource == null) { + throw new NotFoundException("User with ID '" + userId + "' not found for role assignment."); + } + List rolesToAssign = roleNames.stream() + .map(roleName -> { + try { + return getRealmResource().roles().get(roleName).toRepresentation(); + } catch (jakarta.ws.rs.NotFoundException e) { + log.warn("Role '{}' not found in realm '{}' during assignment to user {}", roleName, realmName, userId); + return null; // Or throw an exception for invalid role + } + }) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toList()); + + if (!rolesToAssign.isEmpty()) { + try { + userResource.roles().realmLevel().add(rolesToAssign); + log.info("Successfully assigned roles {} to user ID: {}", roleNames, userId); + } catch (WebApplicationException e) { + log.error("Keycloak admin error assigning roles to user {}: {}", userId, e.getMessage(), e); + throw new KeycloakAdminException("Error assigning roles to user " + userId + ": " + e.getMessage(), e); + } + } + } + + @Override + public List getAvailableRealmRoles() { + log.debug("Fetching all available realm roles from Keycloak realm: {}", realmName); + try { + return getRealmResource().roles().list(); + } catch (WebApplicationException e) { + log.error("Keycloak admin error fetching available realm roles: {}", e.getMessage(), e); + throw new KeycloakAdminException("Error fetching available realm roles: " + e.getMessage(), e); + } + } + + @Override + public List getUserRealmRoles(String userId) { + log.debug("Fetching realm roles for user ID: {}", userId); + try { + UserResource userResource = getUsersResource().get(userId); + return userResource.roles().realmLevel().listEffective(); + } catch (jakarta.ws.rs.NotFoundException e) { + log.warn("User not found in Keycloak with ID: {} when fetching roles", userId); + throw new NotFoundException("User with ID '" + userId + "' not found when fetching roles."); + } catch (WebApplicationException e) { + log.error("Keycloak admin error fetching roles for user {}: {}", userId, e.getMessage(), e); + throw new KeycloakAdminException("Error fetching roles for user " + userId + ": " + e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000000000000000000000000000000000000..90ffa5a0d08d3ee7b9994513025d400391e528f7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,75 @@ +# DALab Admin Service Configuration +spring.application.name=da-admin-service +server.port=8080 + +# Database Configuration - da_admin database +spring.datasource.url=jdbc:postgresql://localhost:5432/da_admin +spring.datasource.username=da_admin_service_user +spring.datasource.password=admin_secure_2024 +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA Configuration +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=false +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect +spring.jpa.properties.hibernate.format_sql=true + +# Common entities database configuration (for da-protos entities) +dalab.common.datasource.url=jdbc:postgresql://localhost:5432/dalab_common +dalab.common.datasource.username=dalab_common_user +dalab.common.datasource.password=dalab_common_pass + +# Kafka Configuration +spring.kafka.bootstrap-servers=localhost:9092 +spring.kafka.consumer.group-id=da-admin-service-group +spring.kafka.consumer.auto-offset-reset=earliest +spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer +spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer +spring.kafka.consumer.properties.spring.json.trusted.packages=* +spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer +spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer + +# Kafka Topics +dalab.kafka.topics.admin-events=dalab.admin.events + +# Security Configuration (Keycloak JWT) +spring.security.oauth2.resourceserver.jwt.issuer-uri=http://localhost:8180/realms/dalab +spring.security.oauth2.resourceserver.jwt.jwk-set-uri=http://localhost:8180/realms/dalab/protocol/openid-connect/certs + +# Keycloak Admin Client Configuration +keycloak.auth-server-url=http://localhost:8180 +keycloak.realm=dalab +keycloak.client-id=admin-cli +keycloak.client-secret= +keycloak.admin.username=admin +keycloak.admin.password=admin + +# OpenFeign Configuration +spring.cloud.openfeign.client.config.default.connect-timeout=5000 +spring.cloud.openfeign.client.config.default.read-timeout=10000 + +# Service URLs for Feign clients +services.da-catalog.url=http://localhost:8082 +services.da-policyengine.url=http://localhost:8083 +services.da-reporting.url=http://localhost:8084 +services.da-autolabel.url=http://localhost:8085 +services.da-autoarchival.url=http://localhost:8086 +services.da-autodelete.url=http://localhost:8087 +services.da-autocompliance.url=http://localhost:8088 + +# Encryption Configuration +app.encryption.key=DefaultTestEncrKey + +# Actuator Configuration +management.endpoints.web.exposure.include=health,info,metrics,prometheus +management.endpoint.health.show-details=when-authorized +management.metrics.export.prometheus.enabled=true + +# OpenAPI Documentation +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html + +# Logging Configuration +logging.level.com.dalab.adminservice=INFO +logging.level.org.springframework.kafka=WARN +logging.level.org.springframework.security=WARN \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/client/IAutoarchivalTaskApiClient.java b/src/test/java/com/dalab/adminservice/client/IAutoarchivalTaskApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..5e89f271c575b89d3e525ea3b2420f5400b69069 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/client/IAutoarchivalTaskApiClient.java @@ -0,0 +1,17 @@ +package com.dalab.adminservice.client; + +import java.util.List; + +import com.dalab.adminservice.dto.JobStatusDTO; + +/** + * Test client interface for Autoarchival Task API + */ +public interface IAutoarchivalTaskApiClient { + + /** + * Get all archival tasks status + * @return List of job status DTOs + */ + List getArchivalTasks(); +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/client/IAutocomplianceJobApiClient.java b/src/test/java/com/dalab/adminservice/client/IAutocomplianceJobApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..86e0cda9b6e2de139344dee3cec2e36f2fdd7a7f --- /dev/null +++ b/src/test/java/com/dalab/adminservice/client/IAutocomplianceJobApiClient.java @@ -0,0 +1,17 @@ +package com.dalab.adminservice.client; + +import java.util.List; + +import com.dalab.adminservice.dto.JobStatusDTO; + +/** + * Test client interface for Autocompliance Job API + */ +public interface IAutocomplianceJobApiClient { + + /** + * Get all compliance report jobs status + * @return List of job status DTOs + */ + List getComplianceReportJobs(); +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/client/IAutodeleteTaskApiClient.java b/src/test/java/com/dalab/adminservice/client/IAutodeleteTaskApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..4db549d4e5ad5e6ab81307a3da29964e7b1ccff4 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/client/IAutodeleteTaskApiClient.java @@ -0,0 +1,17 @@ +package com.dalab.adminservice.client; + +import java.util.List; + +import com.dalab.adminservice.dto.JobStatusDTO; + +/** + * Test client interface for Autodelete Task API + */ +public interface IAutodeleteTaskApiClient { + + /** + * Get all deletion tasks status + * @return List of job status DTOs + */ + List getDeletionTasks(); +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/client/IAutolabelJobApiClient.java b/src/test/java/com/dalab/adminservice/client/IAutolabelJobApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..b6c128b403f8c48be9a16d8726f0e7c97b9cc85e --- /dev/null +++ b/src/test/java/com/dalab/adminservice/client/IAutolabelJobApiClient.java @@ -0,0 +1,16 @@ +package com.dalab.adminservice.client; + +import com.dalab.adminservice.dto.JobStatusDTO; +import java.util.List; + +/** + * Test client interface for Autolabel Job API + */ +public interface IAutolabelJobApiClient { + + /** + * Get all labeling jobs status + * @return List of job status DTOs + */ + List getAutolabelJobs(); +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/client/IDiscoveryJobApiClient.java b/src/test/java/com/dalab/adminservice/client/IDiscoveryJobApiClient.java new file mode 100644 index 0000000000000000000000000000000000000000..2b96892b4b27b1137f94fd2b064d48d0f75f2096 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/client/IDiscoveryJobApiClient.java @@ -0,0 +1,16 @@ +package com.dalab.adminservice.client; + +import com.dalab.adminservice.dto.JobStatusDTO; +import java.util.List; + +/** + * Test client interface for Discovery Job API + */ +public interface IDiscoveryJobApiClient { + + /** + * Get all discovery jobs status + * @return List of job status DTOs + */ + List getDiscoveryJobs(); +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/config/TestSecurityConfiguration.java b/src/test/java/com/dalab/adminservice/config/TestSecurityConfiguration.java new file mode 100644 index 0000000000000000000000000000000000000000..be93ec837df40227f523ef3930adc35d0fa7575f --- /dev/null +++ b/src/test/java/com/dalab/adminservice/config/TestSecurityConfiguration.java @@ -0,0 +1,44 @@ +package com.dalab.adminservice.config; + +import static org.mockito.Mockito.*; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.web.SecurityFilterChain; + +/** + * Test security configuration for da-admin-service tests. + * Enables method-level security to test @PreAuthorize annotations. + */ +@TestConfiguration +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +public class TestSecurityConfiguration { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authz -> authz + .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.decoder(jwtDecoder())) + ); + + return http.build(); + } + + @Bean + public JwtDecoder jwtDecoder() { + return mock(JwtDecoder.class); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/controller/CloudConnectionControllerTest.java b/src/test/java/com/dalab/adminservice/controller/CloudConnectionControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..6e8c72cf36170e289e34269d4b3108ebdd595acc --- /dev/null +++ b/src/test/java/com/dalab/adminservice/controller/CloudConnectionControllerTest.java @@ -0,0 +1,153 @@ +package com.dalab.adminservice.controller; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.dalab.adminservice.config.TestSecurityConfiguration; +import com.dalab.adminservice.dto.CloudConnectionDTO; +import com.dalab.adminservice.dto.CloudConnectionTestResultDTO; +import com.dalab.adminservice.model.enums.CloudProviderType; +import com.dalab.adminservice.service.ICloudConnectionService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@WebMvcTest(CloudConnectionController.class) +@Import(TestSecurityConfiguration.class) +@WithMockUser(roles = "ADMIN") +class CloudConnectionControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private ICloudConnectionService cloudConnectionService; + + @Autowired + private ObjectMapper objectMapper; + + private CloudConnectionDTO cloudConnectionDTO; + private String connectionId; + + @BeforeEach + void setUp() { + connectionId = "test-connection-id"; + cloudConnectionDTO = CloudConnectionDTO.builder() + .id(connectionId) + .name("Test GCP Connection") + .providerType(CloudProviderType.GCP) + .connectionParameters(Map.of("projectId", "test-project")) + .sensitiveCredentials("{\"type\": \"service_account\"}") // Example, this won't be returned + .enabled(true) + .build(); + } + + @Test + void getAllCloudConnections_shouldReturnListOfConnections() throws Exception { + given(cloudConnectionService.getAllCloudConnections()).willReturn(Collections.singletonList(cloudConnectionDTO)); + + mockMvc.perform(get("/api/v1/admin/cloud-connections")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("Test GCP Connection")); + } + + @Test + void getCloudConnectionById_whenExists_shouldReturnConnection() throws Exception { + given(cloudConnectionService.getCloudConnectionById(connectionId)).willReturn(Optional.of(cloudConnectionDTO)); + + mockMvc.perform(get("/api/v1/admin/cloud-connections/" + connectionId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Test GCP Connection")); + } + + @Test + void getCloudConnectionById_whenNotExists_shouldReturnNotFound() throws Exception { + given(cloudConnectionService.getCloudConnectionById("unknown-id")).willReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/admin/cloud-connections/unknown-id")) + .andExpect(status().isNotFound()); + } + + @Test + void createCloudConnection_shouldReturnCreatedConnection() throws Exception { + given(cloudConnectionService.createCloudConnection(any(CloudConnectionDTO.class))).willReturn(cloudConnectionDTO); + + mockMvc.perform(post("/api/v1/admin/cloud-connections").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cloudConnectionDTO))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(connectionId)); + } + + @Test + void updateCloudConnection_shouldReturnUpdatedConnection() throws Exception { + CloudConnectionDTO updatedDto = CloudConnectionDTO.builder() + .id(connectionId) + .name("Updated GCP Connection") + .providerType(CloudProviderType.GCP) + .connectionParameters(Map.of("projectId", "updated-project")) + .enabled(true) + .build(); + given(cloudConnectionService.updateCloudConnection(eq(connectionId), any(CloudConnectionDTO.class))).willReturn(updatedDto); + + CloudConnectionDTO requestDto = CloudConnectionDTO.builder() + .name("Updated GCP Connection") + .providerType(CloudProviderType.GCP) + .build(); + mockMvc.perform(put("/api/v1/admin/cloud-connections/" + connectionId).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(requestDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("Updated GCP Connection")); + } + + @Test + void deleteCloudConnection_shouldReturnNoContent() throws Exception { + doNothing().when(cloudConnectionService).deleteCloudConnection(connectionId); + + mockMvc.perform(delete("/api/v1/admin/cloud-connections/" + connectionId).with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + void testCloudConnection_shouldReturnTestResult() throws Exception { + CloudConnectionTestResultDTO testResult = CloudConnectionTestResultDTO.builder() + .success(true) + .message("Connection successful") + .build(); + given(cloudConnectionService.testCloudConnection(connectionId)).willReturn(testResult); + + mockMvc.perform(post("/api/v1/admin/cloud-connections/" + connectionId + "/test").with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.message").value("Connection successful")); + } + + @Test + @WithMockUser(roles = "VIEWER") // Non-admin + void createCloudConnection_whenUnauthorized_shouldReturnForbidden() throws Exception { + mockMvc.perform(post("/api/v1/admin/cloud-connections").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(cloudConnectionDTO))) + .andExpect(status().isForbidden()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/controller/JobStatusControllerTest.java b/src/test/java/com/dalab/adminservice/controller/JobStatusControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..069b3898e5de5d5500a5b717dc5500f7cead3aa9 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/controller/JobStatusControllerTest.java @@ -0,0 +1,59 @@ +package com.dalab.adminservice.controller; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.dalab.adminservice.config.TestSecurityConfiguration; +import com.dalab.adminservice.dto.AggregatedJobStatusDTO; +import com.dalab.adminservice.service.IJobStatusService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@WebMvcTest(JobStatusController.class) +@Import(TestSecurityConfiguration.class) +class JobStatusControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private IJobStatusService jobStatusService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @WithMockUser(roles = {"ADMIN", "VIEWER"}) + void getAggregatedJobStatuses_shouldReturnAggregatedStatuses() throws Exception { + AggregatedJobStatusDTO aggregatedStatus = AggregatedJobStatusDTO.builder() + .jobs(Collections.emptyList()) + .totalJobs(0) + .build(); + + given(jobStatusService.getAggregatedJobStatuses()).willReturn(aggregatedStatus); + + mockMvc.perform(get("/api/v1/admin/job-statuses") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalJobs").value(0)); + } + + @Test + @WithMockUser(roles = "USER") // A user without ADMIN or VIEWER role + void getAggregatedJobStatuses_whenUnauthorized_shouldReturnForbidden() throws Exception { + mockMvc.perform(get("/api/v1/admin/job-statuses") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/controller/RoleControllerTest.java b/src/test/java/com/dalab/adminservice/controller/RoleControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..21f48eb6a400ffd2c77e93f8c783694ad6d09714 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/controller/RoleControllerTest.java @@ -0,0 +1,68 @@ +package com.dalab.adminservice.controller; + +import com.dalab.adminservice.config.TestSecurityConfiguration; +import com.dalab.adminservice.dto.RoleDTO; +import com.dalab.adminservice.service.IRoleService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; + +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.Matchers.hasSize; + +@WebMvcTest(RoleController.class) +@Import(TestSecurityConfiguration.class) +@WithMockUser(roles = "ADMIN") +class RoleControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private IRoleService roleService; + + @Autowired + private ObjectMapper objectMapper; + + private RoleDTO roleDTO; + + @BeforeEach + void setUp() { + roleDTO = RoleDTO.builder() + .id("role-id-1") + .name("VIEWER") + .description("Viewer role") + .build(); + } + + @Test + void getAllRealmRoles_shouldReturnListOfRoles() throws Exception { + given(roleService.getAllRealmRoles()).willReturn(Collections.singletonList(roleDTO)); + + mockMvc.perform(get("/api/v1/admin/roles") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].name").value("VIEWER")); + } + + @Test + @WithMockUser(roles = "USER") // Non-admin + void getAllRealmRoles_whenUnauthorized_shouldReturnForbidden() throws Exception { + mockMvc.perform(get("/api/v1/admin/roles") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/com/dalab/adminservice/controller/ServiceConfigControllerTest.java b/src/test/java/com/dalab/adminservice/controller/ServiceConfigControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..6b301e12acf32227782dfeb7031c804d73aa71cd --- /dev/null +++ b/src/test/java/com/dalab/adminservice/controller/ServiceConfigControllerTest.java @@ -0,0 +1,114 @@ +package com.dalab.adminservice.controller; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Collections; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.dalab.adminservice.config.TestSecurityConfiguration; +import com.dalab.adminservice.dto.ServiceConfigDTO; +import com.dalab.adminservice.service.IServiceConfigService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@WebMvcTest(ServiceConfigController.class) +@Import(TestSecurityConfiguration.class) +class ServiceConfigControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private IServiceConfigService serviceConfigService; + + @Autowired + private ObjectMapper objectMapper; + + private ServiceConfigDTO serviceConfigDTO; + + @BeforeEach + void setUp() { + serviceConfigDTO = ServiceConfigDTO.builder() + .serviceId("test-service") + .displayName("Test Service") + .endpoint("http://localhost:1234") + .enabled(true) + .build(); + } + + @Test + @WithMockUser(roles = "ADMIN") + void getAllServiceConfigs_shouldReturnListOfConfigs() throws Exception { + given(serviceConfigService.getAllServiceConfigs()).willReturn(Collections.singletonList(serviceConfigDTO)); + + mockMvc.perform(get("/api/v1/admin/config/services")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].serviceId").value("test-service")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void getServiceConfigById_whenExists_shouldReturnConfig() throws Exception { + given(serviceConfigService.getServiceConfigById("test-service")).willReturn(Optional.of(serviceConfigDTO)); + + mockMvc.perform(get("/api/v1/admin/config/services/test-service")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("Test Service")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void getServiceConfigById_whenNotExists_shouldReturnNotFound() throws Exception { + given(serviceConfigService.getServiceConfigById("unknown-service")).willReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/admin/config/services/unknown-service")) + .andExpect(status().isNotFound()); + } + + @Test + @WithMockUser(roles = "ADMIN") + void createServiceConfig_shouldReturnCreatedConfig() throws Exception { + given(serviceConfigService.createServiceConfig(any(ServiceConfigDTO.class))).willReturn(serviceConfigDTO); + + mockMvc.perform(post("/api/v1/admin/config/services").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(serviceConfigDTO))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.serviceId").value("test-service")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void updateServiceConfig_shouldReturnUpdatedConfig() throws Exception { + ServiceConfigDTO updatedDTO = ServiceConfigDTO.builder().serviceId("test-service").displayName("Updated Name").build(); + given(serviceConfigService.updateServiceConfig(eq("test-service"), any(ServiceConfigDTO.class))).willReturn(updatedDTO); + + mockMvc.perform(put("/api/v1/admin/config/services/test-service").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updatedDTO))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.displayName").value("Updated Name")); + } + + @Test + @WithMockUser(roles = "USER") // Non-admin user + void createServiceConfig_whenUnauthorized_shouldReturnForbidden() throws Exception { + mockMvc.perform(post("/api/v1/admin/config/services").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(serviceConfigDTO))) + .andExpect(status().isForbidden()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/controller/UserControllerTest.java b/src/test/java/com/dalab/adminservice/controller/UserControllerTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5fef68ad50be8a75c55d4ee50c71edd132a6ddcb --- /dev/null +++ b/src/test/java/com/dalab/adminservice/controller/UserControllerTest.java @@ -0,0 +1,158 @@ +package com.dalab.adminservice.controller; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.keycloak.representations.idm.RoleRepresentation; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import com.dalab.adminservice.config.TestSecurityConfiguration; +import com.dalab.adminservice.dto.UserDTO; +import com.dalab.adminservice.service.IUserService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@WebMvcTest(UserController.class) +@Import(TestSecurityConfiguration.class) +@WithMockUser(roles = "ADMIN") // Apply to all tests in this class for brevity, can be overridden +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private IUserService userService; + + @Autowired + private ObjectMapper objectMapper; + + private UserDTO userDTO; + + @BeforeEach + void setUp() { + userDTO = UserDTO.builder() + .id(UUID.randomUUID().toString()) + .username("testuser") + .email("test@example.com") + .firstName("Test") + .lastName("User") + .enabled(true) + .roles(List.of("USER")) + .build(); + } + + @Test + void getAllUsers_shouldReturnListOfUsers() throws Exception { + given(userService.getAllUsers(null, null)).willReturn(Collections.singletonList(userDTO)); + + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].username").value("testuser")); + } + + @Test + void getUserById_whenExists_shouldReturnUser() throws Exception { + given(userService.getUserById(userDTO.getId())).willReturn(Optional.of(userDTO)); + + mockMvc.perform(get("/api/v1/admin/users/" + userDTO.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("testuser")); + } + + @Test + void getUserById_whenNotExists_shouldReturnNotFound() throws Exception { + given(userService.getUserById("unknown-id")).willReturn(Optional.empty()); + + mockMvc.perform(get("/api/v1/admin/users/unknown-id")) + .andExpect(status().isNotFound()); + } + + @Test + void getUserByUsername_whenExists_shouldReturnUser() throws Exception { + given(userService.getUserByUsername("testuser")).willReturn(Optional.of(userDTO)); + + mockMvc.perform(get("/api/v1/admin/users/username/testuser")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(userDTO.getId())); + } + + @Test + void createUser_shouldReturnCreatedUser() throws Exception { + given(userService.createUser(any(UserDTO.class))).willReturn(userDTO); + + mockMvc.perform(post("/api/v1/admin/users").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userDTO))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.username").value("testuser")); + } + + @Test + void updateUser_shouldReturnUpdatedUser() throws Exception { + UserDTO updatedDto = UserDTO.builder().username("updateduser").build(); + given(userService.updateUser(eq(userDTO.getId()), any(UserDTO.class))).willReturn(updatedDto); + + mockMvc.perform(put("/api/v1/admin/users/" + userDTO.getId()).with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updatedDto))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.username").value("updateduser")); + } + + @Test + void deleteUser_shouldReturnNoContent() throws Exception { + doNothing().when(userService).deleteUser(userDTO.getId()); + + mockMvc.perform(delete("/api/v1/admin/users/" + userDTO.getId()).with(csrf())) + .andExpect(status().isNoContent()); + } + + @Test + void getUserRealmRoles_shouldReturnRoles() throws Exception { + RoleRepresentation roleRep = new RoleRepresentation("USER", "User role", false); + given(userService.getUserRealmRoles(userDTO.getId())).willReturn(Collections.singletonList(roleRep)); + + mockMvc.perform(get("/api/v1/admin/users/" + userDTO.getId() + "/roles")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].name").value("USER")); + } + + @Test + void assignRealmRolesToUser_shouldReturnNoContent() throws Exception { + List rolesToAssign = List.of("USER", "EDITOR"); + doNothing().when(userService).assignRealmRolesToUser(userDTO.getId(), rolesToAssign); + + mockMvc.perform(post("/api/v1/admin/users/" + userDTO.getId() + "/roles").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(rolesToAssign))) + .andExpect(status().isNoContent()); + } + + @Test + @WithMockUser(roles = "VIEWER") // Non-admin user for a modification endpoint + void createUser_whenUnauthorized_shouldReturnForbidden() throws Exception { + mockMvc.perform(post("/api/v1/admin/users").with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userDTO))) + .andExpect(status().isForbidden()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/service/impl/CloudConnectionServiceImplTest.java b/src/test/java/com/dalab/adminservice/service/impl/CloudConnectionServiceImplTest.java new file mode 100644 index 0000000000000000000000000000000000000000..ec8200169f8bdfbce036875e3f24fd895f10f09c --- /dev/null +++ b/src/test/java/com/dalab/adminservice/service/impl/CloudConnectionServiceImplTest.java @@ -0,0 +1,242 @@ +package com.dalab.adminservice.service.impl; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dalab.adminservice.dto.CloudConnectionDTO; +import com.dalab.adminservice.dto.CloudConnectionTestResultDTO; +import com.dalab.adminservice.exception.ConflictException; +import com.dalab.adminservice.exception.NotFoundException; +import com.dalab.adminservice.mapper.CloudConnectionMapper; +import com.dalab.adminservice.model.CloudConnectionEntity; +import com.dalab.adminservice.model.enums.CloudProviderType; +import com.dalab.adminservice.repository.CloudConnectionRepository; +import com.dalab.adminservice.service.IEncryptionService; + +@ExtendWith(MockitoExtension.class) +class CloudConnectionServiceImplTest { + + @Mock + private CloudConnectionRepository cloudConnectionRepository; + @Mock + private CloudConnectionMapper cloudConnectionMapper; + @Mock + private IEncryptionService encryptionService; + + @InjectMocks + private CloudConnectionServiceImpl cloudConnectionService; + + private CloudConnectionEntity connectionEntity; + private CloudConnectionDTO connectionDTO; + private String connectionId = "test-conn-id"; + private String sensitiveData = "supersecret"; + private String encryptedData = "encryptedSuperSecret"; + + @BeforeEach + void setUp() { + connectionEntity = new CloudConnectionEntity(); + connectionEntity.setId(connectionId); + connectionEntity.setName("TestConnection"); + connectionEntity.setProviderType(CloudProviderType.GCP); + connectionEntity.setEncryptedCredentials(encryptedData); + connectionEntity.setConnectionParameters(Map.of("projectId", "gcp-project")); + connectionEntity.setEnabled(true); + + connectionDTO = CloudConnectionDTO.builder() + .id(connectionId) + .name("TestConnection") + .providerType(CloudProviderType.GCP) + .connectionParameters(Map.of("projectId", "gcp-project")) + .sensitiveCredentials(sensitiveData) // DTO carries raw sensitive data for creation/update + .enabled(true) + .build(); + } + + @Test + void getAllCloudConnections_shouldReturnDtoList() { + when(cloudConnectionRepository.findAll()).thenReturn(Collections.singletonList(connectionEntity)); + when(cloudConnectionMapper.toDtoList(anyList())).thenReturn(Collections.singletonList(connectionDTO)); + + List result = cloudConnectionService.getAllCloudConnections(); + + assertThat(result).isNotNull().hasSize(1); + assertThat(result.get(0).getName()).isEqualTo(connectionDTO.getName()); + verify(cloudConnectionRepository).findAll(); + } + + @Test + void getCloudConnectionById_whenFound_shouldReturnDto() { + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + when(cloudConnectionMapper.toDto(connectionEntity)).thenReturn(connectionDTO); + + Optional result = cloudConnectionService.getCloudConnectionById(connectionId); + + assertThat(result).isPresent(); + assertThat(result.get().getName()).isEqualTo(connectionDTO.getName()); + } + + @Test + void createCloudConnection_shouldEncryptCredentialsAndSave() { + when(cloudConnectionRepository.findByName(connectionDTO.getName())).thenReturn(Optional.empty()); + when(cloudConnectionMapper.toEntity(connectionDTO)).thenReturn(connectionEntity); // Assume mapper creates entity without encrypted creds + when(encryptionService.encrypt(sensitiveData)).thenReturn(encryptedData); + when(cloudConnectionRepository.save(any(CloudConnectionEntity.class))).thenReturn(connectionEntity); + when(cloudConnectionMapper.toDto(connectionEntity)).thenReturn(connectionDTO); // DTO returned should not have sensitive creds + + CloudConnectionDTO result = cloudConnectionService.createCloudConnection(connectionDTO); + + assertThat(result).isNotNull(); + assertThat(result.getName()).isEqualTo(connectionDTO.getName()); + verify(encryptionService).encrypt(sensitiveData); + verify(cloudConnectionRepository).save(argThat(entity -> entity.getEncryptedCredentials().equals(encryptedData))); + } + + @Test + void createCloudConnection_withNullSensitiveCredentials_shouldNotEncrypt() { + CloudConnectionDTO dtoWithNullCreds = CloudConnectionDTO.builder().name("NoCredsConn").sensitiveCredentials(null).build(); + CloudConnectionEntity entityFromMapper = new CloudConnectionEntity(); + entityFromMapper.setName("NoCredsConn"); + + when(cloudConnectionRepository.findByName("NoCredsConn")).thenReturn(Optional.empty()); + when(cloudConnectionMapper.toEntity(dtoWithNullCreds)).thenReturn(entityFromMapper); + when(cloudConnectionRepository.save(any(CloudConnectionEntity.class))).thenReturn(entityFromMapper); + when(cloudConnectionMapper.toDto(entityFromMapper)).thenReturn(dtoWithNullCreds); + + cloudConnectionService.createCloudConnection(dtoWithNullCreds); + + verify(encryptionService, never()).encrypt(any()); + verify(cloudConnectionRepository).save(argThat(entity -> entity.getEncryptedCredentials() == null)); + } + + @Test + void createCloudConnection_whenNameExists_shouldThrowConflictException() { + when(cloudConnectionRepository.findByName(connectionDTO.getName())).thenReturn(Optional.of(new CloudConnectionEntity())); + assertThrows(ConflictException.class, () -> cloudConnectionService.createCloudConnection(connectionDTO)); + verify(cloudConnectionRepository, never()).save(any()); + } + + @Test + void updateCloudConnection_shouldUpdateAndEncryptIfCredentialsProvided() { + CloudConnectionDTO updateDto = CloudConnectionDTO.builder() + .name("UpdatedName") + .sensitiveCredentials("newSecret") + .build(); + String newEncryptedSecret = "newEncryptedSecret"; + + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + when(encryptionService.encrypt("newSecret")).thenReturn(newEncryptedSecret); + when(cloudConnectionRepository.save(any(CloudConnectionEntity.class))).thenReturn(connectionEntity); + when(cloudConnectionMapper.toDto(connectionEntity)).thenReturn(CloudConnectionDTO.builder().name("UpdatedName").build()); + doNothing().when(cloudConnectionMapper).updateEntityFromDto(updateDto, connectionEntity); + + CloudConnectionDTO result = cloudConnectionService.updateCloudConnection(connectionId, updateDto); + + assertThat(result.getName()).isEqualTo("UpdatedName"); + verify(cloudConnectionMapper).updateEntityFromDto(updateDto, connectionEntity); + verify(encryptionService).encrypt("newSecret"); + verify(cloudConnectionRepository).save(argThat(entity -> entity.getEncryptedCredentials().equals(newEncryptedSecret))); + } + + @Test + void updateCloudConnection_whenNameChangedToExisting_shouldThrowConflict() { + CloudConnectionDTO updateDto = CloudConnectionDTO.builder().name("ExistingOtherName").build(); + CloudConnectionEntity otherEntity = new CloudConnectionEntity(); + otherEntity.setId("other-id"); + otherEntity.setName("ExistingOtherName"); + + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + when(cloudConnectionRepository.findByName("ExistingOtherName")).thenReturn(Optional.of(otherEntity)); + + assertThrows(ConflictException.class, () -> cloudConnectionService.updateCloudConnection(connectionId, updateDto)); + verify(cloudConnectionRepository, never()).save(any()); + } + + @Test + void deleteCloudConnection_shouldDelete() { + when(cloudConnectionRepository.existsById(connectionId)).thenReturn(true); + doNothing().when(cloudConnectionRepository).deleteById(connectionId); + cloudConnectionService.deleteCloudConnection(connectionId); + verify(cloudConnectionRepository).deleteById(connectionId); + } + + @Test + void deleteCloudConnection_whenNotFound_shouldThrowNotFoundException() { + when(cloudConnectionRepository.existsById(connectionId)).thenReturn(false); + assertThrows(NotFoundException.class, () -> cloudConnectionService.deleteCloudConnection(connectionId)); + } + + @Test + void testCloudConnection_shouldDecryptAndReturnResult() { + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + when(encryptionService.decrypt(encryptedData)).thenReturn(sensitiveData); + // Assume save is called to update last test status + when(cloudConnectionRepository.save(any(CloudConnectionEntity.class))).thenReturn(connectionEntity); + + CloudConnectionTestResultDTO result = cloudConnectionService.testCloudConnection(connectionId); + + assertThat(result.isSuccess()).isTrue(); // Simulated success + assertThat(result.getMessage()).contains("Simulated connection test successful"); + verify(encryptionService).decrypt(encryptedData); + verify(cloudConnectionRepository).save(argThat(entity -> + entity.getLastConnectionTestStatus().equals("SUCCESS") && + entity.getLastConnectionTestAt() != null + )); + } + + @Test + void testCloudConnection_whenNoCredentials_shouldFailTest() { + connectionEntity.setEncryptedCredentials(null); + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + when(encryptionService.decrypt(null)).thenReturn(null); // Service will call decrypt with null + when(cloudConnectionRepository.save(any(CloudConnectionEntity.class))).thenReturn(connectionEntity); + + CloudConnectionTestResultDTO result = cloudConnectionService.testCloudConnection(connectionId); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.getMessage()).contains("No credentials to test"); + verify(encryptionService).decrypt(null); // Service does call decrypt, but with null + verify(cloudConnectionRepository).save(argThat(entity -> + entity.getLastConnectionTestStatus().equals("FAILED") + )); + } + + @Test + void getDecryptedCredentials_shouldReturnDecryptedString() { + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + when(encryptionService.decrypt(encryptedData)).thenReturn(sensitiveData); + + String result = cloudConnectionService.getDecryptedCredentials(connectionId); + + assertThat(result).isEqualTo(sensitiveData); + verify(encryptionService).decrypt(encryptedData); + } + + @Test + void getDecryptedCredentials_whenEntityNotFound_shouldThrowNotFound() { + when(cloudConnectionRepository.findById("unknown")).thenReturn(Optional.empty()); + assertThrows(NotFoundException.class, () -> cloudConnectionService.getDecryptedCredentials("unknown")); + } + + @Test + void getDecryptedCredentials_whenNoEncryptedData_shouldReturnNull() { + connectionEntity.setEncryptedCredentials(null); + when(cloudConnectionRepository.findById(connectionId)).thenReturn(Optional.of(connectionEntity)); + String result = cloudConnectionService.getDecryptedCredentials(connectionId); + assertThat(result).isNull(); + verify(encryptionService, never()).decrypt(any()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/service/impl/JobStatusServiceImplTest.java b/src/test/java/com/dalab/adminservice/service/impl/JobStatusServiceImplTest.java new file mode 100644 index 0000000000000000000000000000000000000000..d9b8c47e14d2c0fdab24651ce508e18717b3dcb5 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/service/impl/JobStatusServiceImplTest.java @@ -0,0 +1,114 @@ +package com.dalab.adminservice.service.impl; + +import com.dalab.adminservice.client.*; +import com.dalab.adminservice.dto.AggregatedJobStatusDTO; +import com.dalab.adminservice.dto.JobStatusDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class JobStatusServiceImplTest { + + @Mock + private IDiscoveryJobApiClient discoveryJobApiClient; + @Mock + private IAutolabelJobApiClient autolabelJobApiClient; + @Mock + private IAutoarchivalTaskApiClient autoarchivalTaskApiClient; + @Mock + private IAutodeleteTaskApiClient autodeleteTaskApiClient; + @Mock + private IAutocomplianceJobApiClient autocomplianceJobApiClient; + + @InjectMocks + private JobStatusServiceImpl jobStatusService; + + private JobStatusDTO sampleJob1, sampleJob2; + + @BeforeEach + void setUp() { + sampleJob1 = JobStatusDTO.builder() + .jobId("job1") + .serviceName("TestService1") + .status("COMPLETED_SUCCESS") + .submittedAt(LocalDateTime.now().minusHours(2)) + .completedAt(LocalDateTime.now().minusHours(1)) + .build(); + + sampleJob2 = JobStatusDTO.builder() + .jobId("job2") + .serviceName("TestService2") + .status("PENDING") + .submittedAt(LocalDateTime.now().minusMinutes(30)) + .build(); + } + + @Test + void getAggregatedJobStatuses_shouldAggregateFromAllClients() { + when(discoveryJobApiClient.getDiscoveryJobs()).thenReturn(List.of(sampleJob1)); + when(autolabelJobApiClient.getAutolabelJobs()).thenReturn(List.of(sampleJob2)); + when(autoarchivalTaskApiClient.getArchivalTasks()).thenReturn(Collections.emptyList()); + when(autodeleteTaskApiClient.getDeletionTasks()).thenReturn(Collections.emptyList()); + when(autocomplianceJobApiClient.getComplianceReportJobs()).thenReturn(Collections.emptyList()); + + AggregatedJobStatusDTO result = jobStatusService.getAggregatedJobStatuses(); + + assertNotNull(result); + assertEquals(2, result.getTotalJobs()); + assertEquals(1, result.getCompletedSuccessJobs()); + assertEquals(1, result.getPendingJobs()); + assertEquals(0, result.getRunningJobs()); + assertEquals(0, result.getCompletedFailedJobs()); + assertNotNull(result.getJobs()); + assertEquals(2, result.getJobs().size()); + } + + @Test + void getAggregatedJobStatuses_whenClientThrowsException_shouldHandleGracefully() { + when(discoveryJobApiClient.getDiscoveryJobs()).thenThrow(new RuntimeException("Discovery Unreachable")); + when(autolabelJobApiClient.getAutolabelJobs()).thenReturn(List.of(sampleJob2)); // Autolabel still works + when(autoarchivalTaskApiClient.getArchivalTasks()).thenReturn(Collections.emptyList()); + when(autodeleteTaskApiClient.getDeletionTasks()).thenReturn(Collections.emptyList()); + when(autocomplianceJobApiClient.getComplianceReportJobs()).thenReturn(Collections.emptyList()); + + AggregatedJobStatusDTO result = jobStatusService.getAggregatedJobStatuses(); + + assertNotNull(result); + assertEquals(1, result.getTotalJobs()); // Only sampleJob2 from autolabel + assertEquals(0, result.getCompletedSuccessJobs()); + assertEquals(1, result.getPendingJobs()); + assertNotNull(result.getJobs()); + assertEquals(1, result.getJobs().size()); + assertEquals("job2", result.getJobs().get(0).getJobId()); + } + + @Test + void getAggregatedJobStatuses_whenAllClientsReturnEmpty_shouldReturnEmptyAggregation() { + when(discoveryJobApiClient.getDiscoveryJobs()).thenReturn(Collections.emptyList()); + when(autolabelJobApiClient.getAutolabelJobs()).thenReturn(Collections.emptyList()); + when(autoarchivalTaskApiClient.getArchivalTasks()).thenReturn(Collections.emptyList()); + when(autodeleteTaskApiClient.getDeletionTasks()).thenReturn(Collections.emptyList()); + when(autocomplianceJobApiClient.getComplianceReportJobs()).thenReturn(Collections.emptyList()); + + AggregatedJobStatusDTO result = jobStatusService.getAggregatedJobStatuses(); + + assertNotNull(result); + assertEquals(0, result.getTotalJobs()); + assertEquals(0, result.getPendingJobs()); + assertEquals(0, result.getCompletedSuccessJobs()); + assertNotNull(result.getJobs()); + assertEquals(0, result.getJobs().size()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/service/impl/RoleServiceImplTest.java b/src/test/java/com/dalab/adminservice/service/impl/RoleServiceImplTest.java new file mode 100644 index 0000000000000000000000000000000000000000..e690231c248eb5e2819076deb0421a12d20f6988 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/service/impl/RoleServiceImplTest.java @@ -0,0 +1,78 @@ +package com.dalab.adminservice.service.impl; + +import com.dalab.adminservice.dto.RoleDTO; +import com.dalab.adminservice.mapper.RoleMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RolesResource; +import org.keycloak.representations.idm.RoleRepresentation; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.anyList; + +@ExtendWith(MockitoExtension.class) +class RoleServiceImplTest { + + @Mock + private Keycloak keycloakAdminClient; + @Mock + private RoleMapper roleMapper; + + @InjectMocks + private RoleServiceImpl roleService; + + @Mock + private RealmResource realmResource; + @Mock + private RolesResource rolesResource; + + private String testRealmName = "test-realm"; + private RoleRepresentation roleRepresentation; + private RoleDTO roleDTO; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(roleService, "realmName", testRealmName); + + roleRepresentation = new RoleRepresentation(); + roleRepresentation.setId("role-id"); + roleRepresentation.setName("TEST_ROLE"); + roleRepresentation.setDescription("A test role"); + + roleDTO = RoleDTO.builder() + .id("role-id") + .name("TEST_ROLE") + .description("A test role") + .build(); + + when(keycloakAdminClient.realm(testRealmName)).thenReturn(realmResource); + when(realmResource.roles()).thenReturn(rolesResource); + } + + @Test + void getAllRealmRoles_shouldReturnRoleDtoList() { + when(rolesResource.list()).thenReturn(Collections.singletonList(roleRepresentation)); + when(roleMapper.toDtoList(anyList())).thenReturn(Collections.singletonList(roleDTO)); + + List result = roleService.getAllRealmRoles(); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(roleDTO.getName(), result.get(0).getName()); + verify(rolesResource).list(); + verify(roleMapper).toDtoList(Collections.singletonList(roleRepresentation)); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/service/impl/ServiceConfigServiceImplTest.java b/src/test/java/com/dalab/adminservice/service/impl/ServiceConfigServiceImplTest.java new file mode 100644 index 0000000000000000000000000000000000000000..5f288835b6756511c1e4540b28ff02805c7360d0 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/service/impl/ServiceConfigServiceImplTest.java @@ -0,0 +1,130 @@ +package com.dalab.adminservice.service.impl; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.dalab.adminservice.dto.ServiceConfigDTO; +import com.dalab.adminservice.exception.NotFoundException; +import com.dalab.adminservice.mapper.ServiceConfigMapper; +import com.dalab.adminservice.model.ServiceConfigEntity; +import com.dalab.adminservice.repository.ServiceConfigRepository; + +@ExtendWith(MockitoExtension.class) +class ServiceConfigServiceImplTest { + + @Mock + private ServiceConfigRepository serviceConfigRepository; + + @Mock + private ServiceConfigMapper serviceConfigMapper; + + @InjectMocks + private ServiceConfigServiceImpl serviceConfigService; + + private ServiceConfigEntity serviceConfigEntity; + private ServiceConfigDTO serviceConfigDTO; + + @BeforeEach + void setUp() { + serviceConfigEntity = new ServiceConfigEntity(); + serviceConfigEntity.setServiceId("test-service"); + serviceConfigEntity.setDisplayName("Test Service"); + serviceConfigEntity.setEnabled(true); + + serviceConfigDTO = ServiceConfigDTO.builder() + .serviceId("test-service") + .displayName("Test Service") + .enabled(true) + .build(); + } + + @Test + void getAllServiceConfigs_shouldReturnDtoList() { + when(serviceConfigRepository.findAll()).thenReturn(Collections.singletonList(serviceConfigEntity)); + when(serviceConfigMapper.toDtoList(anyList())).thenReturn(Collections.singletonList(serviceConfigDTO)); + + List result = serviceConfigService.getAllServiceConfigs(); + + assertThat(result).isNotNull(); + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getServiceId()).isEqualTo("test-service"); + verify(serviceConfigRepository).findAll(); + verify(serviceConfigMapper).toDtoList(anyList()); + } + + @Test + void getServiceConfigById_whenFound_shouldReturnOptionalDto() { + when(serviceConfigRepository.findByServiceId("test-service")).thenReturn(Optional.of(serviceConfigEntity)); + when(serviceConfigMapper.toDto(serviceConfigEntity)).thenReturn(serviceConfigDTO); + + Optional result = serviceConfigService.getServiceConfigById("test-service"); + + assertThat(result).isPresent(); + assertThat(result.get().getDisplayName()).isEqualTo("Test Service"); + verify(serviceConfigRepository).findByServiceId("test-service"); + } + + @Test + void getServiceConfigById_whenNotFound_shouldReturnEmptyOptional() { + when(serviceConfigRepository.findByServiceId("unknown")).thenReturn(Optional.empty()); + + Optional result = serviceConfigService.getServiceConfigById("unknown"); + + assertThat(result).isNotPresent(); + verify(serviceConfigRepository).findByServiceId("unknown"); + } + + @Test + void createServiceConfig_shouldCreateAndReturnDto() { + when(serviceConfigMapper.toEntity(serviceConfigDTO)).thenReturn(serviceConfigEntity); + when(serviceConfigRepository.save(serviceConfigEntity)).thenReturn(serviceConfigEntity); + when(serviceConfigMapper.toDto(serviceConfigEntity)).thenReturn(serviceConfigDTO); + + ServiceConfigDTO result = serviceConfigService.createServiceConfig(serviceConfigDTO); + + assertThat(result).isNotNull(); + assertThat(result.getServiceId()).isEqualTo(serviceConfigDTO.getServiceId()); + verify(serviceConfigRepository).save(serviceConfigEntity); + } + + @Test + void updateServiceConfig_whenFound_shouldUpdateAndReturnDto() { + ServiceConfigDTO updatedDto = ServiceConfigDTO.builder().serviceId("test-service").displayName("Updated Service").build(); + + when(serviceConfigRepository.findByServiceId("test-service")).thenReturn(Optional.of(serviceConfigEntity)); + // serviceConfigMapper.updateEntityFromDto is void, so no when()... needed for it if just verifying call + when(serviceConfigRepository.save(serviceConfigEntity)).thenReturn(serviceConfigEntity); // assume save returns updated entity + when(serviceConfigMapper.toDto(serviceConfigEntity)).thenReturn(updatedDto); // mapper returns DTO of updated entity + + ServiceConfigDTO result = serviceConfigService.updateServiceConfig("test-service", updatedDto); + + assertThat(result).isNotNull(); + assertThat(result.getDisplayName()).isEqualTo("Updated Service"); + verify(serviceConfigMapper).updateEntityFromDto(updatedDto, serviceConfigEntity); + verify(serviceConfigRepository).save(serviceConfigEntity); + } + + @Test + void updateServiceConfig_whenNotFound_shouldThrowNotFoundException() { + ServiceConfigDTO updatedDto = ServiceConfigDTO.builder().serviceId("unknown").build(); + when(serviceConfigRepository.findByServiceId("unknown")).thenReturn(Optional.empty()); + + assertThrows(NotFoundException.class, () -> { + serviceConfigService.updateServiceConfig("unknown", updatedDto); + }); + verify(serviceConfigRepository, never()).save(any()); + } +} \ No newline at end of file diff --git a/src/test/java/com/dalab/adminservice/service/impl/UserServiceImplTest.java b/src/test/java/com/dalab/adminservice/service/impl/UserServiceImplTest.java new file mode 100644 index 0000000000000000000000000000000000000000..441e28c916eeccf73824d2fdbbd89d90e1f527c1 --- /dev/null +++ b/src/test/java/com/dalab/adminservice/service/impl/UserServiceImplTest.java @@ -0,0 +1,330 @@ +package com.dalab.adminservice.service.impl; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.net.URI; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.admin.client.resource.RoleMappingResource; +import org.keycloak.admin.client.resource.RoleResource; +import org.keycloak.admin.client.resource.RoleScopeResource; +import org.keycloak.admin.client.resource.RolesResource; +import org.keycloak.admin.client.resource.UserResource; +import org.keycloak.admin.client.resource.UsersResource; +import org.keycloak.representations.idm.RoleRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import com.dalab.adminservice.dto.UserDTO; +import com.dalab.adminservice.exception.ConflictException; +import com.dalab.adminservice.exception.NotFoundException; +import com.dalab.adminservice.mapper.UserMapper; + +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.UriInfo; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class UserServiceImplTest { + + @Mock + private Keycloak keycloakAdminClient; + @Mock + private UserMapper userMapper; + + @InjectMocks + private UserServiceImpl userService; + + @Mock + private RealmResource realmResource; + @Mock + private UsersResource usersResource; + @Mock + private UserResource userResource; + @Mock + private RolesResource rolesResource; + @Mock + private RoleResource roleResource; + @Mock + private RoleMappingResource roleMappingResource; + @Mock + private RoleScopeResource roleScopeResource; + + private UserRepresentation userRepresentation; + private UserDTO userDTO; + private String testUserId = "test-user-id"; + private String testRealmName = "test-realm"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(userService, "realmName", testRealmName); + + userRepresentation = new UserRepresentation(); + userRepresentation.setId(testUserId); + userRepresentation.setUsername("testuser"); + userRepresentation.setEmail("test@example.com"); + + userDTO = UserDTO.builder() + .id(testUserId) + .username("testuser") + .email("test@example.com") + .firstName("Test") + .lastName("User") + .enabled(true) + .password("password") // Include password for create/update tests + .roles(List.of("USER")) + .build(); + + // Common Keycloak resource mocking + when(keycloakAdminClient.realm(testRealmName)).thenReturn(realmResource); + when(realmResource.users()).thenReturn(usersResource); + } + + @Test + void getAllUsers_shouldReturnUserDtoList() { + when(usersResource.list(anyInt(), anyInt())).thenReturn(Collections.singletonList(userRepresentation)); + when(userMapper.toDtoList(anyList())).thenReturn(Collections.singletonList(userDTO)); + + List result = userService.getAllUsers(0, 10); + + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(userDTO.getUsername(), result.get(0).getUsername()); + verify(usersResource).list(0, 10); + } + + @Test + void getUserById_whenFound_shouldReturnUserDto() { + when(usersResource.get(testUserId)).thenReturn(userResource); + when(userResource.toRepresentation()).thenReturn(userRepresentation); + when(userResource.roles()).thenReturn(roleMappingResource); + when(roleMappingResource.realmLevel()).thenReturn(roleScopeResource); + when(roleScopeResource.listEffective()).thenReturn(Collections.emptyList()); // Mock roles as needed + when(userMapper.toDto(userRepresentation)).thenReturn(userDTO); + + Optional result = userService.getUserById(testUserId); + + assertTrue(result.isPresent()); + assertEquals(userDTO.getUsername(), result.get().getUsername()); + } + + @Test + void getUserById_whenNotFound_shouldReturnEmpty() { + when(usersResource.get("unknown-id")).thenThrow(new jakarta.ws.rs.NotFoundException()); + Optional result = userService.getUserById("unknown-id"); + assertFalse(result.isPresent()); + } + + @Test + void getUserByUsername_whenFound_shouldReturnUserDto() { + when(usersResource.searchByUsername(userDTO.getUsername(), true)).thenReturn(List.of(userRepresentation)); + when(usersResource.get(testUserId)).thenReturn(userResource); + when(userResource.roles()).thenReturn(roleMappingResource); + when(roleMappingResource.realmLevel()).thenReturn(roleScopeResource); + when(roleScopeResource.listEffective()).thenReturn(Collections.emptyList()); + when(userMapper.toDto(userRepresentation)).thenReturn(userDTO); + + Optional result = userService.getUserByUsername(userDTO.getUsername()); + + assertTrue(result.isPresent()); + assertEquals(userDTO.getUsername(), result.get().getUsername()); + } + + @Test + void createUser_shouldCreateAndReturnUserDto() { + Response mockResponse = mock(Response.class); + UriInfo mockUriInfo = mock(UriInfo.class); + when(mockResponse.getStatus()).thenReturn(Response.Status.CREATED.getStatusCode()); + when(mockResponse.getLocation()).thenReturn(URI.create("http://localhost/users/" + testUserId)); + // when(mockResponse.getUriInfo()).thenReturn(mockUriInfo); // If using getPath() + // when(mockUriInfo.getPath()).thenReturn("/users/" + testUserId); + + when(userMapper.toRepresentation(any(UserDTO.class))).thenReturn(userRepresentation); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + // Mock the follow-up getUserById call + when(usersResource.get(testUserId)).thenReturn(userResource); + when(userResource.toRepresentation()).thenReturn(userRepresentation); + when(userResource.roles()).thenReturn(roleMappingResource); + when(roleMappingResource.realmLevel()).thenReturn(roleScopeResource); + when(roleScopeResource.listEffective()).thenReturn(Collections.emptyList()); + when(userMapper.toDto(userRepresentation)).thenReturn(userDTO); + // Mock roles part + when(realmResource.roles()).thenReturn(rolesResource); + RoleRepresentation roleRep = new RoleRepresentation("USER", null, false); + when(rolesResource.get("USER")).thenReturn(roleResource); + when(roleResource.toRepresentation()).thenReturn(roleRep); + + + UserDTO createdUser = userService.createUser(userDTO); + + assertNotNull(createdUser); + assertEquals(userDTO.getUsername(), createdUser.getUsername()); + verify(usersResource).create(userRepresentation); + verify(userResource.roles().realmLevel()).add(anyList()); + } + + @Test + void createUser_whenConflict_shouldThrowConflictException() { + Response mockResponse = mock(Response.class); + when(mockResponse.getStatus()).thenReturn(Response.Status.CONFLICT.getStatusCode()); + + when(userMapper.toRepresentation(any(UserDTO.class))).thenReturn(userRepresentation); + when(usersResource.create(any(UserRepresentation.class))).thenReturn(mockResponse); + + assertThrows(ConflictException.class, () -> userService.createUser(userDTO)); + } + + @Test + void updateUser_shouldUpdateAndReturnUserDto() { + UserDTO updateRequest = UserDTO.builder() + .id(testUserId) + .firstName("Updated") + .lastName("Name") + .email("updated@example.com") + .enabled(false) + .roles(List.of("ADMIN")) + .build(); + + UserRepresentation existingUserRep = new UserRepresentation(); + existingUserRep.setId(testUserId); + existingUserRep.setUsername("testuser"); + existingUserRep.setFirstName("Test"); + existingUserRep.setLastName("User"); + existingUserRep.setEmail("test@example.com"); + existingUserRep.setEnabled(true); + + UserRepresentation updatedUserRep = new UserRepresentation(); + updatedUserRep.setId(testUserId); + updatedUserRep.setUsername("testuser"); + updatedUserRep.setFirstName("Updated"); + updatedUserRep.setLastName("Name"); + updatedUserRep.setEmail("updated@example.com"); + updatedUserRep.setEnabled(false); + + // Mock the initial fetch and update operations + when(usersResource.get(testUserId)).thenReturn(userResource); + when(userResource.toRepresentation()).thenReturn(existingUserRep); + when(userMapper.toRepresentation(updateRequest)).thenReturn(updatedUserRep); + + doNothing().when(userResource).update(any(UserRepresentation.class)); + + // Mock role operations + when(userResource.roles()).thenReturn(roleMappingResource); + when(roleMappingResource.realmLevel()).thenReturn(roleScopeResource); + when(roleScopeResource.listEffective()).thenReturn(Collections.singletonList(new RoleRepresentation("USER",null,false))); + + when(realmResource.roles()).thenReturn(rolesResource); + RoleRepresentation adminRoleRep = new RoleRepresentation("ADMIN", null, false); + when(rolesResource.get("ADMIN")).thenReturn(roleResource); + when(roleResource.toRepresentation()).thenReturn(adminRoleRep); + + doNothing().when(roleScopeResource).add(anyList()); + doNothing().when(roleScopeResource).remove(anyList()); + + // Mock the final getUserById call - create a separate mock chain + UserResource finalUserResource = mock(UserResource.class); + RoleMappingResource finalRoleMappingResource = mock(RoleMappingResource.class); + RoleScopeResource finalRoleScopeResource = mock(RoleScopeResource.class); + + // The updateUser method calls getUserById at the end, which needs fresh mocks + when(usersResource.get(testUserId)).thenReturn(userResource, finalUserResource); + when(finalUserResource.toRepresentation()).thenReturn(updatedUserRep); + when(finalUserResource.roles()).thenReturn(finalRoleMappingResource); + when(finalRoleMappingResource.realmLevel()).thenReturn(finalRoleScopeResource); + when(finalRoleScopeResource.listEffective()).thenReturn(Collections.singletonList(new RoleRepresentation("ADMIN",null,false))); + + UserDTO finalResult = UserDTO.builder() + .id(testUserId) + .username("testuser") + .firstName("Updated") + .lastName("Name") + .email("updated@example.com") + .enabled(false) + .roles(List.of("ADMIN")) + .build(); + when(userMapper.toDto(updatedUserRep)).thenReturn(finalResult); + + UserDTO result = userService.updateUser(testUserId, updateRequest); + + assertNotNull(result); + assertEquals("Updated", result.getFirstName()); + assertFalse(result.isEnabled()); + verify(userResource).update(any(UserRepresentation.class)); + verify(roleScopeResource).add(anyList()); + verify(roleScopeResource).remove(anyList()); + } + + @Test + void deleteUser_shouldCallRemove() { + when(usersResource.get(testUserId)).thenReturn(userResource); + doNothing().when(userResource).remove(); + userService.deleteUser(testUserId); + verify(userResource).remove(); + } + + @Test + void deleteUser_whenNotFound_shouldThrowNotFound() { + when(usersResource.get("unknown-id")).thenThrow(new jakarta.ws.rs.NotFoundException()); + assertThrows(NotFoundException.class, () -> userService.deleteUser("unknown-id")); + } + + @Test + void assignRealmRolesToUser_shouldAddRoles() { + when(usersResource.get(testUserId)).thenReturn(userResource); + when(realmResource.roles()).thenReturn(rolesResource); + + RoleRepresentation roleRep = new RoleRepresentation("NEW_ROLE", null, false); + when(rolesResource.get("NEW_ROLE")).thenReturn(roleResource); + when(roleResource.toRepresentation()).thenReturn(roleRep); + + when(userResource.roles()).thenReturn(roleMappingResource); + when(roleMappingResource.realmLevel()).thenReturn(roleScopeResource); + doNothing().when(roleScopeResource).add(anyList()); + + userService.assignRealmRolesToUser(testUserId, List.of("NEW_ROLE")); + verify(roleScopeResource).add(List.of(roleRep)); + } + + @Test + void getAvailableRealmRoles_shouldReturnRoleRepresentations() { + RoleRepresentation roleRep = new RoleRepresentation("TEST_ROLE", "A test role", false); + when(realmResource.roles()).thenReturn(rolesResource); + when(rolesResource.list()).thenReturn(Collections.singletonList(roleRep)); + + List roles = userService.getAvailableRealmRoles(); + + assertNotNull(roles); + assertEquals(1, roles.size()); + assertEquals("TEST_ROLE", roles.get(0).getName()); + } + + @Test + void getUserRealmRoles_shouldReturnUserRoles() { + RoleRepresentation roleRep = new RoleRepresentation("ASSIGNED_ROLE", null, false); + when(usersResource.get(testUserId)).thenReturn(userResource); + when(userResource.roles()).thenReturn(roleMappingResource); + when(roleMappingResource.realmLevel()).thenReturn(roleScopeResource); + when(roleScopeResource.listEffective()).thenReturn(Collections.singletonList(roleRep)); + + List roles = userService.getUserRealmRoles(testUserId); + + assertNotNull(roles); + assertEquals(1, roles.size()); + assertEquals("ASSIGNED_ROLE", roles.get(0).getName()); + } +} \ No newline at end of file