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 14420b704bd..b4557772e4b 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 @@ -36,4 +36,7 @@ public interface WorkflowResource { @POST @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 new file mode 100644 index 00000000000..d56f5e4e6af --- /dev/null +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/WorkflowStepsResource.java @@ -0,0 +1,41 @@ +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 ba4a27ccd62..a251d1b91d4 100644 --- a/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java +++ b/services/src/main/java/org/keycloak/models/workflow/WorkflowsManager.java @@ -433,4 +433,111 @@ public class WorkflowsManager { } } } + + public WorkflowStep addStepToWorkflow(String workflowId, WorkflowStep step, Integer position) { + Objects.requireNonNull(workflowId, "workflowId cannot be null"); + Objects.requireNonNull(step, "step cannot be null"); + + List existingSteps = getSteps(workflowId); + + 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(workflowId, step); + + updateScheduledStepsAfterStepChange(workflowId); + + log.debugf("Added step %s to workflow %s at position %d", addedStep.getId(), workflowId, targetPosition); + return addedStep; + } + + public void removeStepFromWorkflow(String workflowId, String stepId) { + Objects.requireNonNull(workflowId, "workflowId cannot be null"); + Objects.requireNonNull(stepId, "stepId cannot be null"); + + RealmModel realm = getRealm(); + ComponentModel stepComponent = realm.getComponent(stepId); + + if (stepComponent == null || !stepComponent.getParentId().equals(workflowId)) { + throw new BadRequestException("Step not found or not part of workflow: " + stepId); + } + + realm.removeComponent(stepComponent); + + // Reorder remaining steps and update state + reorderAllSteps(workflowId); + updateScheduledStepsAfterStepChange(workflowId); + + log.debugf("Removed step %s from workflow %s", stepId, workflowId); + } + + 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(String workflowId) { + List steps = getSteps(workflowId); + + if (steps.isEmpty()) { + workflowStateProvider.remove(workflowId); + return; + } + + for (ScheduledStep scheduled : workflowStateProvider.getScheduledStepsByWorkflow(workflowId)) { + boolean stepStillExists = steps.stream() + .anyMatch(step -> step.getId().equals(scheduled.stepId())); + + if (!stepStillExists) { + Workflow workflow = getWorkflow(workflowId); + workflowStateProvider.scheduleStep(workflow, steps.get(0), scheduled.resourceId()); + } + } + } + + public WorkflowStepRepresentation toStepRepresentation(WorkflowStep step) { + List steps = step.getSteps().stream() + .map(this::toStepRepresentation) + .toList(); + return new WorkflowStepRepresentation(step.getId(), step.getProviderId(), step.getConfig(), steps); + } + + public WorkflowStep toStepModel(WorkflowStepRepresentation rep) { + List subSteps = new ArrayList<>(); + + for (WorkflowStepRepresentation subStep : ofNullable(rep.getSteps()).orElse(List.of())) { + subSteps.add(toStepModel(subStep)); + } + + return new WorkflowStep(rep.getProviderId(), rep.getConfig(), subSteps); + } } 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 25d4f4693ea..104f22f2de8 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,4 +82,9 @@ 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 new file mode 100644 index 00000000000..611318a62cc --- /dev/null +++ b/services/src/main/java/org/keycloak/workflow/admin/resource/WorkflowStepsResource.java @@ -0,0 +1,174 @@ +/* + * 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.workflow.WorkflowStep; +import org.keycloak.models.workflow.Workflow; +import org.keycloak.models.workflow.WorkflowsManager; +import org.keycloak.representations.workflows.WorkflowStepRepresentation; + +/** + * 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::toStepRepresentation) + .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 new BadRequestException("Step representation cannot be null"); + } + + WorkflowStep step = workflowsManager.toStepModel(stepRep); + WorkflowStep addedStep = workflowsManager.addStepToWorkflow(workflow.getId(), step, position); + + WorkflowStepRepresentation result = workflowsManager.toStepRepresentation(addedStep); + return Response.ok(result).build(); + } + + /** + * 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.getId(), 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.toStepRepresentation(step); + } +} 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 new file mode 100644 index 00000000000..37888cf1d7b --- /dev/null +++ b/tests/base/src/test/java/org/keycloak/tests/admin/model/workflow/WorkflowStepManagementTest.java @@ -0,0 +1,296 @@ +/* + * 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.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) + List workflows = WorkflowRepresentation.create() + .of(UserCreationTimeWorkflowProviderFactory.ID) + .onEvent(ResourceOperationType.CREATE.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.setProviderId(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.getProviderId()); + } + + // 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.getProviderId()) && + "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.setProviderId(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.setProviderId(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.setProviderId(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.setProviderId(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.setProviderId(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.getProviderId()); + 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, List.of()); + step1.setAfter(Duration.ofDays(30).toMillis()); + WorkflowStep step2 = new WorkflowStep(DisableUserStepProviderFactory.ID, null, List.of()); + step2.setAfter(Duration.ofDays(60).toMillis()); + + WorkflowStep addedStep1 = manager.addStepToWorkflow(workflow.getId(), step1, null); + WorkflowStep addedStep2 = manager.addStepToWorkflow(workflow.getId(), 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.getId(), 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, List.of()); + step3.setAfter(Duration.ofDays(15).toMillis()); + manager.addStepToWorkflow(workflow.getId(), 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); + } +}