Add execution ownership filtering and usage stats

This commit is contained in:
Lucio Lelii 2026-03-26 13:01:29 +01:00
parent 8c26b5d8fc
commit eb66452f03
11 changed files with 850 additions and 72 deletions

View File

@ -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<ExecutionView> getAll() {
return executionService.getAllExecutions().stream().map(ExecutionView::fromExecution).toList();
public List<ExecutionView> 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<String,ExecutionContextView> getAllContexts() {
return executionService.getAllExecutions().stream()
public Map<String,ExecutionContextView> 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<ExecutionObject> visibleExecutions(LoginEntity userDetails) {
List<ExecutionObject> 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<ExecutionEvent> getEvents(@PathVariable String id) {
return executionService.getExecution(id).getContext().getEvents();
public List<ExecutionEvent> 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<String> input) {
return ExecutionView.fromExecution(executionService.prepareInput(executionId, nodeId, inputName, input));
@PathVariable String inputName, @RequestBody List<String> 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<MultipartFile> files) {
@PathVariable String inputName, @RequestParam List<MultipartFile> files,
@AuthenticationPrincipal LoginEntity userDetails) {
List<File> 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;
}
}

View File

@ -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<SystemUsageStatsView> 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<List<String>> 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<UserUsageStatsView> 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()));
}
}

View File

@ -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<ExecutionAuthorizationRequirement> requiredAuthorizations) {
public ExecutionObject(String executionName, FlowData flow, List<ExecutionAuthorizationRequirement> 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);

View File

@ -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<ExecutionAuthorizationRequirement> 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(),

View File

@ -27,6 +27,8 @@ public class ExecutionEntity {
@NotBlank
private String name;
private String owner;
private long creationTime;
private long lastUpdateTime;

View File

@ -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);
}
}

View File

@ -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<FlowEntity> flows = flowRepository.findAll();
List<ExecutionEntity> 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<String> 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<FlowEntity> flows = flowRepository.findByOwner(username);
List<ExecutionEntity> 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();
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<FlowView> 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<FlowView> 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<ExecutionEvent> events = executionsController.getEvents(executionObject.getId());
List<ExecutionEvent> 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<LLMBlockType> 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<ExecutionView> 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<ExecutionView> 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<LLMBlockType> 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<FlowView> 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<FlowView> 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<FlowView> 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<FlowView> 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<FlowView> 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<FlowView> 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<FlowView> 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;
}

View File

@ -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<UserUsageStatsView> 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<UserUsageStatsView> 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<UserUsageStatsView> 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<UserUsageStatsView> 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<List<String>> 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<SystemUsageStatsView> 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<SystemUsageStatsView> 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<List<String>> 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<UserUsageStatsView> 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<UserUsageStatsView> response = statsController.getUserStats(
"missing-user",
new LoginEntity("adminstats2", "ignored", "admin2@example.com", UserRole.ADMIN));
assertEquals(404, response.getStatusCode().value());
}
}