From 8c26b5d8fcda9d8d54a75f82a7fa01a11cf5446f Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 26 Mar 2026 13:01:14 +0100 Subject: [PATCH] Add flow publication and finalization controls --- .../manager/controllers/FlowController.java | 33 ++++ .../workflow/manager/flows/FlowService.java | 43 +++++ .../flows/model/FlowFlagUpdateRequest.java | 7 + .../controllers/FlowControllerTest.java | 165 ++++++++++++++++++ 4 files changed, 248 insertions(+) create mode 100644 src/main/java/it/cnr/isti/workflow/manager/flows/model/FlowFlagUpdateRequest.java diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/FlowController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/FlowController.java index a15b54f..f3b7181 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/FlowController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/FlowController.java @@ -23,6 +23,7 @@ import it.cnr.isti.workflow.manager.flows.FlowAccessDeniedException; import it.cnr.isti.workflow.manager.flows.FlowNotFoundException; import it.cnr.isti.workflow.manager.flows.FlowService; import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest; +import it.cnr.isti.workflow.manager.flows.model.FlowFlagUpdateRequest; import it.cnr.isti.workflow.manager.flows.model.FlowView; @@ -118,4 +119,36 @@ public class FlowController { } } + @PutMapping("/{id}/published") + @Operation(summary = "Update published flag", description = "Updates the published flag of a flow owned by the authenticated user.") + public ResponseEntity updatePublished(@PathVariable String id, + @RequestBody @Valid FlowFlagUpdateRequest request, + @AuthenticationPrincipal LoginEntity userDetails) { + try { + return ResponseEntity.ok(flowService.updatePublished(id, userDetails.getUsername(), request)); + } catch (FlowNotFoundException e) { + logger.warn("Update published flag for flow {} failed for user {}: not found", id, userDetails.getUsername()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } catch (FlowAccessDeniedException e) { + logger.warn("Update published flag for flow {} forbidden for user {}", id, userDetails.getUsername()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + + @PutMapping("/{id}/finalized") + @Operation(summary = "Update finalized flag", description = "Updates the finalized flag of a flow owned by the authenticated user.") + public ResponseEntity updateFinalized(@PathVariable String id, + @RequestBody @Valid FlowFlagUpdateRequest request, + @AuthenticationPrincipal LoginEntity userDetails) { + try { + return ResponseEntity.ok(flowService.updateFinalized(id, userDetails.getUsername(), request)); + } catch (FlowNotFoundException e) { + logger.warn("Update finalized flag for flow {} failed for user {}: not found", id, userDetails.getUsername()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } catch (FlowAccessDeniedException e) { + logger.warn("Update finalized flag for flow {} forbidden for user {}", id, userDetails.getUsername()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + } + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/FlowService.java b/src/main/java/it/cnr/isti/workflow/manager/flows/FlowService.java index 4e30ad0..121845e 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/flows/FlowService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/FlowService.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest; +import it.cnr.isti.workflow.manager.flows.model.FlowFlagUpdateRequest; import it.cnr.isti.workflow.manager.flows.model.FlowView; import it.cnr.isti.workflow.manager.flows.model.FlowViewStatus; import it.cnr.isti.workflow.manager.flows.repo.FlowEntity; @@ -48,6 +49,7 @@ public class FlowService { logger.warn("User {} attempted to update flow {} owned by {}", owner, id, entity.getOwner()); throw new FlowAccessDeniedException(); } + ensureMutable(entity); validateFlow(request); FlowMapper.updateEntity(entity, request); @@ -63,10 +65,45 @@ public class FlowService { logger.warn("User {} attempted to delete flow {} owned by {}", owner, id, entity.getOwner()); throw new FlowAccessDeniedException(); } + ensureMutable(entity); flowRepository.delete(entity); } + public FlowView updatePublished(String id, String owner, FlowFlagUpdateRequest request) { + FlowEntity entity = flowRepository.findById(id) + .orElseThrow(() -> new FlowNotFoundException(id)); + + if (!entity.getOwner().equals(owner)) { + logger.warn("User {} attempted to change publish flag of flow {} owned by {}", owner, id, entity.getOwner()); + throw new FlowAccessDeniedException(); + } + + entity.setPublished(Boolean.TRUE.equals(request.value())); + entity.setLastUpdateAt(java.time.LocalDateTime.now()); + return toView(flowRepository.save(entity)); + } + + public FlowView updateFinalized(String id, String owner, FlowFlagUpdateRequest request) { + FlowEntity entity = flowRepository.findById(id) + .orElseThrow(() -> new FlowNotFoundException(id)); + + if (!entity.getOwner().equals(owner)) { + logger.warn("User {} attempted to change finalized flag of flow {} owned by {}", owner, id, entity.getOwner()); + throw new FlowAccessDeniedException(); + } + if (entity.isFinalized()) { + if (Boolean.TRUE.equals(request.value())) { + return toView(entity); + } + throw new ResponseStatusException(HttpStatus.CONFLICT, "Flow is finalized"); + } + + entity.setFinalized(Boolean.TRUE.equals(request.value())); + entity.setLastUpdateAt(java.time.LocalDateTime.now()); + return toView(flowRepository.save(entity)); + } + public List getAllFlows(String owner) { return flowRepository.findFlowsByOwnerOrPublic(owner).stream().map(this::toView).toList(); } @@ -96,6 +133,12 @@ public class FlowService { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + private void ensureMutable(FlowEntity entity) { + if (entity != null && entity.isFinalized()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "Flow is finalized"); + } + } + private FlowView toView(FlowEntity entity) { FlowViewStatus status = flowExecutionValidator.isExecutable(entity.getFlow()) ? FlowViewStatus.EXECUTABLE diff --git a/src/main/java/it/cnr/isti/workflow/manager/flows/model/FlowFlagUpdateRequest.java b/src/main/java/it/cnr/isti/workflow/manager/flows/model/FlowFlagUpdateRequest.java new file mode 100644 index 0000000..45423c9 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/flows/model/FlowFlagUpdateRequest.java @@ -0,0 +1,7 @@ +package it.cnr.isti.workflow.manager.flows.model; + +import jakarta.validation.constraints.NotNull; + +public record FlowFlagUpdateRequest( + @NotNull Boolean value) { +} diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java index 3fd52dc..2993de1 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java @@ -45,6 +45,7 @@ import it.cnr.isti.workflow.manager.flows.model.Connection; import it.cnr.isti.workflow.manager.flows.model.Flow; import it.cnr.isti.workflow.manager.flows.model.FlowCreateRequest; import it.cnr.isti.workflow.manager.flows.model.FlowData; +import it.cnr.isti.workflow.manager.flows.model.FlowFlagUpdateRequest; import it.cnr.isti.workflow.manager.flows.model.FlowView; import it.cnr.isti.workflow.manager.flows.model.FlowViewStatus; import it.cnr.isti.workflow.manager.flows.repo.FlowRepository; @@ -336,6 +337,170 @@ public class FlowControllerTest { assertEquals(404, response.getStatusCode().value()); } + @Test + public void ownerCanUpdatePublishedAndFinalizedFlags() { + LLMDescriptor llmDescriptor = LLMDescriptor.builder() + .provider("testProvider") + .model("testModel") + .build(); + + Flow flow = flowTestCreator.createFlowWithConnection(llmDescriptor); + FlowCreateRequest request = new FlowCreateRequest( + flow.getName(), + flow.getDescription(), + FlowData.builder() + .blocks(flow.getBlocks()) + .connections(flow.getConnections()) + .build()); + + ResponseEntity createResponse = flowController.createFlow( + request, + new LoginEntity("testuser", "testpassword")); + + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + assertNotNull(createResponse.getBody()); + + ResponseEntity publishedResponse = flowController.updatePublished( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("testuser", "testpassword")); + assertEquals(200, publishedResponse.getStatusCode().value()); + assertNotNull(publishedResponse.getBody()); + assertTrue(publishedResponse.getBody().published()); + + ResponseEntity finalizedResponse = flowController.updateFinalized( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("testuser", "testpassword")); + assertEquals(200, finalizedResponse.getStatusCode().value()); + assertNotNull(finalizedResponse.getBody()); + assertTrue(finalizedResponse.getBody().finalized()); + } + + @Test + public void nonOwnerCannotUpdatePublishedAndFinalizedFlags() { + LLMDescriptor llmDescriptor = LLMDescriptor.builder() + .provider("testProvider") + .model("testModel") + .build(); + + Flow flow = flowTestCreator.createFlowWithConnection(llmDescriptor); + FlowCreateRequest request = new FlowCreateRequest( + flow.getName(), + flow.getDescription(), + FlowData.builder() + .blocks(flow.getBlocks()) + .connections(flow.getConnections()) + .build()); + + ResponseEntity createResponse = flowController.createFlow( + request, + new LoginEntity("testuser", "testpassword")); + + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + assertNotNull(createResponse.getBody()); + + ResponseEntity publishedResponse = flowController.updatePublished( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("otheruser", "testpassword")); + assertEquals(403, publishedResponse.getStatusCode().value()); + + ResponseEntity finalizedResponse = flowController.updateFinalized( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("otheruser", "testpassword")); + assertEquals(403, finalizedResponse.getStatusCode().value()); + } + + @Test + public void finalizedFlowCannotBeModified() { + LLMDescriptor llmDescriptor = LLMDescriptor.builder() + .provider("testProvider") + .model("testModel") + .build(); + + Flow flow = flowTestCreator.createFlowWithConnection(llmDescriptor); + FlowCreateRequest createRequest = new FlowCreateRequest( + flow.getName(), + flow.getDescription(), + FlowData.builder() + .blocks(flow.getBlocks()) + .connections(flow.getConnections()) + .build()); + + ResponseEntity createResponse = flowController.createFlow( + createRequest, + new LoginEntity("testuser", "testpassword")); + + assertTrue(createResponse.getStatusCode().is2xxSuccessful()); + assertNotNull(createResponse.getBody()); + + ResponseEntity finalizedResponse = flowController.updateFinalized( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("testuser", "testpassword")); + assertEquals(200, finalizedResponse.getStatusCode().value()); + assertTrue(finalizedResponse.getBody().finalized()); + + Flow updatedFlowDefinition = flowTestCreator.createFlowWithInteraction(llmDescriptor); + FlowCreateRequest updateRequest = new FlowCreateRequest( + updatedFlowDefinition.getName(), + updatedFlowDefinition.getDescription(), + FlowData.builder() + .blocks(updatedFlowDefinition.getBlocks()) + .connections(updatedFlowDefinition.getConnections()) + .build()); + + ResponseStatusException updateException = assertThrows( + ResponseStatusException.class, + () -> flowController.updateFlow( + createResponse.getBody().id(), + updateRequest, + new LoginEntity("testuser", "testpassword"))); + assertEquals(HttpStatus.CONFLICT, updateException.getStatusCode()); + + ResponseStatusException publishException = assertThrows( + ResponseStatusException.class, + () -> flowController.updateFinalized( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(false), + new LoginEntity("testuser", "testpassword"))); + assertEquals(HttpStatus.CONFLICT, publishException.getStatusCode()); + + ResponseEntity unpublishedResponse = flowController.updatePublished( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(false), + new LoginEntity("testuser", "testpassword")); + assertEquals(200, unpublishedResponse.getStatusCode().value()); + assertNotNull(unpublishedResponse.getBody()); + assertTrue(unpublishedResponse.getBody().finalized()); + assertEquals(false, unpublishedResponse.getBody().published()); + + ResponseEntity publishedResponse = flowController.updatePublished( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("testuser", "testpassword")); + assertEquals(200, publishedResponse.getStatusCode().value()); + assertNotNull(publishedResponse.getBody()); + assertTrue(publishedResponse.getBody().finalized()); + assertTrue(publishedResponse.getBody().published()); + + ResponseEntity idempotentFinalizeResponse = flowController.updateFinalized( + createResponse.getBody().id(), + new FlowFlagUpdateRequest(true), + new LoginEntity("testuser", "testpassword")); + assertEquals(200, idempotentFinalizeResponse.getStatusCode().value()); + assertTrue(idempotentFinalizeResponse.getBody().finalized()); + + ResponseStatusException deleteException = assertThrows( + ResponseStatusException.class, + () -> flowController.deleteFlow( + createResponse.getBody().id(), + new LoginEntity("testuser", "testpassword"))); + assertEquals(HttpStatus.CONFLICT, deleteException.getStatusCode()); + } + @Test public void deleteFlow() { LLMDescriptor llmDescriptor = LLMDescriptor.builder()