From eb66452f0324a7103b00521e9b4fe8727dff95db Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 26 Mar 2026 13:01:29 +0100 Subject: [PATCH] Add execution ownership filtering and usage stats --- .../controllers/ExecutionsController.java | 103 +++-- .../manager/controllers/StatsController.java | 84 ++++ .../manager/executions/ExecutionObject.java | 6 +- .../manager/executions/ExecutionsService.java | 9 +- .../executions/repo/ExecutionEntity.java | 2 + .../stats/UserStatsNotFoundException.java | 7 + .../manager/stats/UserStatsService.java | 158 ++++++++ .../stats/model/SystemUsageStatsView.java | 22 ++ .../stats/model/UserUsageStatsView.java | 22 ++ .../controllers/ExecutionControllerTest.java | 143 +++++-- .../controllers/StatsControllerTest.java | 366 ++++++++++++++++++ 11 files changed, 850 insertions(+), 72 deletions(-) create mode 100644 src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsNotFoundException.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsService.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/stats/model/SystemUsageStatsView.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/stats/model/UserUsageStatsView.java create mode 100644 src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java index c4397da..429435e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/ExecutionsController.java @@ -13,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.server.WebServerException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -33,6 +34,7 @@ import it.cnr.isti.workflow.manager.executions.ExecutionSimulationRequest; import it.cnr.isti.workflow.manager.executions.ExecutionsService; import it.cnr.isti.workflow.manager.executions.api.ExecutionContextView; import it.cnr.isti.workflow.manager.executions.api.ExecutionView; +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; import it.cnr.isti.workflow.manager.flows.repo.FlowEntity; import it.cnr.isti.workflow.manager.flows.repo.FlowRepository; import jakarta.validation.constraints.NotNull; @@ -98,7 +100,8 @@ public class ExecutionsController { */ @Operation(summary = "Creates an execution", description = "Creates a new execution object for the specified flow.") @PostMapping() - public ExecutionView create(@RequestBody @NotNull String flowId) { + public ExecutionView create(@RequestBody @NotNull String flowId, + @AuthenticationPrincipal LoginEntity userDetails) { logger.info("Creating execution for flow {}", flowId); if (flowId == null || flowId.isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Flow ID must not be null or empty"); @@ -107,7 +110,8 @@ public class ExecutionsController { FlowEntity flow = flowRepository.findById(flowId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Flow with id " + flowId + " not found")); try{ - ExecutionObject eo = executionService.createExecution(flow.getName(), flow.getFlow()); + ExecutionObject eo = executionService.createExecution(flow.getName(), flow.getFlow(), + userDetails == null ? null : userDetails.getUsername()); return ExecutionView.fromExecution(eo); } catch (ResponseStatusException e) { throw e; @@ -124,8 +128,8 @@ public class ExecutionsController { */ @Operation(summary = "Retrieves executions", description = "Retrieves a list of all executions") @GetMapping() - public List getAll() { - return executionService.getAllExecutions().stream().map(ExecutionView::fromExecution).toList(); + public List getAll(@AuthenticationPrincipal LoginEntity userDetails) { + return visibleExecutions(userDetails).stream().map(ExecutionView::fromExecution).toList(); } @@ -137,8 +141,8 @@ public class ExecutionsController { */ @Operation(summary = "Retrieves contexts", description = "Retrieves all execution contexts") @GetMapping(path = "contexts") - public Map getAllContexts() { - return executionService.getAllExecutions().stream() + public Map getAllContexts(@AuthenticationPrincipal LoginEntity userDetails) { + return visibleExecutions(userDetails).stream() .collect(Collectors.toMap(ExecutionObject::getId, eo -> ExecutionContextView.fromContext(eo.getContext()))); } @@ -151,14 +155,24 @@ public class ExecutionsController { * @throws IllegalArgumentException if the execution with the specified ID is not found */ @Operation(summary = "Retrieves an execution", description = "Retrieves a specific execution by its ID") - public ExecutionView get(@PathVariable String id) { - return ExecutionView.fromExecution(executionService.getExecution(id)); + public ExecutionView get(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution(visibleExecution(id, userDetails)); + } + + private List visibleExecutions(LoginEntity userDetails) { + List allExecutions = executionService.getAllExecutions(); + if (userDetails == null) { + return allExecutions; + } + return allExecutions.stream() + .filter(execution -> userDetails.getUsername().equals(execution.getOwner())) + .toList(); } @GetMapping(path = "{id}/events") @Operation(summary = "Retrieves execution events", description = "Retrieves the user-readable event log for a specific execution.") - public List getEvents(@PathVariable String id) { - return executionService.getExecution(id).getContext().getEvents(); + public List getEvents(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) { + return visibleExecution(id, userDetails).getContext().getEvents(); } /** @@ -169,8 +183,8 @@ public class ExecutionsController { */ @DeleteMapping(path = "{id}") @Operation(summary = "Deletes an execution", description = "Deletes an execution with the specified ID") - public ResponseEntity delete(@PathVariable String id) { - executionService.removeExecution(id); + public ResponseEntity delete(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) { + executionService.removeExecution(visibleExecution(id, userDetails).getId()); return ResponseEntity.ok().build(); } @@ -182,27 +196,30 @@ public class ExecutionsController { */ @PutMapping(path = "{id}/start") @Operation(summary = "Starts an execution", description = "Starts the execution of a workflow with the given ID") - public ExecutionView start(@PathVariable String id) { - return ExecutionView.fromExecution(executionService.startExecution(id)); + public ExecutionView start(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution(executionService.startExecution(visibleExecution(id, userDetails).getId())); } @PutMapping(path = "{id}/simulate") @Operation(summary = "Starts an execution in simulation mode", description = "Starts the execution and enables simulation for interactive nodes.") - public ExecutionView simulate(@PathVariable String id, @RequestBody @jakarta.validation.Valid ExecutionSimulationRequest request) { - return ExecutionView.fromExecution(executionService.startSimulationExecution(id, request.simulator())); + public ExecutionView simulate(@PathVariable String id, + @RequestBody @jakarta.validation.Valid ExecutionSimulationRequest request, + @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution( + executionService.startSimulationExecution(visibleExecution(id, userDetails).getId(), request.simulator())); } @PutMapping(path = "{id}/resume") @Operation(summary = "Resumes an execution", description = "Rebuilds a persisted execution and resumes it when possible.") - public ExecutionView resume(@PathVariable String id) { - return ExecutionView.fromExecution(executionService.resumeExecution(id)); + public ExecutionView resume(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution(executionService.resumeExecution(visibleExecution(id, userDetails).getId())); } @PutMapping(path = "{id}/cancel") @Operation(summary = "Cancels an execution", description = "Cancels the execution, clears runtime state and marks it as CANCELLED.") - public ExecutionView cancel(@PathVariable String id) { - return ExecutionView.fromExecution(executionService.cancelExecution(id)); + public ExecutionView cancel(@PathVariable String id, @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution(executionService.cancelExecution(visibleExecution(id, userDetails).getId())); } @@ -219,15 +236,18 @@ public class ExecutionsController { @PutMapping(path = "{executionId}/node/{nodeId}/input/{inputName}/text", consumes = "text/plain") @Operation(summary = "Prepares string inputs", description = "Prepares the input for an execution by associating a given input string with a specific input name and execution ID.") public ExecutionView prepareStringInputs(@PathVariable String executionId, @PathVariable String nodeId, @PathVariable String inputName, - @RequestBody String input) { - return ExecutionView.fromExecution(executionService.prepareInput(executionId, nodeId, inputName, input)); + @RequestBody String input, @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution( + executionService.prepareInput(visibleExecution(executionId, userDetails).getId(), nodeId, inputName, input)); } @PutMapping(path = "{executionId}/node/{nodeId}/input/{inputName}/texts", consumes = "application/json") @Operation(summary = "Prepares string array inputs", description = "Prepares an array of string inputs for an execution node input.") public ExecutionView prepareStringArrayInputs(@PathVariable String executionId, @PathVariable String nodeId, - @PathVariable String inputName, @RequestBody List input) { - return ExecutionView.fromExecution(executionService.prepareInput(executionId, nodeId, inputName, input)); + @PathVariable String inputName, @RequestBody List input, + @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution( + executionService.prepareInput(visibleExecution(executionId, userDetails).getId(), nodeId, inputName, input)); } @@ -245,11 +265,12 @@ public class ExecutionsController { @PutMapping(path = "{executionId}/node/{nodeId}/input/{inputName}/file", consumes = "multipart/form-data") @Operation(summary = "Prepares file inputs", description = "Prepares file inputs for a specific execution by uploading a file and associating it with the given input name and execution ID.") public ExecutionView prepareFileInputs(@PathVariable String executionId, @PathVariable String nodeId, @PathVariable String inputName, - @RequestParam MultipartFile file) { + @RequestParam MultipartFile file, @AuthenticationPrincipal LoginEntity userDetails) { try { File myFile = File.createTempFile(inputName, file.getOriginalFilename()); file.transferTo(myFile); - return ExecutionView.fromExecution(executionService.prepareInput(executionId, nodeId, inputName, myFile)); + return ExecutionView.fromExecution( + executionService.prepareInput(visibleExecution(executionId, userDetails).getId(), nodeId, inputName, myFile)); } catch (IOException e) { throw new WebServerException("Error while creating file", e); } @@ -259,7 +280,8 @@ public class ExecutionsController { @PutMapping(path = "{executionId}/node/{nodeId}/input/{inputName}/files", consumes = "multipart/form-data") @Operation(summary = "Prepares file array inputs", description = "Prepares multiple file inputs for a specific execution node input.") public ExecutionView prepareFileArrayInputs(@PathVariable String executionId, @PathVariable String nodeId, - @PathVariable String inputName, @RequestParam List files) { + @PathVariable String inputName, @RequestParam List files, + @AuthenticationPrincipal LoginEntity userDetails) { List preparedFiles = new ArrayList<>(); try { for (MultipartFile file : files) { @@ -267,7 +289,8 @@ public class ExecutionsController { file.transferTo(myFile); preparedFiles.add(myFile); } - return ExecutionView.fromExecution(executionService.prepareInput(executionId, nodeId, inputName, preparedFiles)); + return ExecutionView.fromExecution( + executionService.prepareInput(visibleExecution(executionId, userDetails).getId(), nodeId, inputName, preparedFiles)); } catch (IOException e) { throw new WebServerException("Error while creating file", e); } @@ -276,16 +299,30 @@ public class ExecutionsController { @PutMapping(path = "{executionId}/node/{nodeId}/interaction/{fieldName}/text", consumes = "text/plain") @Operation(summary = "Provides human interaction output", description = "Provides the value for a waiting human interaction step.") public ExecutionView provideInteractionValue(@PathVariable String executionId, @PathVariable String nodeId, - @PathVariable String fieldName, @RequestBody String value) { - return ExecutionView.fromExecution(executionService.setInteractionValue(executionId, nodeId, fieldName, value)); + @PathVariable String fieldName, @RequestBody String value, + @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution( + executionService.setInteractionValue(visibleExecution(executionId, userDetails).getId(), nodeId, fieldName, value)); } @PutMapping(path = "{executionId}/authorizations") @Operation(summary = "Provides execution authorization", description = "Stores a required provider authorization value for an execution.") public ExecutionView provideAuthorization(@PathVariable String executionId, - @RequestBody @jakarta.validation.Valid ExecutionAuthorizationValueRequest request) { - return ExecutionView.fromExecution(executionService.setAuthorizationValue(executionId, request.key(), request.value())); + @RequestBody @jakarta.validation.Valid ExecutionAuthorizationValueRequest request, + @AuthenticationPrincipal LoginEntity userDetails) { + return ExecutionView.fromExecution( + executionService.setAuthorizationValue(visibleExecution(executionId, userDetails).getId(), request.key(), request.value())); + } + + private ExecutionObject visibleExecution(String id, LoginEntity userDetails) { + ExecutionObject execution = executionService.getExecution(id); + if (userDetails == null) { + return execution; + } + if (!userDetails.getUsername().equals(execution.getOwner())) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Execution with id " + id + " is not accessible"); + } + return execution; } - } diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java new file mode 100644 index 0000000..ba46b36 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/StatsController.java @@ -0,0 +1,84 @@ +package it.cnr.isti.workflow.manager.controllers; + +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import it.cnr.isti.workflow.manager.auth.model.UserRole; +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; +import it.cnr.isti.workflow.manager.stats.UserStatsNotFoundException; +import it.cnr.isti.workflow.manager.stats.UserStatsService; +import it.cnr.isti.workflow.manager.stats.model.SystemUsageStatsView; +import it.cnr.isti.workflow.manager.stats.model.UserUsageStatsView; + +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/stats") +public class StatsController { + + @Autowired + private UserStatsService userStatsService; + + @GetMapping() + @Operation(summary = "Get system usage stats", description = "Returns aggregated usage statistics for the whole system. Accessible only to admins.") + public ResponseEntity getSystemStats(@AuthenticationPrincipal LoginEntity userDetails) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + boolean admin = isAdmin(userDetails); + if (!admin) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.ok(userStatsService.getSystemStats()); + } + + @GetMapping("/users") + @Operation(summary = "Get all user usage stats", description = "Returns aggregated usage statistics for all users. Accessible only to admins.") + public ResponseEntity> getAllUserStats(@AuthenticationPrincipal LoginEntity userDetails) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + boolean admin = isAdmin(userDetails); + if (!admin) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.ok(userStatsService.getAllUsersInStats()); + } + + @GetMapping("/users/{username}") + @Operation(summary = "Get user usage stats", description = "Returns aggregated usage statistics for a user. Accessible only to admins.") + public ResponseEntity getUserStats(@PathVariable String username, + @AuthenticationPrincipal LoginEntity userDetails) { + if (userDetails == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + boolean admin = isAdmin(userDetails); + if (!admin) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + try { + return ResponseEntity.ok(userStatsService.getUserStats(username)); + } catch (UserStatsNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + } + + private boolean isAdmin(LoginEntity userDetails) { + if (userDetails != null && userDetails.effectiveRole() == UserRole.ADMIN) { + return true; + } + var authentication = SecurityContextHolder.getContext().getAuthentication(); + return authentication != null && authentication.getAuthorities().stream() + .anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority())); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java index 44c908f..25d5f89 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionObject.java @@ -42,6 +42,8 @@ public class ExecutionObject { String name; + String owner; + @JsonIgnore FlowData flow; @@ -59,8 +61,10 @@ public class ExecutionObject { LLMDescriptor interactionSimulationDescriptor; @Builder - public ExecutionObject(String executionName, FlowData flow, List requiredAuthorizations) { + public ExecutionObject(String executionName, FlowData flow, List requiredAuthorizations, + String owner) { this.name = executionName; + this.owner = owner; this.flow = flow; this.stepConnections = flow.getConnections() == null ? List.of() : List.copyOf(flow.getConnections()); this.requiredAuthorizations = requiredAuthorizations == null ? List.of() : List.copyOf(requiredAuthorizations); diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index f1029a6..e223678 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -50,12 +50,17 @@ public class ExecutionsService { MCPAgentService mcpAgentService; public ExecutionObject createExecution(String executionName, FlowData flow) { + return createExecution(executionName, flow, null); + } + + public ExecutionObject createExecution(String executionName, FlowData flow, String owner) { flowExecutionValidator.validate(flow); List requiredAuthorizations = resolveRequiredAuthorizations(flow); ExecutionObject execObject = ExecutionObject.builder() .executionName(executionName) .flow(flow) .requiredAuthorizations(requiredAuthorizations) + .owner(owner) .build(); attachPersistence(execObject); executions.put(execObject.getId(), execObject); @@ -75,7 +80,7 @@ public class ExecutionsService { flowDataBuilder.connections(flow.getConnections()); } FlowData flowData = flowDataBuilder.build(); - return createExecution(flow.getName(), flowData); + return createExecution(flow.getName(), flowData, null); } public ExecutionObject getExecution(String id) { @@ -334,6 +339,7 @@ public class ExecutionsService { executionRepository.save(ExecutionEntity.builder() .id(executionObject.getId()) .name(executionObject.getName()) + .owner(executionObject.getOwner()) .creationTime(executionObject.getCreationTime()) .lastUpdateTime(System.currentTimeMillis()) .flow(executionObject.getFlow()) @@ -347,6 +353,7 @@ public class ExecutionsService { .executionName(entity.getName()) .flow(flow) .requiredAuthorizations(resolveRequiredAuthorizations(flow)) + .owner(entity.getOwner()) .build(); ExecutionSnapshot snapshot = entity.getSnapshot(); executionObject.restore(entity.getId(), entity.getCreationTime(), diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java b/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java index 4e87dee..8c7b1cd 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/repo/ExecutionEntity.java @@ -27,6 +27,8 @@ public class ExecutionEntity { @NotBlank private String name; + private String owner; + private long creationTime; private long lastUpdateTime; diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsNotFoundException.java b/src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsNotFoundException.java new file mode 100644 index 0000000..4a29720 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsNotFoundException.java @@ -0,0 +1,7 @@ +package it.cnr.isti.workflow.manager.stats; + +public class UserStatsNotFoundException extends RuntimeException { + public UserStatsNotFoundException(String username) { + super("User not found: " + username); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsService.java b/src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsService.java new file mode 100644 index 0000000..86df307 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/stats/UserStatsService.java @@ -0,0 +1,158 @@ +package it.cnr.isti.workflow.manager.stats; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import it.cnr.isti.workflow.manager.auth.repo.AuthRepository; +import it.cnr.isti.workflow.manager.executions.ExecutionStatus; +import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot; +import it.cnr.isti.workflow.manager.executions.repo.ExecutionEntity; +import it.cnr.isti.workflow.manager.executions.repo.ExecutionRepository; +import it.cnr.isti.workflow.manager.flows.repo.FlowEntity; +import it.cnr.isti.workflow.manager.flows.repo.FlowRepository; +import it.cnr.isti.workflow.manager.stats.model.SystemUsageStatsView; +import it.cnr.isti.workflow.manager.stats.model.UserUsageStatsView; + +@Service +public class UserStatsService { + + @Autowired + private AuthRepository authRepository; + + @Autowired + private FlowRepository flowRepository; + + @Autowired + private ExecutionRepository executionRepository; + + public SystemUsageStatsView getSystemStats() { + List flows = flowRepository.findAll(); + List executions = executionRepository.findAll(); + + long executionsRunning = executions.stream() + .map(ExecutionEntity::getSnapshot) + .map(this::statusOf) + .filter(status -> status != null && status.isRunningState()) + .count(); + + long executionsSucceeded = executions.stream() + .map(ExecutionEntity::getSnapshot) + .map(this::statusOf) + .filter(ExecutionStatus.SUCCESS::equals) + .count(); + + long executionsFailed = executions.stream() + .map(ExecutionEntity::getSnapshot) + .map(this::statusOf) + .filter(ExecutionStatus.ERROR::equals) + .count(); + + long simulationsStarted = executions.stream() + .map(ExecutionEntity::getSnapshot) + .filter(snapshot -> snapshot != null && snapshot.isInteractionSimulationEnabled()) + .count(); + + LocalDateTime lastFlowUpdateAt = flows.stream() + .map(FlowEntity::getLastUpdateAt) + .filter(java.util.Objects::nonNull) + .max(Comparator.naturalOrder()) + .orElse(null); + + Long lastExecutionAt = executions.stream() + .map(ExecutionEntity::getLastUpdateTime) + .max(Long::compareTo) + .orElse(null); + + return SystemUsageStatsView.builder() + .usersCount(authRepository.count()) + .flowsCreated(flows.size()) + .flowsPublished(flows.stream().filter(FlowEntity::isPublished).count()) + .flowsFinalized(flows.stream().filter(FlowEntity::isFinalized).count()) + .executionsCreated(executions.size()) + .executionsRunning(executionsRunning) + .executionsSucceeded(executionsSucceeded) + .executionsFailed(executionsFailed) + .simulationsStarted(simulationsStarted) + .lastFlowUpdateAt(lastFlowUpdateAt) + .lastExecutionAt(lastExecutionAt) + .build(); + } + + public List getAllUsersInStats() { + return authRepository.findAll().stream() + .map(login -> login.getUsername()) + .distinct() + .sorted() + .collect(Collectors.toList()); + } + + public UserUsageStatsView getUserStats(String username) { + if (!authRepository.existsById(username)) { + throw new UserStatsNotFoundException(username); + } + + return buildUserStats(username); + } + + private UserUsageStatsView buildUserStats(String username) { + List flows = flowRepository.findByOwner(username); + List executions = executionRepository.findByOwner(username); + + long executionsRunning = executions.stream() + .map(ExecutionEntity::getSnapshot) + .map(this::statusOf) + .filter(status -> status != null && status.isRunningState()) + .count(); + + long executionsSucceeded = executions.stream() + .map(ExecutionEntity::getSnapshot) + .map(this::statusOf) + .filter(ExecutionStatus.SUCCESS::equals) + .count(); + + long executionsFailed = executions.stream() + .map(ExecutionEntity::getSnapshot) + .map(this::statusOf) + .filter(ExecutionStatus.ERROR::equals) + .count(); + + long simulationsStarted = executions.stream() + .map(ExecutionEntity::getSnapshot) + .filter(snapshot -> snapshot != null && snapshot.isInteractionSimulationEnabled()) + .count(); + + LocalDateTime lastFlowUpdateAt = flows.stream() + .map(FlowEntity::getLastUpdateAt) + .filter(java.util.Objects::nonNull) + .max(Comparator.naturalOrder()) + .orElse(null); + + Long lastExecutionAt = executions.stream() + .map(ExecutionEntity::getLastUpdateTime) + .max(Long::compareTo) + .orElse(null); + + return UserUsageStatsView.builder() + .username(username) + .flowsCreated(flows.size()) + .flowsPublished(flows.stream().filter(FlowEntity::isPublished).count()) + .flowsFinalized(flows.stream().filter(FlowEntity::isFinalized).count()) + .executionsCreated(executions.size()) + .executionsRunning(executionsRunning) + .executionsSucceeded(executionsSucceeded) + .executionsFailed(executionsFailed) + .simulationsStarted(simulationsStarted) + .lastFlowUpdateAt(lastFlowUpdateAt) + .lastExecutionAt(lastExecutionAt) + .build(); + } + + private ExecutionStatus statusOf(ExecutionSnapshot snapshot) { + return snapshot == null ? null : snapshot.getStatus(); + } +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/model/SystemUsageStatsView.java b/src/main/java/it/cnr/isti/workflow/manager/stats/model/SystemUsageStatsView.java new file mode 100644 index 0000000..e161ddb --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/stats/model/SystemUsageStatsView.java @@ -0,0 +1,22 @@ +package it.cnr.isti.workflow.manager.stats.model; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class SystemUsageStatsView { + private long usersCount; + private long flowsCreated; + private long flowsPublished; + private long flowsFinalized; + private long executionsCreated; + private long executionsRunning; + private long executionsSucceeded; + private long executionsFailed; + private long simulationsStarted; + private LocalDateTime lastFlowUpdateAt; + private Long lastExecutionAt; +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/stats/model/UserUsageStatsView.java b/src/main/java/it/cnr/isti/workflow/manager/stats/model/UserUsageStatsView.java new file mode 100644 index 0000000..8015b58 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/stats/model/UserUsageStatsView.java @@ -0,0 +1,22 @@ +package it.cnr.isti.workflow.manager.stats.model; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserUsageStatsView { + private String username; + private long flowsCreated; + private long flowsPublished; + private long flowsFinalized; + private long executionsCreated; + private long executionsRunning; + private long executionsSucceeded; + private long executionsFailed; + private long simulationsStarted; + private LocalDateTime lastFlowUpdateAt; + private Long lastExecutionAt; +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java index 37e8be7..007e018 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java @@ -50,6 +50,10 @@ public class ExecutionControllerTest { private static final Logger logger = LoggerFactory.getLogger(ExecutionControllerTest.class); + private LoginEntity testUser() { + return new LoginEntity("testuser", "testpassword"); + } + @TestConfiguration static class TestConfig { @@ -135,7 +139,8 @@ public class ExecutionControllerTest { flow.getDescription(), FlowData.builder().blocks(flow.getBlocks()).connections(flow.getConnections()).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); assert executionObject != null; assert executionObject.getId() != null; @@ -144,18 +149,19 @@ public class ExecutionControllerTest { logger.info("execution status is {}",executionObject.getContext().getStatus()); - executionObject = executionsController.prepareStringInputs(executionObject.getId(), first.getId(), first.getInputs().get(0).getName(), "Leonardo da Vinci"); + executionObject = executionsController.prepareStringInputs(executionObject.getId(), first.getId(), + first.getInputs().get(0).getName(), "Leonardo da Vinci", testUser()); assert executionObject.getContext().getStatus() == ExecutionStatus.READY; logger.info("execution status is {}",executionObject.getContext().getStatus()); - executionObject = executionsController.start(executionObject.getId()); + executionObject = executionsController.start(executionObject.getId(), testUser()); assert executionObject.getContext().getStatus() == ExecutionStatus.RUNNING; while(executionObject.getContext().getStatus().isRunningState()){ - executionObject = executionsController.get(executionObject.getId()); + executionObject = executionsController.get(executionObject.getId(), testUser()); logger.info("execution status is {}",executionObject.getContext().getStatus()); try { Thread.sleep(1000); @@ -199,12 +205,13 @@ public class ExecutionControllerTest { flow.getDescription(), FlowData.builder().blocks(flow.getBlocks()).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); - executionsController.prepareStringInputs(executionObject.getId(), block.getId(), "name", "Ada"); - executionObject = executionsController.start(executionObject.getId()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); + executionsController.prepareStringInputs(executionObject.getId(), block.getId(), "name", "Ada", testUser()); + executionObject = executionsController.start(executionObject.getId(), testUser()); waitForExecutionStatus(executionObject, ExecutionStatus.SUCCESS); - List events = executionsController.getEvents(executionObject.getId()); + List events = executionsController.getEvents(executionObject.getId(), testUser()); org.junit.jupiter.api.Assertions.assertFalse(events.isEmpty()); org.junit.jupiter.api.Assertions.assertTrue(events.stream().anyMatch(event -> event.getType() == ExecutionEventType.LLM_REQUEST)); } @@ -213,10 +220,62 @@ public class ExecutionControllerTest { public void getMissingExecutionReturnsNotFound() { ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows( ResponseStatusException.class, - () -> executionsController.get("missing-execution-id")); + () -> executionsController.get("missing-execution-id", testUser())); org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode()); } + @Test + public void getAllExecutionsForUserReturnsOnlyOwnedExecutions() { + Block block = blocksController.create(LLMBlockConfiguration.builder() + .prompt("Hello, ${{name}}!") + .name("owned") + .llmDescriptor(LLMDescriptor.builder().provider("testProvider").model("testModel").build()) + .build()); + + FlowCreateRequest request = new FlowCreateRequest( + "Owned Executions Flow", + "Execution ownership", + FlowData.builder().blocks(List.of(block)).build()); + + FlowView firstFlow = flowController.createFlow(request, new LoginEntity("owner-a", "pwd")).getBody(); + FlowView secondFlow = flowController.createFlow(request, new LoginEntity("owner-b", "pwd")).getBody(); + + ExecutionView firstExecution = executionsController.create(firstFlow.id(), new LoginEntity("owner-a", "pwd")); + ExecutionView secondExecution = executionsController.create(secondFlow.id(), new LoginEntity("owner-b", "pwd")); + + List ownerExecutions = executionsController.getAll(new LoginEntity("owner-a", "pwd")); + org.junit.jupiter.api.Assertions.assertEquals(1, ownerExecutions.size()); + org.junit.jupiter.api.Assertions.assertEquals(firstExecution.getId(), ownerExecutions.getFirst().getId()); + org.junit.jupiter.api.Assertions.assertNotEquals(secondExecution.getId(), ownerExecutions.getFirst().getId()); + + List otherUserExecutions = executionsController.getAll(new LoginEntity("owner-b", "pwd")); + org.junit.jupiter.api.Assertions.assertEquals(1, otherUserExecutions.size()); + org.junit.jupiter.api.Assertions.assertEquals(secondExecution.getId(), otherUserExecutions.getFirst().getId()); + } + + @Test + public void executionOwnedByAnotherUserReturnsForbidden() { + Block block = blocksController.create(LLMBlockConfiguration.builder() + .prompt("Hello, ${{name}}!") + .name("forbidden") + .llmDescriptor(LLMDescriptor.builder().provider("testProvider").model("testModel").build()) + .build()); + + FlowCreateRequest request = new FlowCreateRequest( + "Forbidden Execution Flow", + "Execution ownership restriction", + FlowData.builder().blocks(List.of(block)).build()); + + FlowView flow = flowController.createFlow(request, new LoginEntity("owner-a", "pwd")).getBody(); + ExecutionView execution = executionsController.create(flow.id(), new LoginEntity("owner-a", "pwd")); + + ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> executionsController.get(execution.getId(), new LoginEntity("owner-b", "pwd"))); + + org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.FORBIDDEN, exception.getStatusCode()); + } + @Test public void executionPayloadDoesNotSerializeEvents() throws JsonProcessingException { LLMDescriptor llmDescriptor = LLMDescriptor.builder() @@ -241,7 +300,8 @@ public class ExecutionControllerTest { flow.getDescription(), FlowData.builder().blocks(flow.getBlocks()).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); String serialized = ObjectMapperHolder.mapper.writeValueAsString(executionObject); JsonNode root = ObjectMapperHolder.mapper.readTree(serialized); @@ -273,7 +333,8 @@ public class ExecutionControllerTest { ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows( ResponseStatusException.class, - () -> executionsController.create(createdFlow.getBody().id())); + () -> executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword"))); org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); org.junit.jupiter.api.Assertions.assertTrue(exception.getReason().contains("\"field\":\"specificConfiguration.llmDescriptor\"")); @@ -297,15 +358,17 @@ public class ExecutionControllerTest { FlowData.builder().block(reviewBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); executionsController.prepareStringInputs(executionObject.getId(), reviewBlock.getId(), - reviewBlock.getInputs().getFirst().getName(), "Candidate summary"); - executionsController.start(executionObject.getId()); + reviewBlock.getInputs().getFirst().getName(), "Candidate summary", testUser()); + executionsController.start(executionObject.getId(), testUser()); waitForExecutionStatus(executionObject, ExecutionStatus.WAITING); - executionsController.provideInteractionValue(executionObject.getId(), reviewBlock.getId(), "output", "Approved"); + executionsController.provideInteractionValue(executionObject.getId(), reviewBlock.getId(), "output", "Approved", + testUser()); waitForExecutionStatus(executionObject, ExecutionStatus.SUCCESS); } @@ -329,7 +392,8 @@ public class ExecutionControllerTest { FlowData.builder().block(llmBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); org.junit.jupiter.api.Assertions.assertEquals(1, executionObject.getRequiredAuthorizations().size()); org.junit.jupiter.api.Assertions.assertEquals("LLMProvider::Gemini::authorization", @@ -338,15 +402,15 @@ public class ExecutionControllerTest { String executionId = executionObject.getId(); executionsController.prepareStringInputs(executionId, llmBlock.getId(), - llmBlock.getInputs().getFirst().getName(), "Ada Lovelace"); + llmBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); IllegalStateException missingAuthorization = org.junit.jupiter.api.Assertions.assertThrows( IllegalStateException.class, - () -> executionsController.start(executionId)); + () -> executionsController.start(executionId, testUser())); org.junit.jupiter.api.Assertions.assertTrue(missingAuthorization.getMessage().contains("LLMProvider::Gemini::authorization")); executionObject = executionsController.provideAuthorization(executionId, - new ExecutionAuthorizationValueRequest("LLMProvider::Gemini::authorization", "test-api-key")); + new ExecutionAuthorizationValueRequest("LLMProvider::Gemini::authorization", "test-api-key"), testUser()); org.junit.jupiter.api.Assertions.assertTrue(executionObject.getMissingAuthorizationKeys().isEmpty()); org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.READY, executionObject.getContext().getStatus()); @@ -370,14 +434,15 @@ public class ExecutionControllerTest { FlowData.builder().block(reviewBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); executionsController.prepareStringInputs(executionObject.getId(), reviewBlock.getId(), - reviewBlock.getInputs().getFirst().getName(), "Ada Lovelace"); - executionsController.start(executionObject.getId()); + reviewBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); + executionsController.start(executionObject.getId(), testUser()); waitForExecutionStatus(executionObject, ExecutionStatus.WAITING); - ExecutionView cancelled = executionsController.cancel(executionObject.getId()); + ExecutionView cancelled = executionsController.cancel(executionObject.getId(), testUser()); org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.CANCELLED, cancelled.getContext().getStatus()); org.junit.jupiter.api.Assertions.assertTrue(cancelled.getContext().getStatus().isFinalState()); @@ -406,23 +471,25 @@ public class ExecutionControllerTest { FlowData.builder().block(reviewBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); executionsController.prepareStringInputs(executionObject.getId(), reviewBlock.getId(), - reviewBlock.getInputs().getFirst().getName(), "Ada Lovelace"); - executionsController.start(executionObject.getId()); + reviewBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); + executionsController.start(executionObject.getId(), testUser()); waitForExecutionStatus(executionObject, ExecutionStatus.WAITING); String executionId = executionObject.getId(); executionsService.clearInMemoryExecutions(); - ExecutionView suspended = executionsController.get(executionId); + ExecutionView suspended = executionsController.get(executionId, testUser()); org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.SUSPENDED, suspended.getContext().getStatus()); - ExecutionView resumed = executionsController.resume(executionId); + ExecutionView resumed = executionsController.resume(executionId, testUser()); org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.WAITING, resumed.getContext().getStatus()); - resumed = executionsController.provideInteractionValue(executionId, reviewBlock.getId(), "output", "Approved"); + resumed = executionsController.provideInteractionValue(executionId, reviewBlock.getId(), "output", "Approved", + testUser()); waitForExecutionStatus(resumed, ExecutionStatus.SUCCESS); } @@ -450,14 +517,15 @@ public class ExecutionControllerTest { FlowData.builder().block(chatBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); org.junit.jupiter.api.Assertions.assertTrue(executionObject.isSimulationAvailable()); executionsController.prepareStringInputs(executionObject.getId(), chatBlock.getId(), - chatBlock.getInputs().getFirst().getName(), "Ada Lovelace"); + chatBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); executionObject = executionsController.simulate(executionObject.getId(), - new ExecutionSimulationRequest(simulatorDescriptor)); + new ExecutionSimulationRequest(simulatorDescriptor), testUser()); org.junit.jupiter.api.Assertions.assertTrue(executionObject.isInteractionSimulationEnabled()); waitForExecutionStatus(executionObject, ExecutionStatus.SUCCESS); } @@ -481,12 +549,13 @@ public class ExecutionControllerTest { FlowData.builder().block(llmBlock).build()); ResponseEntity createdFlow = flowController.createFlow(request, new LoginEntity("testuser", "testpassword")); - ExecutionView executionObject = executionsController.create(createdFlow.getBody().id()); + ExecutionView executionObject = executionsController.create(createdFlow.getBody().id(), + new LoginEntity("testuser", "testpassword")); org.junit.jupiter.api.Assertions.assertFalse(executionObject.isSimulationAvailable()); executionsController.prepareStringInputs(executionObject.getId(), llmBlock.getId(), - llmBlock.getInputs().getFirst().getName(), "Ada Lovelace"); + llmBlock.getInputs().getFirst().getName(), "Ada Lovelace", testUser()); ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows( ResponseStatusException.class, @@ -494,15 +563,15 @@ public class ExecutionControllerTest { new ExecutionSimulationRequest(LLMDescriptor.builder() .provider("testProvider") .model("simulateModel") - .build()))); + .build()), testUser())); org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.BAD_REQUEST, exception.getStatusCode()); - org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.READY, executionObject.getContext().getStatus()); + org.junit.jupiter.api.Assertions.assertEquals(ExecutionStatus.CREATED, executionObject.getContext().getStatus()); } private void waitForExecutionStatus(ExecutionView executionObject, ExecutionStatus expectedStatus) { long deadline = System.currentTimeMillis() + 5_000; while (System.currentTimeMillis() < deadline) { - executionObject = executionsController.get(executionObject.getId()); + executionObject = executionsController.get(executionObject.getId(), testUser()); if (executionObject.getContext().getStatus() == expectedStatus) { return; } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java new file mode 100644 index 0000000..cb71da7 --- /dev/null +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/StatsControllerTest.java @@ -0,0 +1,366 @@ +package it.cnr.isti.workflow.manager.controllers; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import it.cnr.isti.workflow.manager.auth.model.UserRole; +import it.cnr.isti.workflow.manager.auth.repo.AuthRepository; +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; +import it.cnr.isti.workflow.manager.executions.ExecutionStatus; +import it.cnr.isti.workflow.manager.executions.persistence.ExecutionSnapshot; +import it.cnr.isti.workflow.manager.executions.repo.ExecutionEntity; +import it.cnr.isti.workflow.manager.executions.repo.ExecutionRepository; +import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.flows.repo.FlowEntity; +import it.cnr.isti.workflow.manager.flows.repo.FlowRepository; +import it.cnr.isti.workflow.manager.stats.model.SystemUsageStatsView; +import it.cnr.isti.workflow.manager.stats.model.UserUsageStatsView; + +@SpringBootTest +@TestPropertySource(locations = "classpath:test.properties") +public class StatsControllerTest { + + @Autowired + private StatsController statsController; + + @Autowired + private AuthRepository authRepository; + + @Autowired + private FlowRepository flowRepository; + + @Autowired + private ExecutionRepository executionRepository; + + @Test + public void nonAdminCannotReadOwnUsageStats() { + authRepository.save(new LoginEntity("statsuser", "hashed", "statsuser@example.com", UserRole.USER)); + + flowRepository.save(FlowEntity.builder() + .name("Flow One") + .owner("statsuser") + .description("first") + .createdAt(LocalDateTime.now().minusDays(2)) + .lastUpdateAt(LocalDateTime.now().minusDays(1)) + .published(true) + .finalized(true) + .flow(FlowData.builder().build()) + .build()); + + flowRepository.save(FlowEntity.builder() + .name("Flow Two") + .owner("statsuser") + .description("second") + .createdAt(LocalDateTime.now().minusHours(10)) + .lastUpdateAt(LocalDateTime.now()) + .published(false) + .finalized(false) + .flow(FlowData.builder().build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-1") + .name("Execution One") + .owner("statsuser") + .creationTime(100L) + .lastUpdateTime(200L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.SUCCESS) + .interactionSimulationEnabled(true) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-2") + .name("Execution Two") + .owner("statsuser") + .creationTime(300L) + .lastUpdateTime(400L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.ERROR) + .interactionSimulationEnabled(false) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-3") + .name("Execution Three") + .owner("statsuser") + .creationTime(500L) + .lastUpdateTime(600L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.RUNNING) + .interactionSimulationEnabled(false) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + ResponseEntity response = statsController.getUserStats( + "statsuser", + new LoginEntity("statsuser", "ignored", "statsuser@example.com", UserRole.USER)); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + public void adminCanReadOtherUserUsageStats() { + authRepository.save(new LoginEntity("targetuser", "hashed", "target@example.com", UserRole.USER)); + authRepository.save(new LoginEntity("adminstats", "hashed", "admin@example.com", UserRole.ADMIN)); + + ResponseEntity response = statsController.getUserStats( + "targetuser", + new LoginEntity("adminstats", "ignored", "admin@example.com", UserRole.ADMIN)); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("targetuser", response.getBody().getUsername()); + } + + @Test + public void adminCanReadOwnUsageStats() { + authRepository.save(new LoginEntity("adminstats3", "hashed", "admin3@example.com", UserRole.ADMIN)); + + flowRepository.save(FlowEntity.builder() + .name("Flow One") + .owner("adminstats3") + .description("first") + .createdAt(LocalDateTime.now().minusDays(2)) + .lastUpdateAt(LocalDateTime.now().minusDays(1)) + .published(true) + .finalized(true) + .flow(FlowData.builder().build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-admin-1") + .name("Execution One") + .owner("adminstats3") + .creationTime(100L) + .lastUpdateTime(200L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.SUCCESS) + .interactionSimulationEnabled(true) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + ResponseEntity response = statsController.getUserStats( + "adminstats3", + new LoginEntity("adminstats3", "ignored", "admin3@example.com", UserRole.ADMIN)); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("adminstats3", response.getBody().getUsername()); + assertEquals(1L, response.getBody().getFlowsCreated()); + assertEquals(1L, response.getBody().getFlowsPublished()); + assertEquals(1L, response.getBody().getFlowsFinalized()); + assertEquals(1L, response.getBody().getExecutionsCreated()); + assertEquals(0L, response.getBody().getExecutionsRunning()); + assertEquals(1L, response.getBody().getExecutionsSucceeded()); + assertEquals(0L, response.getBody().getExecutionsFailed()); + assertEquals(1L, response.getBody().getSimulationsStarted()); + assertEquals(200L, response.getBody().getLastExecutionAt()); + assertNotNull(response.getBody().getLastFlowUpdateAt()); + } + + @Test + public void deletedUserStillAppearsInUserStats() { + LoginEntity deletedUser = new LoginEntity("deletedstats", "hashed", "deletedstats@example.com", UserRole.USER); + deletedUser.setActive(false); + authRepository.save(deletedUser); + authRepository.save(new LoginEntity("adminstatsDeleted", "hashed", "admin-deleted@example.com", UserRole.ADMIN)); + + flowRepository.save(FlowEntity.builder() + .name("Deleted Flow") + .owner("deletedstats") + .description("deleted") + .createdAt(LocalDateTime.now().minusDays(1)) + .lastUpdateAt(LocalDateTime.now().minusHours(1)) + .published(false) + .finalized(false) + .flow(FlowData.builder().build()) + .build()); + + ResponseEntity response = statsController.getUserStats( + "deletedstats", + new LoginEntity("adminstatsDeleted", "ignored", "admin-deleted@example.com", UserRole.ADMIN)); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals("deletedstats", response.getBody().getUsername()); + assertEquals(1L, response.getBody().getFlowsCreated()); + } + + @Test + public void adminCanReadAllUserUsageStats() { + authRepository.save(new LoginEntity("usera", "hashed", "usera@example.com", UserRole.USER)); + authRepository.save(new LoginEntity("userb", "hashed", "userb@example.com", UserRole.USER)); + authRepository.save(new LoginEntity("adminstats4", "hashed", "admin4@example.com", UserRole.ADMIN)); + + flowRepository.save(FlowEntity.builder() + .name("Flow A") + .owner("usera") + .description("a") + .createdAt(LocalDateTime.now().minusDays(1)) + .lastUpdateAt(LocalDateTime.now().minusHours(2)) + .published(true) + .finalized(false) + .flow(FlowData.builder().build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-list-1") + .name("Execution A") + .owner("usera") + .creationTime(10L) + .lastUpdateTime(20L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.SUCCESS) + .interactionSimulationEnabled(false) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + ResponseEntity> response = statsController.getAllUserStats( + new LoginEntity("adminstats4", "ignored", "admin4@example.com", UserRole.ADMIN)); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertFalse(response.getBody().isEmpty()); + org.junit.jupiter.api.Assertions.assertTrue(response.getBody().contains("adminstats4")); + org.junit.jupiter.api.Assertions.assertTrue(response.getBody().contains("usera")); + } + + @Test + public void adminCanReadSystemUsageStats() { + authRepository.save(new LoginEntity("systemadmin", "hashed", "systemadmin@example.com", UserRole.ADMIN)); + authRepository.save(new LoginEntity("systemuser", "hashed", "systemuser@example.com", UserRole.USER)); + + flowRepository.save(FlowEntity.builder() + .name("Flow S1") + .owner("systemuser") + .description("s1") + .createdAt(LocalDateTime.now().minusDays(1)) + .lastUpdateAt(LocalDateTime.now().minusHours(4)) + .published(true) + .finalized(false) + .flow(FlowData.builder().build()) + .build()); + + flowRepository.save(FlowEntity.builder() + .name("Flow S2") + .owner("systemadmin") + .description("s2") + .createdAt(LocalDateTime.now().minusHours(12)) + .lastUpdateAt(LocalDateTime.now().minusHours(1)) + .published(false) + .finalized(true) + .flow(FlowData.builder().build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-system-1") + .name("Execution S1") + .owner("systemuser") + .creationTime(10L) + .lastUpdateTime(20L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.SUCCESS) + .interactionSimulationEnabled(true) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + executionRepository.save(ExecutionEntity.builder() + .id("exec-system-2") + .name("Execution S2") + .owner("systemadmin") + .creationTime(30L) + .lastUpdateTime(40L) + .flow(FlowData.builder().build()) + .snapshot(ExecutionSnapshot.builder() + .status(ExecutionStatus.ERROR) + .interactionSimulationEnabled(false) + .providedAuthorizations(Map.of()) + .build()) + .build()); + + ResponseEntity response = statsController.getSystemStats( + new LoginEntity("systemadmin", "ignored", "systemadmin@example.com", UserRole.ADMIN)); + + assertEquals(200, response.getStatusCode().value()); + assertNotNull(response.getBody()); + assertEquals(2L, response.getBody().getFlowsCreated()); + assertEquals(1L, response.getBody().getFlowsPublished()); + assertEquals(1L, response.getBody().getFlowsFinalized()); + assertEquals(2L, response.getBody().getExecutionsCreated()); + assertEquals(0L, response.getBody().getExecutionsRunning()); + assertEquals(1L, response.getBody().getExecutionsSucceeded()); + assertEquals(1L, response.getBody().getExecutionsFailed()); + assertEquals(1L, response.getBody().getSimulationsStarted()); + assertEquals(40L, response.getBody().getLastExecutionAt()); + assertNotNull(response.getBody().getLastFlowUpdateAt()); + } + + @Test + public void nonAdminCannotReadSystemUsageStats() { + authRepository.save(new LoginEntity("plainsystemuser", "hashed", "plainsystem@example.com", UserRole.USER)); + + ResponseEntity response = statsController.getSystemStats( + new LoginEntity("plainsystemuser", "ignored", "plainsystem@example.com", UserRole.USER)); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + public void nonAdminCannotReadAllUserUsageStats() { + authRepository.save(new LoginEntity("plainuser", "hashed", "plain@example.com", UserRole.USER)); + + ResponseEntity> response = statsController.getAllUserStats( + new LoginEntity("plainuser", "ignored", "plain@example.com", UserRole.USER)); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + public void nonAdminCannotReadOtherUserUsageStats() { + authRepository.save(new LoginEntity("targetuser2", "hashed", "target2@example.com", UserRole.USER)); + authRepository.save(new LoginEntity("otheruser2", "hashed", "other2@example.com", UserRole.USER)); + + ResponseEntity response = statsController.getUserStats( + "targetuser2", + new LoginEntity("otheruser2", "ignored", "other2@example.com", UserRole.USER)); + + assertEquals(403, response.getStatusCode().value()); + } + + @Test + public void missingUserStatsReturnNotFound() { + authRepository.save(new LoginEntity("adminstats2", "hashed", "admin2@example.com", UserRole.ADMIN)); + + ResponseEntity response = statsController.getUserStats( + "missing-user", + new LoginEntity("adminstats2", "ignored", "admin2@example.com", UserRole.ADMIN)); + + assertEquals(404, response.getStatusCode().value()); + } +}