From 652270302d369da370e089f2e29818e6fe18ab67 Mon Sep 17 00:00:00 2001 From: Stefan Guilhen Date: Thu, 9 Oct 2025 19:10:15 -0300 Subject: [PATCH] Workflows code cleanup Signed-off-by: Stefan Guilhen --- .../client/resource/WorkflowResource.java | 2 - .../resource/WorkflowStepsResource.java | 41 -- .../models/workflow/WorkflowsManager.java | 577 +++++++----------- .../admin/resource/WorkflowResource.java | 4 - .../admin/resource/WorkflowStepsResource.java | 179 ------ .../UserSessionRefreshTimeWorkflowTest.java | 13 +- .../workflow/WorkflowManagementTest.java | 2 +- .../workflow/WorkflowStepManagementTest.java | 298 --------- 8 files changed, 235 insertions(+), 881 deletions(-) delete mode 100644 integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowStepsResource.java delete mode 100644 services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java delete mode 100644 tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java index b4557772e4b..beab98e3299 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowResource.java @@ -37,6 +37,4 @@ public interface WorkflowResource { @Consumes(MediaType.APPLICATION_JSON) void bind(@PathParam("type") String type, @PathParam("resourceId") String resourceId, Long milliseconds); - @Path("steps") - WorkflowStepsResource steps(); } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowStepsResource.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowStepsResource.java deleted file mode 100644 index d56f5e4e6af..00000000000 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowStepsResource.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.keycloak.admin.client.resource; - -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DELETE; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; -import org.keycloak.representations.workflows.WorkflowStepRepresentation; - -import java.util.List; - -public interface WorkflowStepsResource { - - @GET - @Produces(MediaType.APPLICATION_JSON) - List list(); - - @POST - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - Response create(WorkflowStepRepresentation stepRep, @QueryParam("position") Integer position); - - @POST - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - Response create(WorkflowStepRepresentation stepRep); - - @Path("{stepId}") - @GET - @Produces(MediaType.APPLICATION_JSON) - WorkflowStepRepresentation get(@PathParam("stepId") String stepId); - - @Path("{stepId}") - @DELETE - Response delete(@PathParam("stepId") String stepId); -} \ No newline at end of file diff --git a/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java b/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java index d4d45ff87e7..16a860733ef 100644 --- a/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java +++ b/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java @@ -41,7 +41,6 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Objects; -import java.util.stream.Stream; import static java.util.Optional.ofNullable; import static org.keycloak.representations.workflows.WorkflowConstants.CONFIG_CONDITIONS; @@ -65,9 +64,7 @@ public class WorkflowsManager { this.workflowStateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session); } - public Workflow addWorkflow(String providerId, Map> config) { - return addWorkflow(new Workflow(providerId, config)); - } + /* ========================================= Workflows CRUD operations =========================================== */ private Workflow addWorkflow(Workflow workflow) { RealmModel realm = getRealm(); @@ -99,7 +96,7 @@ public class WorkflowsManager { } } - private WorkflowStep addStep(Workflow workflow, WorkflowStep step) { + private void addStep(Workflow workflow, WorkflowStep step) { RealmModel realm = getRealm(); ComponentModel workflowModel = realm.getComponent(workflow.getId()); @@ -108,14 +105,65 @@ public class WorkflowsManager { } ComponentModel stepModel = new ComponentModel(); - stepModel.setId(step.getId());//need to keep stable UUIDs not to break a link in state table stepModel.setParentId(workflowModel.getId()); stepModel.setProviderId(step.getProviderId()); stepModel.setProviderType(WorkflowStepProvider.class.getName()); stepModel.setConfig(step.getConfig()); + realm.addComponentModel(stepModel); + } - return new WorkflowStep(realm.addComponentModel(stepModel)); + public void updateWorkflow(Workflow workflow, WorkflowRepresentation representation) { + + WorkflowRepresentation currentRep = toRepresentation(workflow); + + // we compare the representation, removing first the entries we allow updating. If anything else changes, we throw a validation exception + String currentName = currentRep.getName(); currentRep.getConfig().remove(CONFIG_NAME); + String newName = representation.getName(); representation.getConfig().remove(CONFIG_NAME); + Boolean currentEnabled = currentRep.getEnabled(); currentRep.getConfig().remove(CONFIG_ENABLED); + Boolean newEnabled = representation.getEnabled(); representation.getConfig().remove(CONFIG_ENABLED); + + if (!currentRep.equals(representation)) { + throw new ModelValidationException("Workflow update can only change 'name' and 'enabled' config entries."); + } + + if (!Objects.equals(currentName, newName) || !Objects.equals(currentEnabled, newEnabled)) { + // only update component if something changed + representation.setName(newName); + representation.setEnabled(newEnabled); + this.updateWorkflowConfig(workflow, representation.getConfig()); + } + } + + private void updateWorkflowConfig(Workflow workflow, MultivaluedHashMap config) { + ComponentModel component = getWorkflowComponent(workflow.getId()); + component.setConfig(config); + getRealm().updateComponent(component); + } + + public void removeWorkflow(String id) { + RealmModel realm = getRealm(); + realm.getComponentsStream(realm.getId(), WorkflowProvider.class.getName()) + .filter(workflow -> workflow.getId().equals(id)) + .forEach(workflow -> { + realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent); + realm.removeComponent(workflow); + }); + workflowStateProvider.removeByWorkflow(id); + } + + public Workflow getWorkflow(String id) { + return new Workflow(getWorkflowComponent(id)); + } + + private ComponentModel getWorkflowComponent(String id) { + ComponentModel component = getRealm().getComponent(id); + + if (component == null || !WorkflowProvider.class.getName().equals(component.getProviderType())) { + throw new BadRequestException("Not a valid resource workflow: " + id); + } + + return component; } public List getWorkflows() { @@ -125,29 +173,12 @@ public class WorkflowsManager { } public List getSteps(String workflowId) { - return getStepsStream(workflowId).toList(); + RealmModel realm = getRealm(); + return realm.getComponentsStream(workflowId, WorkflowStepProvider.class.getName()) + .map(WorkflowStep::new).sorted().toList(); } - public Stream getStepsStream(String parentId) { - RealmModel realm = session.getContext().getRealm(); - return realm.getComponentsStream(parentId, WorkflowStepProvider.class.getName()) - .map(this::toStep).sorted(); - } - - private WorkflowStep toStep(ComponentModel model) { - return new WorkflowStep(model); - } - - public WorkflowStep getStepById(String id) { - RealmModel realm = session.getContext().getRealm(); - ComponentModel component = realm.getComponent(id); - - if (component == null) { - return null; - } - - return toStep(component); - } + /* ================================= Workflows component providers and factories ================================= */ private WorkflowProvider getWorkflowProvider(Workflow workflow) { ComponentFactory factory = (ComponentFactory) session.getKeycloakSessionFactory() @@ -156,40 +187,47 @@ public class WorkflowsManager { } public WorkflowStepProvider getStepProvider(WorkflowStep step) { - return (WorkflowStepProvider) getStepProviderFactory(step).create(session, getRealm().getComponent(step.getId())); + return getStepProviderFactory(step).create(session, getRealm().getComponent(step.getId())); } - private ComponentFactory getStepProviderFactory(WorkflowStep step) { - ComponentFactory stepFactory = (ComponentFactory) session.getKeycloakSessionFactory() - .getProviderFactory(WorkflowStepProvider.class, step.getProviderId()); + private WorkflowStepProviderFactory getStepProviderFactory(WorkflowStep step) { + WorkflowStepProviderFactory factory = (WorkflowStepProviderFactory) session + .getKeycloakSessionFactory().getProviderFactory(WorkflowStepProvider.class, step.getProviderId()); - if (stepFactory == null) { + if (factory == null) { throw new WorkflowInvalidStateException("Step not found: " + step.getProviderId()); } - return stepFactory; + return factory; } - private RealmModel getRealm() { - return session.getContext().getRealm(); - } + public WorkflowConditionProvider getConditionProvider(String providerId, MultivaluedHashMap modelConfig) { + WorkflowConditionProviderFactory providerFactory = getConditionProviderFactory(providerId); + Map> config = new HashMap<>(); - public void removeWorkflows() { - RealmModel realm = getRealm(); - realm.getComponentsStream(realm.getId(), WorkflowProvider.class.getName()).forEach(workflow -> { - realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent); - realm.removeComponent(workflow); - }); - } - - public void scheduleAllEligibleResources(Workflow workflow) { - if (workflow.isEnabled()) { - WorkflowProvider provider = getWorkflowProvider(workflow); - provider.getEligibleResourcesForInitialStep() - .forEach(resourceId -> processEvent(List.of(workflow), new AdhocWorkflowEvent(ResourceType.USERS, resourceId))); + for (Entry> configEntry : modelConfig.entrySet()) { + if (configEntry.getKey().startsWith(providerId)) { + config.put(configEntry.getKey().substring(providerId.length() + 1), configEntry.getValue()); + } } + + return providerFactory.create(session, config); } + public WorkflowConditionProviderFactory getConditionProviderFactory(String providerId) { + KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); + WorkflowConditionProviderFactory providerFactory = (WorkflowConditionProviderFactory) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId); + + if (providerFactory == null) { + throw new WorkflowInvalidStateException("Could not find condition provider: " + providerId); + } + + return providerFactory; + } + + + /* =================== Workflows execution methods (bind, process events, run scheduled steps) =================== */ + public void processEvent(WorkflowEvent event) { processEvent(getWorkflows(), event); } @@ -267,304 +305,16 @@ public class WorkflowsManager { }); } - public void removeWorkflow(String id) { - RealmModel realm = getRealm(); - realm.getComponentsStream(realm.getId(), WorkflowProvider.class.getName()) - .filter(workflow -> workflow.getId().equals(id)) - .forEach(workflow -> { - realm.getComponentsStream(workflow.getId(), WorkflowStepProvider.class.getName()).forEach(realm::removeComponent); - realm.removeComponent(workflow); - }); - workflowStateProvider.removeByWorkflow(id); - } - - public Workflow getWorkflow(String id) { - return new Workflow(getWorkflowComponent(id)); - } - - public void updateWorkflow(Workflow workflow, WorkflowRepresentation representation) { - - WorkflowRepresentation currentRep = toRepresentation(workflow); - - // we compare the representation, removing first the entries we allow updating. If anything else changes, we throw a validation exception - String currentName = currentRep.getName(); currentRep.getConfig().remove(CONFIG_NAME); - String newName = representation.getName(); representation.getConfig().remove(CONFIG_NAME); - Boolean currentEnabled = currentRep.getEnabled(); currentRep.getConfig().remove(CONFIG_ENABLED); - Boolean newEnabled = representation.getEnabled(); representation.getConfig().remove(CONFIG_ENABLED); - - if (!currentRep.equals(representation)) { - throw new ModelValidationException("Workflow update can only change 'name' and 'enabled' config entries."); - } - - if (!Objects.equals(currentName, newName) || !Objects.equals(currentEnabled, newEnabled)) { - // only update component if something changed - representation.setName(newName); - representation.setEnabled(newEnabled); - this.updateWorkflowConfig(workflow, representation.getConfig()); - } - } - - private void updateWorkflowConfig(Workflow workflow, MultivaluedHashMap config) { - ComponentModel component = getWorkflowComponent(workflow.getId()); - component.setConfig(config); - getRealm().updateComponent(component); - } - - private ComponentModel getWorkflowComponent(String id) { - ComponentModel component = getRealm().getComponent(id); - - if (component == null || !WorkflowProvider.class.getName().equals(component.getProviderType())) { - throw new BadRequestException("Not a valid resource workflow: " + id); - } - - return component; - } - - public WorkflowRepresentation toRepresentation(Workflow workflow) { - List conditions = toConditionRepresentation(workflow); - List steps = toRepresentation(getSteps(workflow.getId())); - - return new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig(), conditions, steps); - } - - private List toConditionRepresentation(Workflow workflow) { - MultivaluedHashMap workflowConfig = ofNullable(workflow.getConfig()).orElse(new MultivaluedHashMap<>()); - List ids = workflowConfig.getOrDefault(CONFIG_CONDITIONS, List.of()); - - if (ids.isEmpty()) { - return null; - } - - List conditions = new ArrayList<>(); - - for (String id : ids) { - MultivaluedHashMap config = new MultivaluedHashMap<>(); - - for (Entry> configEntry : workflowConfig.entrySet()) { - String key = configEntry.getKey(); - if (key.startsWith(id + ".")) { - config.put(key.substring(id.length() + 1), configEntry.getValue()); - } - } - - conditions.add(new WorkflowConditionRepresentation(id, config)); - } - - return conditions; - } - - private List toRepresentation(List existingSteps) { - if (existingSteps == null || existingSteps.isEmpty()) { - return null; - } - - List steps = new ArrayList<>(); - - for (WorkflowStep step : existingSteps) { - steps.add(toRepresentation(step)); - } - - return steps; - } - - public WorkflowStepRepresentation toRepresentation(WorkflowStep step) { - return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig()); - } - - public Workflow toModel(WorkflowRepresentation rep) { - validateWorkflow(rep); - - MultivaluedHashMap config = ofNullable(rep.getConfig()).orElse(new MultivaluedHashMap<>()); - List conditions = ofNullable(rep.getConditions()).orElse(List.of()); - - for (WorkflowConditionRepresentation condition : conditions) { - String conditionProviderId = condition.getUses(); - getConditionProviderFactory(conditionProviderId); - config.computeIfAbsent(CONFIG_CONDITIONS, key -> new ArrayList<>()).add(conditionProviderId); - - for (Entry> configEntry : condition.getConfig().entrySet()) { - config.put(conditionProviderId + "." + configEntry.getKey(), configEntry.getValue()); - } - } - - Workflow workflow = addWorkflow(rep.getUses(), config); - - List steps = rep.getSteps().stream().map(this::toModel).toList(); - - addSteps(workflow, steps); - - return workflow; - } - - private void validateWorkflow(WorkflowRepresentation rep) { - validateEvents(rep.getOnValues()); - validateEvents(rep.getOnEventsReset()); - // a recurring workflow must have at least one scheduled step to prevent an infinite loop of immediate executions - if (rep.getConfig() != null && Boolean.parseBoolean(rep.getConfig().getFirstOrDefault(CONFIG_RECURRING, "false"))) { - boolean hasScheduledStep = ofNullable(rep.getSteps()).orElse(List.of()).stream() - .anyMatch(step -> Integer.parseInt(ofNullable(step.getAfter()).orElse("0")) > 0); - if (!hasScheduledStep) { - throw new WorkflowInvalidStateException("A recurring workflow must have at least one step with a time delay."); - } - } - } - - private static void validateEvents(List events) { - for (String event : ofNullable(events).orElse(List.of())) { - try { - ResourceOperationType.valueOf(event.toUpperCase()); - } catch (IllegalArgumentException e) { - throw new WorkflowInvalidStateException("Invalid event type: " + event); - } - } - } - public void bind(Workflow workflow, ResourceType type, String resourceId) { processEvent(List.of(workflow), new AdhocWorkflowEvent(type, resourceId)); } - public Object resolveResource(ResourceType type, String resourceId) { - Objects.requireNonNull(type, "type"); - Objects.requireNonNull(type, "resourceId"); - return type.resolveResource(session, resourceId); - } - - private void validateStep(WorkflowStep step) throws ModelValidationException { - if (step.getAfter() < 0) { - throw new ModelValidationException("Step 'after' time condition cannot be negative."); + public void bindToAllEligibleResources(Workflow workflow) { + if (workflow.isEnabled()) { + WorkflowProvider provider = getWorkflowProvider(workflow); + provider.getEligibleResourcesForInitialStep() + .forEach(resourceId -> processEvent(List.of(workflow), new AdhocWorkflowEvent(ResourceType.USERS, resourceId))); } - // verify the step does have valid provider - getStepProviderFactory(step); - } - - public WorkflowStep addStepToWorkflow(Workflow workflow, WorkflowStep step, Integer position) { - Objects.requireNonNull(workflow, "workflow cannot be null"); - Objects.requireNonNull(step, "step cannot be null"); - - List existingSteps = getSteps(workflow.getId()); - - int targetPosition = position != null ? position : existingSteps.size(); - if (targetPosition < 0 || targetPosition > existingSteps.size()) { - throw new BadRequestException("Invalid position: " + targetPosition + ". Must be between 0 and " + existingSteps.size()); - } - - // First, shift existing steps at and after the target position to make room - shiftStepsForInsertion(targetPosition, existingSteps); - - step.setPriority(targetPosition + 1); - WorkflowStep addedStep = addStep(workflow, step); - - log.debugf("Added step %s to workflow %s at position %d", addedStep.getId(), workflow.getId(), targetPosition); - return addedStep; - } - - public void removeStepFromWorkflow(Workflow workflow, String stepId) { - Objects.requireNonNull(workflow, "workflow cannot be null"); - Objects.requireNonNull(stepId, "stepId cannot be null"); - - RealmModel realm = getRealm(); - ComponentModel stepComponent = realm.getComponent(stepId); - - if (stepComponent == null || !stepComponent.getParentId().equals(workflow.getId())) { - throw new BadRequestException("Step not found or not part of workflow: " + stepId); - } - - realm.removeComponent(stepComponent); - - // Reorder remaining steps and update state - reorderAllSteps(workflow.getId()); - updateScheduledStepsAfterStepChange(workflow, stepId); - - log.debugf("Removed step %s from workflow %s", stepId, workflow.getId()); - } - - private void shiftStepsForInsertion(int insertPosition, List existingSteps) { - RealmModel realm = getRealm(); - - // Shift all steps at and after the insertion position by +1 priority - for (int i = insertPosition; i < existingSteps.size(); i++) { - WorkflowStep step = existingSteps.get(i); - step.setPriority(step.getPriority() + 1); - updateStepComponent(realm, step); - } - } - - private void reorderAllSteps(String workflowId) { - List steps = getSteps(workflowId); - RealmModel realm = getRealm(); - - for (int i = 0; i < steps.size(); i++) { - WorkflowStep step = steps.get(i); - step.setPriority(i + 1); - updateStepComponent(realm, step); - } - } - - private void updateStepComponent(RealmModel realm, WorkflowStep step) { - ComponentModel component = realm.getComponent(step.getId()); - component.setConfig(step.getConfig()); - realm.updateComponent(component); - } - - private void updateScheduledStepsAfterStepChange(Workflow workflow, String stepId) { - - for (ScheduledStep scheduled : workflowStateProvider.getScheduledStepsByStep(stepId)) { - WorkflowExecutionContext context = buildFromScheduledStep(scheduled); - context.restart(); - workflowStateProvider.scheduleStep(workflow, context.getNextStep(), scheduled.resourceId(), context.getExecutionId()); - } - } - - public WorkflowStep toModel(WorkflowStepRepresentation rep) { - WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig()); - validateStep(step); - return step; - } - - public WorkflowConditionProvider getConditionProvider(String providerId, MultivaluedHashMap modelConfig) { - WorkflowConditionProviderFactory providerFactory = getConditionProviderFactory(providerId); - Map> config = new HashMap<>(); - - for (Entry> configEntry : modelConfig.entrySet()) { - if (configEntry.getKey().startsWith(providerId)) { - config.put(configEntry.getKey().substring(providerId.length() + 1), configEntry.getValue()); - } - } - - WorkflowConditionProvider condition = providerFactory.create(session, config); - - if (condition == null) { - throw new IllegalStateException("Factory " + providerFactory.getClass() + " returned a null provider"); - } - - return condition; - } - - public WorkflowConditionProviderFactory getConditionProviderFactory(String providerId) { - KeycloakSessionFactory sessionFactory = session.getKeycloakSessionFactory(); - WorkflowConditionProviderFactory providerFactory = (WorkflowConditionProviderFactory) sessionFactory.getProviderFactory(WorkflowConditionProvider.class, providerId); - - if (providerFactory == null) { - throw new WorkflowInvalidStateException("Could not find condition provider: " + providerId); - } - - return providerFactory; - } - - private WorkflowExecutionContext buildAndInitContext(Workflow workflow, String resourceId) { - WorkflowExecutionContext context = new WorkflowExecutionContext(workflow, getSteps(workflow.getId()), resourceId); - context.init(); - return context; - } - - private WorkflowExecutionContext buildFromScheduledStep(ScheduledStep scheduledStep) { - return new WorkflowExecutionContext( - getWorkflow(scheduledStep.workflowId()), - getSteps(scheduledStep.workflowId()), - scheduledStep.resourceId(), - scheduledStep.stepId(), - scheduledStep.executionId() - ); } private void processWorkflow(Workflow workflow, WorkflowExecutionContext context, String resourceId) { @@ -578,7 +328,6 @@ public class WorkflowsManager { return; } else { // Otherwise run the step right away - runWorkflowStep(context, step, resourceId); } } @@ -607,4 +356,144 @@ public class WorkflowsManager { } }); } + + /* ======================= Workflows representation <-> model conversions and validations ======================== */ + + public WorkflowRepresentation toRepresentation(Workflow workflow) { + List conditions = toConditionRepresentation(workflow); + List steps = toRepresentation(getSteps(workflow.getId())); + + return new WorkflowRepresentation(workflow.getId(), workflow.getProviderId(), workflow.getConfig(), conditions, steps); + } + + private List toConditionRepresentation(Workflow workflow) { + MultivaluedHashMap workflowConfig = ofNullable(workflow.getConfig()).orElse(new MultivaluedHashMap<>()); + List ids = workflowConfig.getOrDefault(CONFIG_CONDITIONS, List.of()); + + if (ids.isEmpty()) { + return null; + } + + List conditions = new ArrayList<>(); + + for (String id : ids) { + MultivaluedHashMap config = new MultivaluedHashMap<>(); + + for (Entry> configEntry : workflowConfig.entrySet()) { + String key = configEntry.getKey(); + if (key.startsWith(id + ".")) { + config.put(key.substring(id.length() + 1), configEntry.getValue()); + } + } + conditions.add(new WorkflowConditionRepresentation(id, config)); + } + + return conditions; + } + + private List toRepresentation(List existingSteps) { + if (existingSteps == null || existingSteps.isEmpty()) { + return null; + } + + List steps = new ArrayList<>(); + + for (WorkflowStep step : existingSteps) { + steps.add(new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig())); + } + + return steps; + } + + public Workflow toModel(WorkflowRepresentation rep) { + validateWorkflow(rep); + + MultivaluedHashMap config = ofNullable(rep.getConfig()).orElse(new MultivaluedHashMap<>()); + List conditions = ofNullable(rep.getConditions()).orElse(List.of()); + + for (WorkflowConditionRepresentation condition : conditions) { + String conditionProviderId = condition.getUses(); + getConditionProviderFactory(conditionProviderId); + config.computeIfAbsent(CONFIG_CONDITIONS, key -> new ArrayList<>()).add(conditionProviderId); + + for (Entry> configEntry : condition.getConfig().entrySet()) { + config.put(conditionProviderId + "." + configEntry.getKey(), configEntry.getValue()); + } + } + + Workflow workflow = addWorkflow(new Workflow(rep.getUses(), config)); + + List steps = rep.getSteps().stream().map(this::toModel).toList(); + + addSteps(workflow, steps); + + return workflow; + } + + public WorkflowStep toModel(WorkflowStepRepresentation rep) { + WorkflowStep step = new WorkflowStep(rep.getUses(), rep.getConfig()); + validateStep(step); + return step; + } + + private void validateWorkflow(WorkflowRepresentation rep) { + validateEvents(rep.getOnValues()); + validateEvents(rep.getOnEventsReset()); + // a recurring workflow must have at least one scheduled step to prevent an infinite loop of immediate executions + if (rep.getConfig() != null && Boolean.parseBoolean(rep.getConfig().getFirstOrDefault(CONFIG_RECURRING, "false"))) { + boolean hasScheduledStep = ofNullable(rep.getSteps()).orElse(List.of()).stream() + .anyMatch(step -> Integer.parseInt(ofNullable(step.getAfter()).orElse("0")) > 0); + if (!hasScheduledStep) { + throw new WorkflowInvalidStateException("A recurring workflow must have at least one step with a time delay."); + } + } + } + + private static void validateEvents(List events) { + for (String event : ofNullable(events).orElse(List.of())) { + try { + ResourceOperationType.valueOf(event.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new WorkflowInvalidStateException("Invalid event type: " + event); + } + } + } + + private void validateStep(WorkflowStep step) throws ModelValidationException { + if (step.getAfter() < 0) { + throw new ModelValidationException("Step 'after' time condition cannot be negative."); + } + // verify the step does have valid provider + getStepProviderFactory(step); + } + + /* ================================== Workflow execution context helper methods ================================== */ + + private WorkflowExecutionContext buildAndInitContext(Workflow workflow, String resourceId) { + WorkflowExecutionContext context = new WorkflowExecutionContext(workflow, getSteps(workflow.getId()), resourceId); + context.init(); + return context; + } + + private WorkflowExecutionContext buildFromScheduledStep(ScheduledStep scheduledStep) { + return new WorkflowExecutionContext( + getWorkflow(scheduledStep.workflowId()), + getSteps(scheduledStep.workflowId()), + scheduledStep.resourceId(), + scheduledStep.stepId(), + scheduledStep.executionId() + ); + } + + /* ============================================ Other utility methods ============================================ */ + + public Object resolveResource(ResourceType type, String resourceId) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(type, "resourceId"); + return type.resolveResource(session, resourceId); + } + + private RealmModel getRealm() { + return session.getContext().getRealm(); + } } diff --git a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java index 39d2732f8c0..7db48c8c93a 100644 --- a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java +++ b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowResource.java @@ -82,8 +82,4 @@ public class WorkflowResource { manager.bind(workflow, type, resourceId); } - @Path("steps") - public WorkflowStepsResource steps() { - return new WorkflowStepsResource(manager, workflow); - } } diff --git a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java deleted file mode 100644 index 3d19f205b25..00000000000 --- a/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java +++ /dev/null @@ -1,179 +0,0 @@ -/* - * Copyright 2025 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.keycloak.workflow.admin.resource; - -import java.util.List; - -import jakarta.ws.rs.BadRequestException; -import jakarta.ws.rs.Consumes; -import jakarta.ws.rs.DELETE; -import jakarta.ws.rs.GET; -import jakarta.ws.rs.POST; -import jakarta.ws.rs.Path; -import jakarta.ws.rs.PathParam; -import jakarta.ws.rs.Produces; -import jakarta.ws.rs.QueryParam; -import jakarta.ws.rs.core.MediaType; -import jakarta.ws.rs.core.Response; - -import org.eclipse.microprofile.openapi.annotations.Operation; -import org.eclipse.microprofile.openapi.annotations.enums.SchemaType; -import org.eclipse.microprofile.openapi.annotations.media.Content; -import org.eclipse.microprofile.openapi.annotations.media.Schema; -import org.eclipse.microprofile.openapi.annotations.parameters.Parameter; -import org.eclipse.microprofile.openapi.annotations.parameters.RequestBody; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; -import org.eclipse.microprofile.openapi.annotations.responses.APIResponses; -import org.eclipse.microprofile.openapi.annotations.tags.Tag; - -import org.keycloak.models.ModelException; -import org.keycloak.models.workflow.WorkflowStep; -import org.keycloak.models.workflow.Workflow; -import org.keycloak.models.workflow.WorkflowsManager; -import org.keycloak.representations.workflows.WorkflowStepRepresentation; -import org.keycloak.services.ErrorResponse; - -/** - * Resource for managing steps within a workflow. - * - */ -@Tag(name = "Workflow Steps", description = "Manage steps within workflows") -public class WorkflowStepsResource { - - private final WorkflowsManager workflowsManager; - private final Workflow workflow; - - public WorkflowStepsResource(WorkflowsManager workflowsManager, Workflow workflow) { - this.workflowsManager = workflowsManager; - this.workflow = workflow; - } - - /** - * Get all steps for this workflow. - * - * @return list of steps - */ - @GET - @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Get all steps for this workflow") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Success", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(type = SchemaType.ARRAY, - implementation = WorkflowStepRepresentation.class))) - }) - public List getSteps() { - return workflowsManager.getSteps(workflow.getId()).stream() - .map(workflowsManager::toRepresentation) - .toList(); - } - - /** - * Add a new step to this workflow. - * - * @param stepRep step representation - * @param position optional position to insert the step at (0-based index) - * @return the created step - */ - @POST - @Consumes(MediaType.APPLICATION_JSON) - @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Add a new step to this workflow") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Step created successfully", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = WorkflowStepRepresentation.class))), - @APIResponse(responseCode = "400", description = "Invalid step representation or position") - }) - public Response addStep( - @RequestBody(description = "Step to add", required = true, - content = @Content(schema = @Schema(implementation = WorkflowStepRepresentation.class))) - WorkflowStepRepresentation stepRep, - @Parameter(description = "Position to insert the step at (0-based index). If not specified, step is added at the end.") - @QueryParam("position") Integer position) { - if (stepRep == null) { - throw ErrorResponse.error("Step representation cannot be null", Response.Status.BAD_REQUEST); - } - try { - WorkflowStep step = workflowsManager.toModel(stepRep); - WorkflowStep addedStep = workflowsManager.addStepToWorkflow(workflow, step, position); - - return Response.ok(workflowsManager.toRepresentation(addedStep)).build(); - } catch (ModelException e) { - throw ErrorResponse.error(e.getMessage(), Response.Status.BAD_REQUEST); - } - } - - /** - * Remove a step from this workflow. - * - * @param stepId ID of the step to remove - * @return no content response on success - */ - @Path("{stepId}") - @DELETE - @Operation(summary = "Remove a step from this workflow") - @APIResponses({ - @APIResponse(responseCode = "204", description = "Step removed successfully"), - @APIResponse(responseCode = "400", description = "Invalid step ID"), - @APIResponse(responseCode = "404", description = "Step not found") - }) - public Response removeStep( - @Parameter(description = "ID of the step to remove", required = true) - @PathParam("stepId") String stepId) { - if (stepId == null || stepId.trim().isEmpty()) { - throw new BadRequestException("Step ID cannot be null or empty"); - } - - workflowsManager.removeStepFromWorkflow(workflow, stepId); - return Response.noContent().build(); - } - - /** - * Get a specific step by its ID. - * - * @param stepId ID of the step to retrieve - * @return the step representation - */ - @Path("{stepId}") - @GET - @Produces(MediaType.APPLICATION_JSON) - @Operation(summary = "Get a specific step by its ID") - @APIResponses({ - @APIResponse(responseCode = "200", description = "Step found", - content = @Content(mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = WorkflowStepRepresentation.class))), - @APIResponse(responseCode = "400", description = "Invalid step ID"), - @APIResponse(responseCode = "404", description = "Step not found") - }) - public WorkflowStepRepresentation getStep( - @Parameter(description = "ID of the step to retrieve", required = true) - @PathParam("stepId") String stepId) { - if (stepId == null || stepId.trim().isEmpty()) { - throw new BadRequestException("Step ID cannot be null or empty"); - } - - WorkflowStep step = workflowsManager.getStepById(stepId); - - if (step == null) { - throw new BadRequestException("Step not found: " + stepId); - } - - return workflowsManager.toRepresentation(step); - } -} diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java index 5288d4fff4b..09bdc17bb71 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/UserSessionRefreshTimeWorkflowTest.java @@ -29,7 +29,6 @@ import java.time.Duration; import java.util.List; import jakarta.mail.internet.MimeMessage; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.keycloak.common.util.Time; import org.keycloak.models.KeycloakSession; @@ -73,7 +72,7 @@ public class UserSessionRefreshTimeWorkflowTest { @InjectUser(ref = "alice", config = DefaultUserConfig.class, lifecycle = LifeCycle.METHOD) private ManagedUser userAlice; - @InjectRealm + @InjectRealm(lifecycle = LifeCycle.METHOD) ManagedRealm managedRealm; @InjectWebDriver @@ -88,16 +87,6 @@ public class UserSessionRefreshTimeWorkflowTest { @InjectMailServer private MailServer mailServer; - @BeforeEach - public void onBefore() { - oauth.realm("default"); - - runOnServer.run(session -> { - WorkflowsManager manager = new WorkflowsManager(session); - manager.removeWorkflows(); - }); - } - @Test public void testDisabledUserAfterInactivityPeriod() { managedRealm.admin().workflows().create(WorkflowRepresentation.create() diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java index c379246d123..455101f0257 100644 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowManagementTest.java @@ -413,7 +413,7 @@ public class WorkflowManagementTest { }); // assign the workflow to the eligible users - i.e. only users from the same idp who are not yet assigned to the workflow. - workflowsManager.scheduleAllEligibleResources(workflow); + workflowsManager.bindToAllEligibleResources(workflow); // check workflow was correctly assigned to the old users, not affecting users already associated with the workflow. scheduledSteps = stateProvider.getScheduledStepsByWorkflow(workflow); diff --git a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java deleted file mode 100644 index 23a7cd00ef7..00000000000 --- a/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java +++ /dev/null @@ -1,298 +0,0 @@ -/* - * Copyright 2025 Red Hat, Inc. and/or its affiliates - * and other contributors as indicated by the @author tags. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.keycloak.tests.admin.model.workflow; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeEach; -import org.keycloak.admin.client.resource.WorkflowResource; -import org.keycloak.admin.client.resource.WorkflowStepsResource; -import org.keycloak.admin.client.resource.WorkflowsResource; -import org.keycloak.models.KeycloakSession; -import org.keycloak.models.RealmModel; -import org.keycloak.models.workflow.DisableUserStepProviderFactory; -import org.keycloak.models.workflow.NotifyUserStepProviderFactory; -import org.keycloak.models.workflow.UserCreationTimeWorkflowProviderFactory; -import org.keycloak.models.workflow.WorkflowStep; -import org.keycloak.models.workflow.Workflow; -import org.keycloak.models.workflow.WorkflowsManager; -import org.keycloak.models.workflow.WorkflowStateProvider; -import org.keycloak.models.workflow.ResourceType; -import org.keycloak.models.workflow.ResourceOperationType; -import org.keycloak.representations.workflows.WorkflowRepresentation; -import org.keycloak.representations.workflows.WorkflowSetRepresentation; -import org.keycloak.representations.workflows.WorkflowStepRepresentation; -import org.keycloak.testframework.annotations.InjectRealm; -import org.keycloak.testframework.annotations.KeycloakIntegrationTest; -import org.keycloak.testframework.injection.LifeCycle; -import org.keycloak.testframework.realm.ManagedRealm; -import org.keycloak.testframework.remote.runonserver.InjectRunOnServer; -import org.keycloak.testframework.remote.runonserver.RunOnServerClient; - -import jakarta.ws.rs.core.Response; - -import java.time.Duration; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -@KeycloakIntegrationTest(config = WorkflowsServerConfig.class) -public class WorkflowStepManagementTest { - - @InjectRealm(lifecycle = LifeCycle.METHOD) - ManagedRealm managedRealm; - - @InjectRunOnServer(permittedPackages = "org.keycloak.tests") - RunOnServerClient runOnServer; - - private WorkflowsResource workflowsResource; - private String workflowId; - - @BeforeEach - public void setup() { - workflowsResource = managedRealm.admin().workflows(); - - // Create a workflow for testing (need at least one step for persistence) - WorkflowSetRepresentation workflows = WorkflowRepresentation.create() - .of(UserCreationTimeWorkflowProviderFactory.ID) - .onEvent(ResourceOperationType.USER_ADD.toString()) - .name("Test Workflow") - .withSteps( - WorkflowStepRepresentation.create().of(NotifyUserStepProviderFactory.ID) - .after(Duration.ofDays(1)) - .build() - ) - .build(); - - try (Response response = workflowsResource.create(workflows)) { - if (response.getStatus() != 201) { - String responseBody = response.readEntity(String.class); - System.err.println("Workflow creation failed with status: " + response.getStatus()); - System.err.println("Response body: " + responseBody); - } - assertEquals(201, response.getStatus()); - - // Since we created a list of workflows, get the first one from the list - List createdWorkflows = workflowsResource.list(); - assertNotNull(createdWorkflows); - assertEquals(1, createdWorkflows.size()); - workflowId = createdWorkflows.get(0).getId(); - } - } - - @Test - public void testAddStepToWorkflow() { - WorkflowResource workflow = workflowsResource.workflow(workflowId); - WorkflowStepsResource steps = workflow.steps(); - - WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation(); - stepRep.setUses(DisableUserStepProviderFactory.ID); - stepRep.setConfig("name", "Test Step"); - stepRep.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis())); - - try (Response response = steps.create(stepRep)) { - assertEquals(200, response.getStatus()); - WorkflowStepRepresentation addedStep = response.readEntity(WorkflowStepRepresentation.class); - - assertNotNull(addedStep); - assertNotNull(addedStep.getId()); - assertEquals(DisableUserStepProviderFactory.ID, addedStep.getUses()); - } - - // Verify step is in workflow (should be 2 total: setup step + our added step) - List allSteps = steps.list(); - assertEquals(2, allSteps.size()); - - // Verify our added step is present - boolean foundOurStep = allSteps.stream() - .anyMatch(step -> DisableUserStepProviderFactory.ID.equals(step.getUses()) && - "Test Step".equals(step.getConfig().getFirst("name"))); - assertTrue(foundOurStep, "Our added step should be present in the workflow"); - } - - @Test - public void testRemoveStepFromWorkflow() { - WorkflowResource workflow = workflowsResource.workflow(workflowId); - WorkflowStepsResource steps = workflow.steps(); - - // Add one more step - WorkflowStepRepresentation step1 = new WorkflowStepRepresentation(); - step1.setUses(DisableUserStepProviderFactory.ID); - step1.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis())); - - String step1Id; - try (Response response = steps.create(step1)) { - assertEquals(200, response.getStatus()); - step1Id = response.readEntity(WorkflowStepRepresentation.class).getId(); - } - - // Verify both steps exist - List allSteps = steps.list(); - assertEquals(2, allSteps.size()); - - // Remove the step we added - try (Response response = steps.delete(step1Id)) { - assertEquals(204, response.getStatus()); - } - - // Verify only the original setup step remains - allSteps = steps.list(); - assertEquals(1, allSteps.size()); - } - - @Test - public void testAddStepAtSpecificPosition() { - WorkflowResource workflow = workflowsResource.workflow(workflowId); - WorkflowStepsResource steps = workflow.steps(); - - // Add first step at position 0 - WorkflowStepRepresentation step1 = new WorkflowStepRepresentation(); - step1.setUses(NotifyUserStepProviderFactory.ID); - step1.setConfig("name", "Step 1"); - step1.setConfig("after", String.valueOf(Duration.ofDays(30).toMillis())); - - String step1Id; - try (Response response = steps.create(step1, 0)) { - assertEquals(200, response.getStatus()); - step1Id = response.readEntity(WorkflowStepRepresentation.class).getId(); - } - - // Verify step1 is at position 0 - List allSteps = steps.list(); - assertEquals(step1Id, allSteps.get(0).getId()); - - // Add second step at position 1 - WorkflowStepRepresentation step2 = new WorkflowStepRepresentation(); - step2.setUses(DisableUserStepProviderFactory.ID); - step2.setConfig("name", "Step 2"); - step2.setConfig("after", String.valueOf(Duration.ofDays(60).toMillis())); - - String step2Id; - try (Response response = steps.create(step2, 1)) { - assertEquals(200, response.getStatus()); - step2Id = response.readEntity(WorkflowStepRepresentation.class).getId(); - } - - // Verify step2 is at position 1 - allSteps = steps.list(); - assertEquals(step2Id, allSteps.get(1).getId()); - - // Add third step at position 1 (middle) - WorkflowStepRepresentation step3 = new WorkflowStepRepresentation(); - step3.setUses(NotifyUserStepProviderFactory.ID); - step3.setConfig("name", "Step 3"); - step3.setConfig("after", String.valueOf(Duration.ofDays(45).toMillis())); // Between 30 and 60 days - - String step3Id; - try (Response response = steps.create(step3, 1)) { - assertEquals(200, response.getStatus()); - step3Id = response.readEntity(WorkflowStepRepresentation.class).getId(); - } - - // Verify step3 is at position 1 (inserted between step1 and step2) - allSteps = steps.list(); - assertEquals(step1Id, allSteps.get(0).getId()); - assertEquals(step3Id, allSteps.get(1).getId()); - assertEquals(step2Id, allSteps.get(2).getId()); - } - - @Test - public void testGetSpecificStep() { - WorkflowResource workflow = workflowsResource.workflow(workflowId); - WorkflowStepsResource steps = workflow.steps(); - - WorkflowStepRepresentation stepRep = new WorkflowStepRepresentation(); - stepRep.setUses(NotifyUserStepProviderFactory.ID); - stepRep.setConfig("name", "Test Step"); - stepRep.setConfig("after", String.valueOf(Duration.ofDays(15).toMillis())); - - String stepId; - try (Response response = steps.create(stepRep)) { - assertEquals(200, response.getStatus()); - stepId = response.readEntity(WorkflowStepRepresentation.class).getId(); - } - - // Get the specific step - WorkflowStepRepresentation retrievedStep = steps.get(stepId); - assertNotNull(retrievedStep); - assertEquals(stepId, retrievedStep.getId()); - assertEquals(NotifyUserStepProviderFactory.ID, retrievedStep.getUses()); - assertEquals("Test Step", retrievedStep.getConfig().getFirst("name")); - } - - @Test - public void testScheduledStepTableUpdatesAfterStepManagement() { - runOnServer.run(session -> { - configureSessionContext(session); - WorkflowsManager manager = new WorkflowsManager(session); - - Workflow workflow = manager.addWorkflow(UserCreationTimeWorkflowProviderFactory.ID, Map.of()); - - WorkflowStep step1 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null); - step1.setAfter(Duration.ofDays(30).toMillis()); - WorkflowStep step2 = new WorkflowStep(DisableUserStepProviderFactory.ID, null); - step2.setAfter(Duration.ofDays(60).toMillis()); - - WorkflowStep addedStep1 = manager.addStepToWorkflow(workflow, step1, null); - WorkflowStep addedStep2 = manager.addStepToWorkflow(workflow, step2, null); - - // Simulate scheduled steps by binding workflow to a test resource - String testResourceId = "test-user-123"; - manager.bind(workflow, ResourceType.USERS, testResourceId); - - // Get scheduled steps for the workflow - WorkflowStateProvider stateProvider = session.getKeycloakSessionFactory().getProviderFactory(WorkflowStateProvider.class).create(session); - - var scheduledStepsBeforeRemoval = stateProvider.getScheduledStepsByWorkflow(workflow.getId()); - assertNotNull(scheduledStepsBeforeRemoval); - - // Remove the first step - manager.removeStepFromWorkflow(workflow, addedStep1.getId()); - - // Verify scheduled steps are updated - var scheduledStepsAfterRemoval = stateProvider.getScheduledStepsByWorkflow(workflow.getId()); - assertNotNull(scheduledStepsAfterRemoval); - - // Verify remaining steps are still properly ordered - List remainingSteps = manager.getSteps(workflow.getId()); - assertEquals(1, remainingSteps.size()); - assertEquals(addedStep2.getId(), remainingSteps.get(0).getId()); - assertEquals(1, remainingSteps.get(0).getPriority()); // Should be reordered to priority 1 - - // Add a new step and verify scheduled steps are updated - WorkflowStep step3 = new WorkflowStep(NotifyUserStepProviderFactory.ID, null); - step3.setAfter(Duration.ofDays(15).toMillis()); - manager.addStepToWorkflow(workflow, step3, 0); // Insert at beginning - - // Verify final state - List finalSteps = manager.getSteps(workflow.getId()); - assertEquals(2, finalSteps.size()); - assertEquals(step3.getProviderId(), finalSteps.get(0).getProviderId()); - assertEquals(1, finalSteps.get(0).getPriority()); - assertEquals(addedStep2.getId(), finalSteps.get(1).getId()); - assertEquals(2, finalSteps.get(1).getPriority()); - }); - } - - private static void configureSessionContext(KeycloakSession session) { - RealmModel realm = session.realms().getRealmByName("default"); - session.getContext().setRealm(realm); - } -}