From be709fd89c996100b5707a6eb23a6e252a14cb41 Mon Sep 17 00:00:00 2001 From: VIHANGAGIT Date: Wed, 17 Jun 2026 14:12:15 +0530 Subject: [PATCH 1/5] Add integration tests to password recovery flow enumeration controls --- .../execution/v1/FlowExecutionTestBase.java | 26 ++ .../v1/PasswordRecoveryFlowExecutionTest.java | 273 ++++++++++++++++++ .../server/flow/execution/v1/model/Data.java | 36 ++- .../flow/execution/v1/model/Message.java | 174 +++++++++++ .../management/v1/FlowManagementTestBase.java | 2 + .../PasswordRecoveryFlowManagementTest.java | 163 +++++++++++ .../execution/v1/password-recovery-flow.json | 248 ++++++++++++++++ .../management/v1/password-recovery-flow.json | 248 ++++++++++++++++ .../src/test/resources/testng.xml | 4 +- 9 files changed, 1171 insertions(+), 3 deletions(-) create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json create mode 100644 modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java index c42ba694cca..c6e20359486 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java @@ -42,6 +42,7 @@ public class FlowExecutionTestBase extends RESTAPIServerTestBase { protected static final String TYPE_VIEW = "VIEW"; protected static final String TYPE_REDIRECTION = "REDIRECTION"; protected static final String REGISTRATION_FLOW = "registration-flow.json"; + protected static final String PASSWORD_RECOVERY_FLOW = "password-recovery-flow.json"; protected static final String API_DEFINITION_NAME = "flow-execution.yaml"; protected static final String API_VERSION = "v1"; protected static final String API_PACKAGE_NAME = "org.wso2.carbon.identity.api.server.flow.execution.v1"; @@ -82,6 +83,30 @@ protected void addRegistrationFlow(FlowManagementClient client) throws Exception client.putFlow(flowRequest); } + protected void addPasswordRecoveryFlow(FlowManagementClient client) throws Exception { + + String passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); + FlowRequest flowRequest = new ObjectMapper() + .readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + client.putFlow(flowRequest); + } + + protected void enablePasswordRecoveryFlow(FlowManagementClient client) throws Exception { + + FlowConfig flowConfigDTO = new FlowConfig(); + flowConfigDTO.setIsEnabled(true); + flowConfigDTO.setFlowType(FlowTypes.PASSWORD_RECOVERY); + client.updateFlowConfig(flowConfigDTO); + } + + protected void disablePasswordRecoveryFlow(FlowManagementClient client) throws Exception { + + FlowConfig flowConfigDTO = new FlowConfig(); + flowConfigDTO.setIsEnabled(false); + flowConfigDTO.setFlowType(FlowTypes.PASSWORD_RECOVERY); + client.updateFlowConfig(flowConfigDTO); + } + protected void enableFlow(String flowType, FlowManagementClient client) throws Exception { FlowConfig flowConfigDTO = new FlowConfig(); @@ -105,5 +130,6 @@ protected void disableFlow(String flowType, FlowManagementClient client) throws protected static class FlowTypes { public static final String REGISTRATION = "REGISTRATION"; + public static final String PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java new file mode 100644 index 00000000000..26756d35ba4 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.execution.v1; + +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; +import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionResponse; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; +import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.ConnectorsPatchReq; +import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.PropertyReq; +import org.wso2.identity.integration.test.rest.api.user.common.model.Email; +import org.wso2.identity.integration.test.rest.api.user.common.model.Name; +import org.wso2.identity.integration.test.rest.api.user.common.model.PatchOperationRequestObject; +import org.wso2.identity.integration.test.rest.api.user.common.model.UserItemAddGroupobj; +import org.wso2.identity.integration.test.rest.api.user.common.model.UserObject; +import org.wso2.identity.integration.test.restclients.FlowExecutionClient; +import org.wso2.identity.integration.test.restclients.FlowManagementClient; +import org.wso2.identity.integration.test.restclients.IdentityGovernanceRestClient; +import org.wso2.identity.integration.test.restclients.SCIM2RestClient; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.FlowExecutionTestBase.FlowTypes.PASSWORD_RECOVERY; + +/** + * Test class for the password recovery flow execution when the {@code UserResolveExecutor} is configured + * with {@code notifyUserExistence} and {@code notifyUserAccountStatus} enabled. + * + *

With both flags enabled, the executor must surface user-facing error messages (instead of silently + * advancing the flow) when the submitted identifier does not resolve to a user, or resolves to a user + * whose account is locked or disabled.

+ */ +public class PasswordRecoveryFlowExecutionTest extends FlowExecutionTestBase { + + private static final String USERNAME_CLAIM = "http://wso2.org/claims/username"; + private static final String USER_RESOLVE_ACTION_ID = "button_1ov4"; + + private static final String USER_SYSTEM_SCHEMA_ATTRIBUTE = "urn:scim:wso2:schema"; + private static final String ACCOUNT_LOCKED_ATTRIBUTE = "accountLocked"; + private static final String ACCOUNT_DISABLED_ATTRIBUTE = "accountDisabled"; + + private static final String LOCKED_USER = "PwdRecLockedUser"; + private static final String LOCKED_USER_EMAIL = "pwd.rec.locked@example.com"; + private static final String DISABLED_USER = "PwdRecDisabledUser"; + private static final String DISABLED_USER_EMAIL = "pwd.rec.disabled@example.com"; + private static final String NON_EXISTENT_USER = "PwdRecNonExistentUser"; + private static final String TEST_USER_PASSWORD = "Wso2@Test123"; + + private static final String INVALID_IDENTIFIER_I18N_KEY = "invalid.identifier"; + private static final String ACCOUNT_LOCKED_I18N_KEY_PREFIX = "account.locked"; + private static final String ACCOUNT_DISABLED_I18N_KEY = "account.disabled"; + + private FlowExecutionClient flowExecutionClient; + private FlowManagementClient flowManagementClient; + private IdentityGovernanceRestClient identityGovernanceRestClient; + private SCIM2RestClient scim2RestClient; + + private String lockedUserId; + private String disabledUserId; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public PasswordRecoveryFlowExecutionTest(TestUserMode userMode) throws Exception { + + super.init(userMode); + this.context = isServer; + this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); + this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); + this.tenant = context.getContextTenant().getDomain(); + } + + @BeforeClass(alwaysRun = true) + public void setupClass() throws Exception { + + super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); + flowExecutionClient = new FlowExecutionClient(serverURL, tenantInfo); + flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); + identityGovernanceRestClient = new IdentityGovernanceRestClient(serverURL, tenantInfo); + scim2RestClient = new SCIM2RestClient(serverURL, tenantInfo); + + updatePasswordRecoveryFeatureStatus(true); + addPasswordRecoveryFlow(flowManagementClient); + enablePasswordRecoveryFlow(flowManagementClient); + + lockedUserId = createUser(LOCKED_USER, LOCKED_USER_EMAIL); + setAccountState(lockedUserId, ACCOUNT_LOCKED_ATTRIBUTE, true); + + disabledUserId = createUser(DISABLED_USER, DISABLED_USER_EMAIL); + setAccountState(disabledUserId, ACCOUNT_DISABLED_ATTRIBUTE, true); + } + + @AfterClass(alwaysRun = true) + public void tearDownClass() throws Exception { + + if (lockedUserId != null) { + scim2RestClient.deleteUser(lockedUserId); + } + if (disabledUserId != null) { + scim2RestClient.deleteUser(disabledUserId); + } + disablePasswordRecoveryFlow(flowManagementClient); + updatePasswordRecoveryFeatureStatus(false); + identityGovernanceRestClient.closeHttpClient(); + flowExecutionClient.closeHttpClient(); + flowManagementClient.closeHttpClient(); + scim2RestClient.closeHttpClient(); + } + + @Test(description = "Initiate the password recovery flow and verify the username collection step is rendered.") + public void testInitiatePasswordRecoveryFlow() throws Exception { + + Object responseObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); + Assert.assertTrue(responseObj instanceof FlowExecutionResponse, + "Expected FlowExecutionResponse on initiation but got: " + describe(responseObj)); + FlowExecutionResponse response = (FlowExecutionResponse) responseObj; + Assert.assertNotNull(response.getFlowId(), "Flow id should be returned on initiation."); + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + Assert.assertNotNull(response.getData()); + Assert.assertNotNull(response.getData().getComponents()); + } + + @Test(description = "Submit a non-existent identifier with notifyUserExistence enabled and expect an error.", + dependsOnMethods = "testInitiatePasswordRecoveryFlow") + public void testNonExistentIdentifierIsNotified() throws Exception { + + FlowExecutionResponse response = submitIdentifier(NON_EXISTENT_USER); + assertErrorMessage(response, INVALID_IDENTIFIER_I18N_KEY, + "non-existent identifier with notifyUserExistence enabled"); + } + + @Test(description = "Submit a locked user with notifyUserAccountStatus enabled and expect an error.", + dependsOnMethods = "testNonExistentIdentifierIsNotified") + public void testLockedAccountIsNotified() throws Exception { + + FlowExecutionResponse response = submitIdentifier(LOCKED_USER); + assertErrorMessage(response, ACCOUNT_LOCKED_I18N_KEY_PREFIX, + "locked account with notifyUserAccountStatus enabled"); + } + + @Test(description = "Submit a disabled user with notifyUserAccountStatus enabled and expect an error.", + dependsOnMethods = "testLockedAccountIsNotified") + public void testDisabledAccountIsNotified() throws Exception { + + FlowExecutionResponse response = submitIdentifier(DISABLED_USER); + assertErrorMessage(response, ACCOUNT_DISABLED_I18N_KEY, + "disabled account with notifyUserAccountStatus enabled"); + } + + /** + * Initiate a fresh password recovery flow and submit the given username at the user resolution step. + * + * @param username Username submitted to the {@code UserResolveExecutor}. + * @return The flow execution response of the user resolution step. + * @throws Exception If the flow could not be initiated or executed. + */ + private FlowExecutionResponse submitIdentifier(String username) throws Exception { + + Object initiationObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); + Assert.assertTrue(initiationObj instanceof FlowExecutionResponse, + "Expected FlowExecutionResponse on initiation but got: " + describe(initiationObj)); + String flowId = ((FlowExecutionResponse) initiationObj).getFlowId(); + + FlowExecutionRequest request = new FlowExecutionRequest(); + request.setFlowType(PASSWORD_RECOVERY); + request.setFlowId(flowId); + request.setActionId(USER_RESOLVE_ACTION_ID); + Map inputs = new HashMap<>(); + inputs.put(USERNAME_CLAIM, username); + request.setInputs(inputs); + + Object responseObj = flowExecutionClient.executeFlow(request); + Assert.assertTrue(responseObj instanceof FlowExecutionResponse, + "Expected FlowExecutionResponse on execution but got: " + describe(responseObj)); + return (FlowExecutionResponse) responseObj; + } + + /** + * Assert that the response re-prompts the same step and carries an ERROR message with the expected i18n key. + * + * @param response Flow execution response to validate. + * @param expectedI18nKeyPrefix Expected i18n key (matched as a prefix to tolerate reason-specific variants). + * @param scenario Human-readable scenario description used in assertion messages. + */ + private void assertErrorMessage(FlowExecutionResponse response, String expectedI18nKeyPrefix, String scenario) { + + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE, + "Expected INCOMPLETE status for " + scenario + "."); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW, + "Expected VIEW type for " + scenario + "."); + Assert.assertNotNull(response.getData(), "Expected response data for " + scenario + "."); + + List messages = response.getData().getMessages(); + Assert.assertNotNull(messages, "Expected messages in response data for " + scenario + "."); + Assert.assertFalse(messages.isEmpty(), "Expected at least one message for " + scenario + "."); + + boolean hasExpectedError = messages.stream().anyMatch(message -> + message.getType() == Message.TypeEnum.ERROR + && message.getI18nKey() != null + && message.getI18nKey().startsWith(expectedI18nKeyPrefix)); + Assert.assertTrue(hasExpectedError, + "Expected an ERROR message with i18n key starting with '" + expectedI18nKeyPrefix + + "' for " + scenario + " but received: " + messages); + } + + private String createUser(String username, String email) throws Exception { + + UserObject user = new UserObject() + .userName(username) + .password(TEST_USER_PASSWORD) + .name(new Name().givenName(username).familyName("User")) + .addEmail(new Email().value(email)); + return scim2RestClient.createUser(user); + } + + private void setAccountState(String userId, String attribute, boolean value) throws IOException { + + UserItemAddGroupobj patchOp = new UserItemAddGroupobj().op(UserItemAddGroupobj.OpEnum.REPLACE); + patchOp.setPath(USER_SYSTEM_SCHEMA_ATTRIBUTE + ":" + attribute); + patchOp.setValue(value); + scim2RestClient.updateUser(new PatchOperationRequestObject().addOperations(patchOp), userId); + } + + private void updatePasswordRecoveryFeatureStatus(boolean enable) throws IOException { + + ConnectorsPatchReq connectorsPatchReq = new ConnectorsPatchReq(); + connectorsPatchReq.setOperation(ConnectorsPatchReq.OperationEnum.UPDATE); + PropertyReq propertyReq = new PropertyReq(); + propertyReq.setName("Recovery.Notification.Password.emailLink.Enable"); + propertyReq.setValue(enable ? "true" : "false"); + connectorsPatchReq.addProperties(propertyReq); + identityGovernanceRestClient.updateConnectors("QWNjb3VudCBNYW5hZ2VtZW50", + "YWNjb3VudC1yZWNvdmVyeQ", connectorsPatchReq); + } + + private static String describe(Object responseObj) { + + return responseObj != null ? responseObj.toString() : "null"; + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java index c51c853d278..f60a4ade9a9 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Component; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; import javax.validation.constraints.*; @@ -46,6 +47,8 @@ public class Data { private Map additionalData = null; + private List messages = null; + /** **/ @@ -172,6 +175,33 @@ public Data putAdditionalDataItem(String key, Object additionalDataItem) { return this; } + /** + * Messages surfaced to the user during the flow step. + **/ + public Data messages(List messages) { + + this.messages = messages; + return this; + } + + @ApiModelProperty(value = "") + @JsonProperty("messages") + @Valid + public List getMessages() { + return messages; + } + public void setMessages(List messages) { + this.messages = messages; + } + + public Data addMessagesItem(Message messagesItem) { + if (this.messages == null) { + this.messages = new ArrayList(); + } + this.messages.add(messagesItem); + return this; + } + @Override @@ -188,12 +218,13 @@ public boolean equals(java.lang.Object o) { Objects.equals(this.redirectURL, data.redirectURL) && Objects.equals(this.requiredParams, data.requiredParams) && Objects.equals(this.webAuthnData, data.webAuthnData) && - Objects.equals(this.additionalData, data.additionalData); + Objects.equals(this.additionalData, data.additionalData) && + Objects.equals(this.messages, data.messages); } @Override public int hashCode() { - return Objects.hash(components, redirectURL, requiredParams, webAuthnData, additionalData); + return Objects.hash(components, redirectURL, requiredParams, webAuthnData, additionalData, messages); } @Override @@ -207,6 +238,7 @@ public String toString() { sb.append(" requiredParams: ").append(toIndentedString(requiredParams)).append("\n"); sb.append(" webAuthnData: ").append(toIndentedString(webAuthnData)).append("\n"); sb.append(" additionalData: ").append(toIndentedString(additionalData)).append("\n"); + sb.append(" messages: ").append(toIndentedString(messages)).append("\n"); sb.append("}"); return sb.toString(); } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java new file mode 100644 index 00000000000..56f0b9ffb79 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.annotations.ApiModelProperty; + +import java.util.Objects; + +import javax.validation.Valid; +import javax.xml.bind.annotation.XmlEnum; +import javax.xml.bind.annotation.XmlEnumValue; +import javax.xml.bind.annotation.XmlType; + +/** + * A message surfaced to the user during a flow step (e.g. validation or status messages). + **/ +public class Message { + + @XmlType(name="TypeEnum") + @XmlEnum(String.class) + public enum TypeEnum { + + @XmlEnumValue("ERROR") ERROR(String.valueOf("ERROR")), + @XmlEnumValue("WARNING") WARNING(String.valueOf("WARNING")), + @XmlEnumValue("INFO") INFO(String.valueOf("INFO")); + + + private String value; + + TypeEnum(String v) { + value = v; + } + + public String value() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + public static TypeEnum fromValue(String value) { + for (TypeEnum b : TypeEnum.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } + } + + private TypeEnum type; + private String message; + private String i18nKey; + + /** + **/ + public Message type(TypeEnum type) { + + this.type = type; + return this; + } + + @ApiModelProperty(example = "ERROR", value = "") + @JsonProperty("type") + @Valid + public TypeEnum getType() { + return type; + } + public void setType(TypeEnum type) { + this.type = type; + } + + /** + **/ + public Message message(String message) { + + this.message = message; + return this; + } + + @ApiModelProperty(example = "The provided identifier is invalid.", value = "") + @JsonProperty("message") + @Valid + public String getMessage() { + return message; + } + public void setMessage(String message) { + this.message = message; + } + + /** + **/ + public Message i18nKey(String i18nKey) { + + this.i18nKey = i18nKey; + return this; + } + + @ApiModelProperty(example = "invalid.identifier", value = "") + @JsonProperty("i18nKey") + @Valid + public String getI18nKey() { + return i18nKey; + } + public void setI18nKey(String i18nKey) { + this.i18nKey = i18nKey; + } + + + + @Override + public boolean equals(java.lang.Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Message message = (Message) o; + return Objects.equals(this.type, message.type) && + Objects.equals(this.message, message.message) && + Objects.equals(this.i18nKey, message.i18nKey); + } + + @Override + public int hashCode() { + return Objects.hash(type, message, i18nKey); + } + + @Override + public String toString() { + + StringBuilder sb = new StringBuilder(); + sb.append("class Message {\n"); + + sb.append(" type: ").append(toIndentedString(type)).append("\n"); + sb.append(" message: ").append(toIndentedString(message)).append("\n"); + sb.append(" i18nKey: ").append(toIndentedString(i18nKey)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(java.lang.Object o) { + + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n"); + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java index 5cbf0fb3dd1..f132ad12fd5 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java @@ -34,6 +34,7 @@ public class FlowManagementTestBase extends RESTAPIServerTestBase { protected static final String REGISTRATION_FLOW = "registration-flow.json"; + protected static final String PASSWORD_RECOVERY_FLOW = "password-recovery-flow.json"; private static final String API_DEFINITION_NAME = "flow.yaml"; protected static final String API_VERSION = "v1"; private static final String API_PACKAGE_NAME = "org.wso2.carbon.identity.api.server.flow.management.v1"; @@ -69,5 +70,6 @@ public void testFinish() { protected static class FlowTypes { public static final String REGISTRATION = "REGISTRATION"; + public static final String PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java new file mode 100644 index 00000000000..9cfcee9a156 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.management.v1; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; +import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Action; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Component; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Executor; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowResponse; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Step; +import org.wso2.identity.integration.test.restclients.FlowManagementClient; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.wso2.identity.integration.test.rest.api.server.flow.management.v1.FlowManagementTestBase.FlowTypes.PASSWORD_RECOVERY; + +/** + * Test class for the password recovery flow management API, focused on round-tripping the + * {@code UserResolveExecutor} metadata flags {@code notifyUserExistence} and {@code notifyUserAccountStatus}. + */ +public class PasswordRecoveryFlowManagementTest extends FlowManagementTestBase { + + private static final String USER_RESOLVE_EXECUTOR = "UserResolveExecutor"; + private static final String NOTIFY_USER_EXISTENCE = "notifyUserExistence"; + private static final String NOTIFY_USER_ACCOUNT_STATUS = "notifyUserAccountStatus"; + + private FlowManagementClient flowManagementClient; + private static String passwordRecoveryFlowRequestJson; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public PasswordRecoveryFlowManagementTest(TestUserMode userMode) throws Exception { + + super.init(userMode); + this.context = isServer; + this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); + this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); + this.tenant = context.getContextTenant().getDomain(); + } + + @BeforeClass(alwaysRun = true) + public void init() throws IOException { + + super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); + flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); + passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); + } + + @AfterClass(alwaysRun = true) + public void testCleanup() throws Exception { + + flowManagementClient.closeHttpClient(); + super.testConclude(); + } + + @Test(description = "Update the password recovery flow with notify flags set on the user resolve executor.") + public void testUpdatePasswordRecoveryFlow() throws Exception { + + FlowRequest passwordRecoveryFlowRequest = getFlowRequest(); + flowManagementClient.putFlow(passwordRecoveryFlowRequest); + } + + @Test(description = "Get the password recovery flow and verify the notify flags round-trip on the executor meta.", + dependsOnMethods = "testUpdatePasswordRecoveryFlow") + public void testGetPasswordRecoveryFlow() throws Exception { + + FlowResponse passwordRecoveryFlowResponse = flowManagementClient.getFlow(PASSWORD_RECOVERY); + Assert.assertNotNull(passwordRecoveryFlowResponse, "Password recovery flow response should not be null."); + Assert.assertNotNull(passwordRecoveryFlowResponse.getSteps(), "Password recovery flow steps should not be null."); + + Object meta = findUserResolveExecutorMetaInSteps(passwordRecoveryFlowResponse.getSteps()); + Assert.assertNotNull(meta, "Could not locate the UserResolveExecutor meta in the retrieved flow."); + Assert.assertTrue(meta instanceof Map, + "Expected the UserResolveExecutor meta to be a map but got: " + meta.getClass().getName()); + + Map metaMap = (Map) meta; + Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_EXISTENCE)), "true", + "Expected notifyUserExistence to round-trip as true on the UserResolveExecutor meta."); + Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_ACCOUNT_STATUS)), "true", + "Expected notifyUserAccountStatus to round-trip as true on the UserResolveExecutor meta."); + } + + private FlowRequest getFlowRequest() throws IOException { + + return new ObjectMapper(new JsonFactory()).readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + } + + /** + * Walk the flow steps and locate the {@code meta} object of the {@code UserResolveExecutor} action. + * + * @param steps Steps of the retrieved flow. + * @return The executor meta object, or {@code null} if the executor is not found. + */ + private Object findUserResolveExecutorMetaInSteps(List steps) { + + for (Step step : steps) { + if (step.getData() == null) { + continue; + } + Object meta = findUserResolveExecutorMeta(step.getData().getComponents()); + if (meta != null) { + return meta; + } + } + return null; + } + + private Object findUserResolveExecutorMeta(List components) { + + if (components == null) { + return null; + } + for (Component component : components) { + Action action = component.getAction(); + if (action != null && action.getExecutor() != null) { + Executor executor = action.getExecutor(); + if (USER_RESOLVE_EXECUTOR.equals(executor.getName())) { + return executor.getMeta(); + } + } + Object nestedMeta = findUserResolveExecutorMeta(component.getComponents()); + if (nestedMeta != null) { + return nestedMeta; + } + } + return null; + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json new file mode 100644 index 00000000000..f274dcffef1 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json @@ -0,0 +1,248 @@ +{ + "steps": [ + { + "id": "view_xx62", + "type": "VIEW", + "size": { + "width": 350, + "height": 556 + }, + "position": { + "x": 300, + "y": 200 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_n6io", + "variant": "H3", + "config": { + "text": "Forgot Password?" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_te8z", + "components": [ + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "
We've got you covered. Enter your username to begin resetting your password.
" + }, + "id": "rich_text_83lf" + }, + { + "category": "FIELD", + "type": "INPUT", + "variant": "TEXT", + "config": { + "type": "text", + "hint": "", + "label": "Username", + "required": true, + "placeholder": "Enter your username", + "identifier": "http://wso2.org/claims/username" + }, + "id": "input_i08a" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_1ov4", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserResolveExecutor", + "meta": { + "notifyUserExistence": "true", + "notifyUserAccountStatus": "true" + } + }, + "next": "ID_csxv" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_lply", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 1140, + "y": 319 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_9ci4", + "variant": "H3", + "config": { + "text": "Reset Password" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_4khj", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "PASSWORD", + "config": { + "identifier": "password", + "type": "password", + "hint": "", + "label": "Password", + "required": true, + "placeholder": "Enter your password" + }, + "id": "input_x92u" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_x19f", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "PasswordProvisioningExecutor" + }, + "next": "END" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_csxv", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 735, + "y": 301 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_a99r", + "variant": "H3", + "config": { + "text": "OTP Verification" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_n6zq", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "OTP", + "config": { + "type": "text", + "hint": "", + "identifier": "otp", + "label": "Enter the code sent to your mobile", + "required": false, + "placeholder": "" + }, + "id": "input_nccl" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_yzph", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "EmailOTPExecutor" + }, + "next": "ID_lply" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "END", + "type": "END", + "size": { + "width": 350, + "height": 328 + }, + "position": { + "x": 1555, + "y": 420 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_u2oz", + "variant": "H3", + "config": { + "text": "Password Reset Successfully" + } + }, + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "

You have successfully changed your password. You can now sign in with your new password.

" + }, + "id": "display_hgvm" + } + ], + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserProvisioningExecutor" + } + } + } + } + ], + "flowType": "PASSWORD_RECOVERY" +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json new file mode 100644 index 00000000000..f274dcffef1 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json @@ -0,0 +1,248 @@ +{ + "steps": [ + { + "id": "view_xx62", + "type": "VIEW", + "size": { + "width": 350, + "height": 556 + }, + "position": { + "x": 300, + "y": 200 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_n6io", + "variant": "H3", + "config": { + "text": "Forgot Password?" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_te8z", + "components": [ + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "
We've got you covered. Enter your username to begin resetting your password.
" + }, + "id": "rich_text_83lf" + }, + { + "category": "FIELD", + "type": "INPUT", + "variant": "TEXT", + "config": { + "type": "text", + "hint": "", + "label": "Username", + "required": true, + "placeholder": "Enter your username", + "identifier": "http://wso2.org/claims/username" + }, + "id": "input_i08a" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_1ov4", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserResolveExecutor", + "meta": { + "notifyUserExistence": "true", + "notifyUserAccountStatus": "true" + } + }, + "next": "ID_csxv" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_lply", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 1140, + "y": 319 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_9ci4", + "variant": "H3", + "config": { + "text": "Reset Password" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_4khj", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "PASSWORD", + "config": { + "identifier": "password", + "type": "password", + "hint": "", + "label": "Password", + "required": true, + "placeholder": "Enter your password" + }, + "id": "input_x92u" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_x19f", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "PasswordProvisioningExecutor" + }, + "next": "END" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_csxv", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 735, + "y": 301 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_a99r", + "variant": "H3", + "config": { + "text": "OTP Verification" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_n6zq", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "OTP", + "config": { + "type": "text", + "hint": "", + "identifier": "otp", + "label": "Enter the code sent to your mobile", + "required": false, + "placeholder": "" + }, + "id": "input_nccl" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_yzph", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "EmailOTPExecutor" + }, + "next": "ID_lply" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "END", + "type": "END", + "size": { + "width": 350, + "height": 328 + }, + "position": { + "x": 1555, + "y": 420 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_u2oz", + "variant": "H3", + "config": { + "text": "Password Reset Successfully" + } + }, + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "

You have successfully changed your password. You can now sign in with your new password.

" + }, + "id": "display_hgvm" + } + ], + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserProvisioningExecutor" + } + } + } + } + ], + "flowType": "PASSWORD_RECOVERY" +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml index f58b65e2b4d..99a1034e3d4 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml @@ -333,7 +333,9 @@ + + @@ -571,6 +573,6 @@ - + From 21651a8a60c72ac2aaaeb8b90696142702083216 Mon Sep 17 00:00:00 2001 From: VIHANGAGIT Date: Wed, 17 Jun 2026 09:47:22 +0530 Subject: [PATCH 2/5] Add integration tests to password recovery flow enumeration controls --- .../execution/v1/FlowExecutionTestBase.java | 26 ++ ...merationControlsExecutionNegativeTest.java | 159 +++++++++ ...merationControlsExecutionPositiveTest.java | 326 ++++++++++++++++++ .../server/flow/execution/v1/model/Data.java | 34 +- .../flow/execution/v1/model/Message.java | 174 ++++++++++ .../management/v1/FlowManagementTestBase.java | 2 + ...erationControlsManagementNegativeTest.java | 97 ++++++ ...erationControlsManagementPositiveTest.java | 178 ++++++++++ .../execution/v1/password-recovery-flow.json | 248 +++++++++++++ .../management/v1/password-recovery-flow.json | 248 +++++++++++++ .../src/test/resources/testng.xml | 6 +- 11 files changed, 1495 insertions(+), 3 deletions(-) create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json create mode 100644 modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java index c42ba694cca..c6e20359486 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java @@ -42,6 +42,7 @@ public class FlowExecutionTestBase extends RESTAPIServerTestBase { protected static final String TYPE_VIEW = "VIEW"; protected static final String TYPE_REDIRECTION = "REDIRECTION"; protected static final String REGISTRATION_FLOW = "registration-flow.json"; + protected static final String PASSWORD_RECOVERY_FLOW = "password-recovery-flow.json"; protected static final String API_DEFINITION_NAME = "flow-execution.yaml"; protected static final String API_VERSION = "v1"; protected static final String API_PACKAGE_NAME = "org.wso2.carbon.identity.api.server.flow.execution.v1"; @@ -82,6 +83,30 @@ protected void addRegistrationFlow(FlowManagementClient client) throws Exception client.putFlow(flowRequest); } + protected void addPasswordRecoveryFlow(FlowManagementClient client) throws Exception { + + String passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); + FlowRequest flowRequest = new ObjectMapper() + .readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + client.putFlow(flowRequest); + } + + protected void enablePasswordRecoveryFlow(FlowManagementClient client) throws Exception { + + FlowConfig flowConfigDTO = new FlowConfig(); + flowConfigDTO.setIsEnabled(true); + flowConfigDTO.setFlowType(FlowTypes.PASSWORD_RECOVERY); + client.updateFlowConfig(flowConfigDTO); + } + + protected void disablePasswordRecoveryFlow(FlowManagementClient client) throws Exception { + + FlowConfig flowConfigDTO = new FlowConfig(); + flowConfigDTO.setIsEnabled(false); + flowConfigDTO.setFlowType(FlowTypes.PASSWORD_RECOVERY); + client.updateFlowConfig(flowConfigDTO); + } + protected void enableFlow(String flowType, FlowManagementClient client) throws Exception { FlowConfig flowConfigDTO = new FlowConfig(); @@ -105,5 +130,6 @@ protected void disableFlow(String flowType, FlowManagementClient client) throws protected static class FlowTypes { public static final String REGISTRATION = "REGISTRATION"; + public static final String PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java new file mode 100644 index 00000000000..0177407e89f --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java @@ -0,0 +1,159 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.execution.v1; + +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; +import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionResponse; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Error; +import org.wso2.identity.integration.test.restclients.FlowExecutionClient; +import org.wso2.identity.integration.test.restclients.FlowManagementClient; + +import java.util.HashMap; +import java.util.Map; + +import static org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.FlowExecutionTestBase.FlowTypes.PASSWORD_RECOVERY; + +/** + * Negative tests for the password recovery flow execution with the user enumeration and account status controls + * enabled on the {@code UserResolveExecutor}. Covers the generic flow-execution error responses: initiating or + * executing before the flow is enabled, executing with an invalid flow id, and submitting empty inputs. + */ +public class RecoveryEnumerationControlsExecutionNegativeTest extends FlowExecutionTestBase { + + private static final String USERNAME_CLAIM = "http://wso2.org/claims/username"; + private static final String USER_RESOLVE_ACTION_ID = "button_1ov4"; + private static final String TEST_IDENTIFIER = "PwdRecNegTestUser"; + + private static final String FLOW_NOT_ENABLED_ON_INITIATE = "FE-60101"; + private static final String FLOW_NOT_ENABLED_OR_INVALID_FLOW_ID = "FE-60001"; + private static final String INVALID_INPUTS = "FE-60008"; + + private FlowExecutionClient flowExecutionClient; + private FlowManagementClient flowManagementClient; + private static String flowId; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public RecoveryEnumerationControlsExecutionNegativeTest(TestUserMode userMode) throws Exception { + + super.init(userMode); + this.context = isServer; + this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); + this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); + this.tenant = context.getContextTenant().getDomain(); + } + + @BeforeClass(alwaysRun = true) + public void setupClass() throws Exception { + + super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); + flowExecutionClient = new FlowExecutionClient(serverURL, tenantInfo); + flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); + addPasswordRecoveryFlow(flowManagementClient); + } + + @AfterClass(alwaysRun = true) + public void tearDownClass() throws Exception { + + disablePasswordRecoveryFlow(flowManagementClient); + flowExecutionClient.closeHttpClient(); + flowManagementClient.closeHttpClient(); + } + + @Test(description = "Initiating the password recovery flow before it is enabled returns FE-60101.") + public void testInitiateFlowWithoutEnable() throws Exception { + + Object responseObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); + Assert.assertTrue(responseObj instanceof Error); + Assert.assertEquals(((Error) responseObj).getCode(), FLOW_NOT_ENABLED_ON_INITIATE); + } + + @Test(description = "Executing the password recovery flow before it is enabled returns FE-60001.", + dependsOnMethods = "testInitiateFlowWithoutEnable") + public void testExecuteFlowWithoutEnable() throws Exception { + + Object responseObj = flowExecutionClient.executeFlow(getRequest("INVALID_FLOW_ID", defaultInputs())); + Assert.assertTrue(responseObj instanceof Error); + Assert.assertEquals(((Error) responseObj).getCode(), FLOW_NOT_ENABLED_OR_INVALID_FLOW_ID); + } + + @Test(description = "Enable the flow and initiate it to obtain a valid flow id.", + dependsOnMethods = "testExecuteFlowWithoutEnable") + public void testInitiateFlow() throws Exception { + + enablePasswordRecoveryFlow(flowManagementClient); + Object responseObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); + Assert.assertTrue(responseObj instanceof FlowExecutionResponse); + FlowExecutionResponse response = (FlowExecutionResponse) responseObj; + Assert.assertNotNull(response.getFlowId()); + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + flowId = response.getFlowId(); + } + + @Test(description = "Executing with an invalid flow id returns FE-60001.", + dependsOnMethods = "testInitiateFlow") + public void testExecuteFlowWithInvalidFlowId() throws Exception { + + Object responseObj = flowExecutionClient.executeFlow(getRequest("INVALID_FLOW_ID", defaultInputs())); + Assert.assertTrue(responseObj instanceof Error); + Assert.assertEquals(((Error) responseObj).getCode(), FLOW_NOT_ENABLED_OR_INVALID_FLOW_ID); + } + + @Test(description = "Executing with empty inputs returns FE-60008.", + dependsOnMethods = "testExecuteFlowWithInvalidFlowId") + public void testExecuteFlowWithEmptyInputs() throws Exception { + + Object responseObj = flowExecutionClient.executeFlow(getRequest(flowId, new HashMap<>())); + Assert.assertTrue(responseObj instanceof Error); + Assert.assertEquals(((Error) responseObj).getCode(), INVALID_INPUTS); + } + + private static Map defaultInputs() { + + Map inputs = new HashMap<>(); + inputs.put(USERNAME_CLAIM, TEST_IDENTIFIER); + return inputs; + } + + private static FlowExecutionRequest getRequest(String flowId, Map inputs) { + + FlowExecutionRequest request = new FlowExecutionRequest(); + request.setFlowType(PASSWORD_RECOVERY); + request.setFlowId(flowId); + request.setActionId(USER_RESOLVE_ACTION_ID); + request.setInputs(inputs); + return request; + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java new file mode 100644 index 00000000000..47a5ca505ec --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.execution.v1; + +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; +import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Component; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionResponse; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; +import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.ConnectorsPatchReq; +import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.PropertyReq; +import org.wso2.identity.integration.test.rest.api.user.common.model.Email; +import org.wso2.identity.integration.test.rest.api.user.common.model.Name; +import org.wso2.identity.integration.test.rest.api.user.common.model.PatchOperationRequestObject; +import org.wso2.identity.integration.test.rest.api.user.common.model.UserItemAddGroupobj; +import org.wso2.identity.integration.test.rest.api.user.common.model.UserObject; +import org.wso2.identity.integration.test.restclients.FlowExecutionClient; +import org.wso2.identity.integration.test.restclients.FlowManagementClient; +import org.wso2.identity.integration.test.restclients.IdentityGovernanceRestClient; +import org.wso2.identity.integration.test.restclients.SCIM2RestClient; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.FlowExecutionTestBase.FlowTypes.PASSWORD_RECOVERY; + +/** + * Tests the user enumeration and account status controls of the password recovery flow execution — the + * {@code UserResolveExecutor}'s {@code notifyUserExistence} and {@code notifyUserAccountStatus} flags. + * + *

With both flags enabled, the executor must surface user-facing error messages (instead of silently + * advancing the flow to prevent user enumeration) when the submitted identifier does not resolve to a user, + * or resolves to a user whose account is locked or disabled.

+ */ +public class RecoveryEnumerationControlsExecutionPositiveTest extends FlowExecutionTestBase { + + private static final String USERNAME_CLAIM = "http://wso2.org/claims/username"; + private static final String USER_RESOLVE_ACTION_ID = "button_1ov4"; + + private static final String USER_SYSTEM_SCHEMA_ATTRIBUTE = "urn:scim:wso2:schema"; + private static final String ACCOUNT_LOCKED_ATTRIBUTE = "accountLocked"; + private static final String ACCOUNT_DISABLED_ATTRIBUTE = "accountDisabled"; + + private static final String LOCKED_USER = "PwdRecLockedUser"; + private static final String LOCKED_USER_EMAIL = "pwd.rec.locked@example.com"; + private static final String DISABLED_USER = "PwdRecDisabledUser"; + private static final String DISABLED_USER_EMAIL = "pwd.rec.disabled@example.com"; + private static final String NON_EXISTENT_USER = "PwdRecNonExistentUser"; + private static final String ACTIVE_USER = "PwdRecActiveUser"; + private static final String ACTIVE_USER_EMAIL = "pwd.rec.active@example.com"; + private static final String TEST_USER_PASSWORD = "Wso2@Test123"; + + private static final String USER_NOT_FOUND_I18N_KEY = "user.not.found"; + private static final String ACCOUNT_LOCKED_I18N_KEY_PREFIX = "account.locked"; + private static final String ACCOUNT_DISABLED_I18N_KEY = "account.disabled"; + + private FlowExecutionClient flowExecutionClient; + private FlowManagementClient flowManagementClient; + private IdentityGovernanceRestClient identityGovernanceRestClient; + private SCIM2RestClient scim2RestClient; + + private String lockedUserId; + private String disabledUserId; + private String activeUserId; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public RecoveryEnumerationControlsExecutionPositiveTest(TestUserMode userMode) throws Exception { + + super.init(userMode); + this.context = isServer; + this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); + this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); + this.tenant = context.getContextTenant().getDomain(); + } + + @BeforeClass(alwaysRun = true) + public void setupClass() throws Exception { + + super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); + flowExecutionClient = new FlowExecutionClient(serverURL, tenantInfo); + flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); + identityGovernanceRestClient = new IdentityGovernanceRestClient(serverURL, tenantInfo); + scim2RestClient = new SCIM2RestClient(serverURL, tenantInfo); + + updatePasswordRecoveryFeatureStatus(true); + // The account disable handler is off by default; it must be enabled for the accountDisabled claim to be + // honoured by the UserResolveExecutor's account status check. + setAccountDisableHandlerStatus(true); + addPasswordRecoveryFlow(flowManagementClient); + enablePasswordRecoveryFlow(flowManagementClient); + + lockedUserId = createUser(LOCKED_USER, LOCKED_USER_EMAIL); + setAccountState(lockedUserId, ACCOUNT_LOCKED_ATTRIBUTE, true); + + disabledUserId = createUser(DISABLED_USER, DISABLED_USER_EMAIL); + setAccountState(disabledUserId, ACCOUNT_DISABLED_ATTRIBUTE, true); + + activeUserId = createUser(ACTIVE_USER, ACTIVE_USER_EMAIL); + } + + @AfterClass(alwaysRun = true) + public void tearDownClass() throws Exception { + + if (lockedUserId != null) { + scim2RestClient.deleteUser(lockedUserId); + } + if (disabledUserId != null) { + scim2RestClient.deleteUser(disabledUserId); + } + if (activeUserId != null) { + scim2RestClient.deleteUser(activeUserId); + } + disablePasswordRecoveryFlow(flowManagementClient); + updatePasswordRecoveryFeatureStatus(false); + setAccountDisableHandlerStatus(false); + identityGovernanceRestClient.closeHttpClient(); + flowExecutionClient.closeHttpClient(); + flowManagementClient.closeHttpClient(); + scim2RestClient.closeHttpClient(); + } + + @Test(description = "Initiate the password recovery flow and verify the username collection step is rendered.") + public void testInitiatePasswordRecoveryFlow() throws Exception { + + Object responseObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); + Assert.assertTrue(responseObj instanceof FlowExecutionResponse); + FlowExecutionResponse response = (FlowExecutionResponse) responseObj; + Assert.assertNotNull(response.getFlowId()); + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + } + + @Test(description = "Submit a non-existent identifier with notifyUserExistence enabled and expect an error.", + dependsOnMethods = "testInitiatePasswordRecoveryFlow") + public void testNonExistentIdentifierIsNotified() throws Exception { + + FlowExecutionResponse response = submitIdentifier(NON_EXISTENT_USER); + assertErrorMessage(response, USER_NOT_FOUND_I18N_KEY); + } + + @Test(description = "Submit a locked user with notifyUserAccountStatus enabled and expect an error.", + dependsOnMethods = "testNonExistentIdentifierIsNotified") + public void testLockedAccountIsNotified() throws Exception { + + FlowExecutionResponse response = submitIdentifier(LOCKED_USER); + assertErrorMessage(response, ACCOUNT_LOCKED_I18N_KEY_PREFIX); + } + + @Test(description = "Submit a disabled user with notifyUserAccountStatus enabled and expect an error.", + dependsOnMethods = "testLockedAccountIsNotified") + public void testDisabledAccountIsNotified() throws Exception { + + FlowExecutionResponse response = submitIdentifier(DISABLED_USER); + assertErrorMessage(response, ACCOUNT_DISABLED_I18N_KEY); + } + + @Test(description = "Submit a valid active user with the controls enabled and expect the flow to advance " + + "past user resolution without any enumeration or account-status error.", + dependsOnMethods = "testDisabledAccountIsNotified") + public void testActiveUserResolvesAndAdvances() throws Exception { + + FlowExecutionResponse response = submitIdentifier(ACTIVE_USER); + + // Flow continues as a VIEW step (the next step is rendered), not terminated. + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + + // No enumeration / account-status ERROR was surfaced for a valid active user. + Assert.assertFalse(hasErrorMessage(response), + "Unexpected ERROR message for a valid active user: " + response.getData().getMessages()); + + // The flow advanced to the OTP verification step (proves it moved past user resolution). + Assert.assertTrue(containsOtpComponent(response.getData().getComponents()), + "Expected the OTP verification step to be rendered after resolving a valid user."); + } + + /** + * Initiate a fresh password recovery flow and submit the given username at the user resolution step. + * + * @param username Username submitted to the {@code UserResolveExecutor}. + * @return The flow execution response of the user resolution step. + * @throws Exception If the flow could not be initiated or executed. + */ + private FlowExecutionResponse submitIdentifier(String username) throws Exception { + + Object initiationObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); + Assert.assertTrue(initiationObj instanceof FlowExecutionResponse); + String flowId = ((FlowExecutionResponse) initiationObj).getFlowId(); + + FlowExecutionRequest request = new FlowExecutionRequest(); + request.setFlowType(PASSWORD_RECOVERY); + request.setFlowId(flowId); + request.setActionId(USER_RESOLVE_ACTION_ID); + Map inputs = new HashMap<>(); + inputs.put(USERNAME_CLAIM, username); + request.setInputs(inputs); + + Object responseObj = flowExecutionClient.executeFlow(request); + Assert.assertTrue(responseObj instanceof FlowExecutionResponse); + return (FlowExecutionResponse) responseObj; + } + + /** + * Assert that the response re-prompts the same step and carries an ERROR message with the expected i18n key. + * The i18n key is matched as a prefix to tolerate reason-specific variants (e.g. account locked reasons). + */ + private void assertErrorMessage(FlowExecutionResponse response, String expectedI18nKeyPrefix) { + + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + + List messages = response.getData().getMessages(); + boolean hasExpectedError = messages != null && messages.stream().anyMatch(message -> + message.getType() == Message.TypeEnum.ERROR + && message.getI18nKey() != null + && message.getI18nKey().startsWith(expectedI18nKeyPrefix)); + Assert.assertTrue(hasExpectedError, + "No ERROR message with i18n key '" + expectedI18nKeyPrefix + "' in: " + messages); + } + + /** + * Whether the response carries any ERROR message (e.g. an enumeration or account-status notification). + */ + private boolean hasErrorMessage(FlowExecutionResponse response) { + + List messages = response.getData().getMessages(); + return messages != null && messages.stream() + .anyMatch(message -> message.getType() == Message.TypeEnum.ERROR); + } + + /** + * Recursively walk the rendered components to detect the OTP verification step, identified by an + * INPUT component with the {@code OTP} variant. + */ + private boolean containsOtpComponent(List components) { + + if (components == null) { + return false; + } + for (Component component : components) { + if ("OTP".equals(component.getVariant())) { + return true; + } + if (containsOtpComponent(component.getComponents())) { + return true; + } + } + return false; + } + + private String createUser(String username, String email) throws Exception { + + UserObject user = new UserObject() + .userName(username) + .password(TEST_USER_PASSWORD) + .name(new Name().givenName(username).familyName("User")) + .addEmail(new Email().value(email)); + return scim2RestClient.createUser(user); + } + + private void setAccountState(String userId, String attribute, boolean value) throws IOException { + + UserItemAddGroupobj patchOp = new UserItemAddGroupobj().op(UserItemAddGroupobj.OpEnum.REPLACE); + patchOp.setPath(USER_SYSTEM_SCHEMA_ATTRIBUTE + ":" + attribute); + patchOp.setValue(value); + scim2RestClient.updateUser(new PatchOperationRequestObject().addOperations(patchOp), userId); + } + + private void updatePasswordRecoveryFeatureStatus(boolean enable) throws IOException { + + ConnectorsPatchReq connectorsPatchReq = new ConnectorsPatchReq(); + connectorsPatchReq.setOperation(ConnectorsPatchReq.OperationEnum.UPDATE); + PropertyReq propertyReq = new PropertyReq(); + propertyReq.setName("Recovery.Notification.Password.emailLink.Enable"); + propertyReq.setValue(enable ? "true" : "false"); + connectorsPatchReq.addProperties(propertyReq); + identityGovernanceRestClient.updateConnectors("QWNjb3VudCBNYW5hZ2VtZW50", + "YWNjb3VudC1yZWNvdmVyeQ", connectorsPatchReq); + } + + private void setAccountDisableHandlerStatus(boolean enable) throws IOException { + + ConnectorsPatchReq connectorsPatchReq = new ConnectorsPatchReq(); + connectorsPatchReq.setOperation(ConnectorsPatchReq.OperationEnum.UPDATE); + PropertyReq propertyReq = new PropertyReq(); + propertyReq.setName("account.disable.handler.enable"); + propertyReq.setValue(enable ? "true" : "false"); + connectorsPatchReq.addProperties(propertyReq); + // Category "Account Management", connector "account.disable.handler". + identityGovernanceRestClient.updateConnectors("QWNjb3VudCBNYW5hZ2VtZW50", + "YWNjb3VudC5kaXNhYmxlLmhhbmRsZXI", connectorsPatchReq); + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java index c51c853d278..06df4679453 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Data.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Component; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; import javax.validation.constraints.*; @@ -46,6 +47,7 @@ public class Data { private Map additionalData = null; + private List messages = null; /** **/ @@ -172,6 +174,32 @@ public Data putAdditionalDataItem(String key, Object additionalDataItem) { return this; } + /** + * Messages surfaced to the user during the flow step. + **/ + public Data messages(List messages) { + + this.messages = messages; + return this; + } + + @ApiModelProperty(value = "") + @JsonProperty("messages") + @Valid + public List getMessages() { + return messages; + } + public void setMessages(List messages) { + this.messages = messages; + } + + public Data addMessagesItem(Message messagesItem) { + if (this.messages == null) { + this.messages = new ArrayList(); + } + this.messages.add(messagesItem); + return this; + } @Override @@ -188,12 +216,13 @@ public boolean equals(java.lang.Object o) { Objects.equals(this.redirectURL, data.redirectURL) && Objects.equals(this.requiredParams, data.requiredParams) && Objects.equals(this.webAuthnData, data.webAuthnData) && - Objects.equals(this.additionalData, data.additionalData); + Objects.equals(this.additionalData, data.additionalData) && + Objects.equals(this.messages, data.messages); } @Override public int hashCode() { - return Objects.hash(components, redirectURL, requiredParams, webAuthnData, additionalData); + return Objects.hash(components, redirectURL, requiredParams, webAuthnData, additionalData, messages); } @Override @@ -207,6 +236,7 @@ public String toString() { sb.append(" requiredParams: ").append(toIndentedString(requiredParams)).append("\n"); sb.append(" webAuthnData: ").append(toIndentedString(webAuthnData)).append("\n"); sb.append(" additionalData: ").append(toIndentedString(additionalData)).append("\n"); + sb.append(" messages: ").append(toIndentedString(messages)).append("\n"); sb.append("}"); return sb.toString(); } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java new file mode 100644 index 00000000000..a1a441834bc --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/model/Message.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.annotations.ApiModelProperty; + +import java.util.Objects; + +import javax.validation.Valid; +import javax.xml.bind.annotation.XmlEnum; +import javax.xml.bind.annotation.XmlEnumValue; +import javax.xml.bind.annotation.XmlType; + +/** + * A message surfaced to the user during a flow step (e.g. validation or status messages). + **/ +public class Message { + + @XmlType(name="TypeEnum") + @XmlEnum(String.class) + public enum TypeEnum { + + @XmlEnumValue("ERROR") ERROR(String.valueOf("ERROR")), + @XmlEnumValue("WARNING") WARNING(String.valueOf("WARNING")), + @XmlEnumValue("INFO") INFO(String.valueOf("INFO")); + + private String value; + + TypeEnum(String v) { + value = v; + } + + public String value() { + return value; + } + + @Override + public String toString() { + return String.valueOf(value); + } + + public static TypeEnum fromValue(String value) { + for (TypeEnum b : TypeEnum.values()) { + if (b.value.equals(value)) { + return b; + } + } + throw new IllegalArgumentException("Unexpected value '" + value + "'"); + } + } + + private TypeEnum type; + private String message; + private String i18nKey; + + /** + * Message severity type. + **/ + public Message type(TypeEnum type) { + + this.type = type; + return this; + } + + @ApiModelProperty(example = "ERROR", value = "") + @JsonProperty("type") + @Valid + public TypeEnum getType() { + return type; + } + public void setType(TypeEnum type) { + this.type = type; + } + + /** + * Message. + **/ + public Message message(String message) { + + this.message = message; + return this; + } + + @ApiModelProperty(example = "The user does not exist.", value = "") + @JsonProperty("message") + @Valid + public String getMessage() { + return message; + } + public void setMessage(String message) { + this.message = message; + } + + /** + * Internationalization key. + **/ + public Message i18nKey(String i18nKey) { + + this.i18nKey = i18nKey; + return this; + } + + @ApiModelProperty(example = "user.not.found", value = "") + @JsonProperty("i18nKey") + @Valid + public String getI18nKey() { + return i18nKey; + } + public void setI18nKey(String i18nKey) { + this.i18nKey = i18nKey; + } + + @Override + public boolean equals(java.lang.Object o) { + + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Message msg = (Message) o; + return Objects.equals(this.type, msg.type) && + Objects.equals(this.message, msg.message) && + Objects.equals(this.i18nKey, msg.i18nKey); + } + + @Override + public int hashCode() { + return Objects.hash(type, message, i18nKey); + } + + @Override + public String toString() { + + StringBuilder sb = new StringBuilder(); + sb.append("class Message {\n"); + + sb.append(" type: ").append(toIndentedString(type)).append("\n"); + sb.append(" message: ").append(toIndentedString(message)).append("\n"); + sb.append(" i18nKey: ").append(toIndentedString(i18nKey)).append("\n"); + sb.append("}"); + return sb.toString(); + } + + /** + * Convert the given object to string with each line indented by 4 spaces + * (except the first line). + */ + private String toIndentedString(java.lang.Object o) { + + if (o == null) { + return "null"; + } + return o.toString().replace("\n", "\n"); + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java index 5cbf0fb3dd1..f132ad12fd5 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/FlowManagementTestBase.java @@ -34,6 +34,7 @@ public class FlowManagementTestBase extends RESTAPIServerTestBase { protected static final String REGISTRATION_FLOW = "registration-flow.json"; + protected static final String PASSWORD_RECOVERY_FLOW = "password-recovery-flow.json"; private static final String API_DEFINITION_NAME = "flow.yaml"; protected static final String API_VERSION = "v1"; private static final String API_PACKAGE_NAME = "org.wso2.carbon.identity.api.server.flow.management.v1"; @@ -69,5 +70,6 @@ public void testFinish() { protected static class FlowTypes { public static final String REGISTRATION = "REGISTRATION"; + public static final String PASSWORD_RECOVERY = "PASSWORD_RECOVERY"; } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java new file mode 100644 index 00000000000..e90a451a3d8 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.management.v1; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; +import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; +import org.wso2.identity.integration.test.restclients.FlowManagementClient; + +import java.io.IOException; + +/** + * Negative tests for managing the password recovery flow with the user enumeration and account status controls + * present on the {@code UserResolveExecutor}. A structurally invalid flow definition must be rejected with + * HTTP 400 even when the controls are configured. + */ +public class RecoveryEnumerationControlsManagementNegativeTest extends FlowManagementTestBase { + + private static final String INVALID_STEP_TYPE = "INVALID"; + private static final String BAD_REQUEST_ERROR = "Error code 400"; + + private FlowManagementClient flowManagementClient; + private static String passwordRecoveryFlowRequestJson; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public RecoveryEnumerationControlsManagementNegativeTest(TestUserMode userMode) throws Exception { + + super.init(userMode); + this.context = isServer; + this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); + this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); + this.tenant = context.getContextTenant().getDomain(); + } + + @BeforeClass(alwaysRun = true) + public void init() throws IOException { + + super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); + flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); + passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); + } + + @AfterClass(alwaysRun = true) + public void testCleanup() throws Exception { + + flowManagementClient.closeHttpClient(); + super.testConclude(); + } + + @Test(description = "Updating a structurally invalid password recovery flow is rejected with HTTP 400.") + public void testUpdateInvalidPasswordRecoveryFlowIsRejected() throws Exception { + + FlowRequest invalidFlowRequest = new ObjectMapper(new JsonFactory()) + .readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + // Keep the controls-enabled UserResolveExecutor but corrupt the step type to make the definition invalid. + invalidFlowRequest.getSteps().get(0).setType(INVALID_STEP_TYPE); + try { + flowManagementClient.putFlow(invalidFlowRequest); + Assert.fail("Invalid password recovery flow should be rejected with HTTP 400."); + } catch (Exception e) { + Assert.assertTrue(e.getMessage().contains(BAD_REQUEST_ERROR), + "Expected a 400 error but got: " + e.getMessage()); + } + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java new file mode 100644 index 00000000000..75d1d5c3f17 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java @@ -0,0 +1,178 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.management.v1; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Factory; +import org.testng.annotations.Test; +import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowConfig; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Action; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Component; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Executor; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowResponse; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Step; +import org.wso2.identity.integration.test.restclients.FlowManagementClient; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.wso2.identity.integration.test.rest.api.server.flow.management.v1.FlowManagementTestBase.FlowTypes.PASSWORD_RECOVERY; + +/** + * Positive tests for managing the password recovery flow's user enumeration and account status controls — the + * {@code UserResolveExecutor}'s {@code notifyUserExistence} and {@code notifyUserAccountStatus} flags. While the + * flow config is enabled, the controls-enabled flow is accepted and the notify flags round-trip on the executor + * meta. + */ +public class RecoveryEnumerationControlsManagementPositiveTest extends FlowManagementTestBase { + + private static final String USER_RESOLVE_EXECUTOR = "UserResolveExecutor"; + private static final String NOTIFY_USER_EXISTENCE = "notifyUserExistence"; + private static final String NOTIFY_USER_ACCOUNT_STATUS = "notifyUserAccountStatus"; + + private FlowManagementClient flowManagementClient; + private static String passwordRecoveryFlowRequestJson; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public RecoveryEnumerationControlsManagementPositiveTest(TestUserMode userMode) throws Exception { + + super.init(userMode); + this.context = isServer; + this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); + this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); + this.tenant = context.getContextTenant().getDomain(); + } + + @BeforeClass(alwaysRun = true) + public void init() throws Exception { + + super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); + flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); + passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); + updatePasswordRecoveryFlowStatus(true); + } + + @AfterClass(alwaysRun = true) + public void testCleanup() throws Exception { + + updatePasswordRecoveryFlowStatus(false); + flowManagementClient.closeHttpClient(); + super.testConclude(); + } + + @Test(description = "Verify the password recovery flow config is enabled.") + public void testPasswordRecoveryFlowConfigEnabled() throws Exception { + + FlowConfig flowConfig = flowManagementClient.getFlowConfig(PASSWORD_RECOVERY); + Assert.assertEquals(flowConfig.getFlowType(), PASSWORD_RECOVERY); + Assert.assertTrue(flowConfig.getIsEnabled()); + } + + @Test(description = "Update the password recovery flow with notify flags set on the user resolve executor.", + dependsOnMethods = "testPasswordRecoveryFlowConfigEnabled") + public void testUpdatePasswordRecoveryFlow() throws Exception { + + FlowRequest passwordRecoveryFlowRequest = getFlowRequest(); + flowManagementClient.putFlow(passwordRecoveryFlowRequest); + } + + @Test(description = "Get the password recovery flow and verify the notify flags round-trip on the executor meta.", + dependsOnMethods = "testUpdatePasswordRecoveryFlow") + public void testGetPasswordRecoveryFlow() throws Exception { + + FlowResponse passwordRecoveryFlowResponse = flowManagementClient.getFlow(PASSWORD_RECOVERY); + Object meta = findUserResolveExecutorMetaInSteps(passwordRecoveryFlowResponse.getSteps()); + Assert.assertTrue(meta instanceof Map, "UserResolveExecutor meta not found in the retrieved flow."); + + Map metaMap = (Map) meta; + Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_EXISTENCE)), "true"); + Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_ACCOUNT_STATUS)), "true"); + } + + private FlowRequest getFlowRequest() throws IOException { + + return new ObjectMapper(new JsonFactory()).readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + } + + private void updatePasswordRecoveryFlowStatus(boolean enable) throws Exception { + + FlowConfig flowConfig = new FlowConfig(); + flowConfig.setFlowType(PASSWORD_RECOVERY); + flowConfig.setIsEnabled(enable); + flowManagementClient.updateFlowConfig(flowConfig); + } + + /** + * Walk the flow steps and locate the {@code meta} object of the {@code UserResolveExecutor} action. + * + * @param steps Steps of the retrieved flow. + * @return The executor meta object, or {@code null} if the executor is not found. + */ + private Object findUserResolveExecutorMetaInSteps(List steps) { + + for (Step step : steps) { + if (step.getData() == null) { + continue; + } + Object meta = findUserResolveExecutorMeta(step.getData().getComponents()); + if (meta != null) { + return meta; + } + } + return null; + } + + private Object findUserResolveExecutorMeta(List components) { + + if (components == null) { + return null; + } + for (Component component : components) { + Action action = component.getAction(); + if (action != null && action.getExecutor() != null) { + Executor executor = action.getExecutor(); + if (USER_RESOLVE_EXECUTOR.equals(executor.getName())) { + return executor.getMeta(); + } + } + Object nestedMeta = findUserResolveExecutorMeta(component.getComponents()); + if (nestedMeta != null) { + return nestedMeta; + } + } + return null; + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json new file mode 100644 index 00000000000..271f9ee866e --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-flow.json @@ -0,0 +1,248 @@ +{ + "steps": [ + { + "id": "view_xx62", + "type": "VIEW", + "size": { + "width": 350, + "height": 556 + }, + "position": { + "x": 300, + "y": 200 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_n6io", + "variant": "H3", + "config": { + "text": "Forgot Password?" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_te8z", + "components": [ + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "
We've got you covered. Enter your username to begin resetting your password.
" + }, + "id": "rich_text_83lf" + }, + { + "category": "FIELD", + "type": "INPUT", + "variant": "TEXT", + "config": { + "type": "text", + "hint": "", + "label": "Username", + "required": true, + "placeholder": "Enter your username", + "identifier": "http://wso2.org/claims/username" + }, + "id": "input_i08a" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_1ov4", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserResolveExecutor", + "meta": { + "notifyUserExistence": "true", + "notifyUserAccountStatus": "true" + } + }, + "next": "ID_csxv" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_lply", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 1140, + "y": 319 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_9ci4", + "variant": "H3", + "config": { + "text": "Reset Password" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_4khj", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "PASSWORD", + "config": { + "identifier": "password", + "type": "password", + "hint": "", + "label": "Password", + "required": true, + "placeholder": "Enter your password" + }, + "id": "input_x92u" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_x19f", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "PasswordProvisioningExecutor" + }, + "next": "END" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_csxv", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 735, + "y": 301 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_a99r", + "variant": "H3", + "config": { + "text": "OTP Verification" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_n6zq", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "OTP", + "config": { + "type": "text", + "hint": "", + "identifier": "otp", + "label": "Enter the code sent to your email", + "required": false, + "placeholder": "" + }, + "id": "input_nccl" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_yzph", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "EmailOTPExecutor" + }, + "next": "ID_lply" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "END", + "type": "END", + "size": { + "width": 350, + "height": 328 + }, + "position": { + "x": 1555, + "y": 420 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_u2oz", + "variant": "H3", + "config": { + "text": "Password Reset Successfully" + } + }, + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "

You have successfully changed your password. You can now sign in with your new password.

" + }, + "id": "display_hgvm" + } + ], + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserProvisioningExecutor" + } + } + } + } + ], + "flowType": "PASSWORD_RECOVERY" +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json new file mode 100644 index 00000000000..271f9ee866e --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/password-recovery-flow.json @@ -0,0 +1,248 @@ +{ + "steps": [ + { + "id": "view_xx62", + "type": "VIEW", + "size": { + "width": 350, + "height": 556 + }, + "position": { + "x": 300, + "y": 200 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_n6io", + "variant": "H3", + "config": { + "text": "Forgot Password?" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_te8z", + "components": [ + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "
We've got you covered. Enter your username to begin resetting your password.
" + }, + "id": "rich_text_83lf" + }, + { + "category": "FIELD", + "type": "INPUT", + "variant": "TEXT", + "config": { + "type": "text", + "hint": "", + "label": "Username", + "required": true, + "placeholder": "Enter your username", + "identifier": "http://wso2.org/claims/username" + }, + "id": "input_i08a" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_1ov4", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserResolveExecutor", + "meta": { + "notifyUserExistence": "true", + "notifyUserAccountStatus": "true" + } + }, + "next": "ID_csxv" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_lply", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 1140, + "y": 319 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_9ci4", + "variant": "H3", + "config": { + "text": "Reset Password" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_4khj", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "PASSWORD", + "config": { + "identifier": "password", + "type": "password", + "hint": "", + "label": "Password", + "required": true, + "placeholder": "Enter your password" + }, + "id": "input_x92u" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_x19f", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "PasswordProvisioningExecutor" + }, + "next": "END" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_csxv", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 735, + "y": 301 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_a99r", + "variant": "H3", + "config": { + "text": "OTP Verification" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_n6zq", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "OTP", + "config": { + "type": "text", + "hint": "", + "identifier": "otp", + "label": "Enter the code sent to your email", + "required": false, + "placeholder": "" + }, + "id": "input_nccl" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_yzph", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "EmailOTPExecutor" + }, + "next": "ID_lply" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "END", + "type": "END", + "size": { + "width": 350, + "height": 328 + }, + "position": { + "x": 1555, + "y": 420 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_u2oz", + "variant": "H3", + "config": { + "text": "Password Reset Successfully" + } + }, + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "

You have successfully changed your password. You can now sign in with your new password.

" + }, + "id": "display_hgvm" + } + ], + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserProvisioningExecutor" + } + } + } + } + ], + "flowType": "PASSWORD_RECOVERY" +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml index f58b65e2b4d..f0714e54d03 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml @@ -333,7 +333,11 @@ + + + + @@ -571,6 +575,6 @@ - + From f1f0dced482e274e40810a6be49141624c20fd18 Mon Sep 17 00:00:00 2001 From: VIHANGAGIT Date: Fri, 26 Jun 2026 10:01:24 +0530 Subject: [PATCH 3/5] Add tests for multi attribute login enabled password recovery --- ...merationControlsExecutionPositiveTest.java | 158 +++++++++++++++++- .../src/test/resources/testng.xml | 2 +- 2 files changed, 154 insertions(+), 6 deletions(-) diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java index 47a5ca505ec..59d1f817483 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java @@ -18,6 +18,7 @@ package org.wso2.identity.integration.test.rest.api.server.flow.execution.v1; +import com.fasterxml.jackson.databind.ObjectMapper; import org.testng.Assert; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; @@ -29,6 +30,8 @@ import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionRequest; import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionResponse; import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Step; import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.ConnectorsPatchReq; import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.PropertyReq; import org.wso2.identity.integration.test.rest.api.user.common.model.Email; @@ -59,6 +62,9 @@ public class RecoveryEnumerationControlsExecutionPositiveTest extends FlowExecutionTestBase { private static final String USERNAME_CLAIM = "http://wso2.org/claims/username"; + // Generic identifier field used by the multi-attribute-login flow variant (resolved against the allowed + // attributes by the UserResolveExecutor), instead of binding the field to the username claim. + private static final String USER_IDENTIFIER_FIELD = "userIdentifier"; private static final String USER_RESOLVE_ACTION_ID = "button_1ov4"; private static final String USER_SYSTEM_SCHEMA_ATTRIBUTE = "urn:scim:wso2:schema"; @@ -70,13 +76,25 @@ public class RecoveryEnumerationControlsExecutionPositiveTest extends FlowExecut private static final String DISABLED_USER = "PwdRecDisabledUser"; private static final String DISABLED_USER_EMAIL = "pwd.rec.disabled@example.com"; private static final String NON_EXISTENT_USER = "PwdRecNonExistentUser"; + private static final String NON_EXISTENT_USER_EMAIL = "pwd.rec.nonexistent@example.com"; private static final String ACTIVE_USER = "PwdRecActiveUser"; private static final String ACTIVE_USER_EMAIL = "pwd.rec.active@example.com"; private static final String TEST_USER_PASSWORD = "Wso2@Test123"; - private static final String USER_NOT_FOUND_I18N_KEY = "user.not.found"; - private static final String ACCOUNT_LOCKED_I18N_KEY_PREFIX = "account.locked"; - private static final String ACCOUNT_DISABLED_I18N_KEY = "account.disabled"; + // The executor returns i18n keys wrapped in double braces (e.g. "{{user.not.found}}"); the framework + // serializes them verbatim, so the expected values must include the braces. + private static final String USER_NOT_FOUND_I18N_KEY = "{{user.not.found}}"; + private static final String ACCOUNT_LOCKED_I18N_KEY_PREFIX = "{{account.locked"; + private static final String ACCOUNT_DISABLED_I18N_KEY = "{{account.disabled}}"; + + // Account Management category; multi-attribute login handler connector and its properties. + private static final String ACCOUNT_MGT_CATEGORY = "QWNjb3VudCBNYW5hZ2VtZW50"; + private static final String MULTI_ATTRIBUTE_CONNECTOR_ID = "bXVsdGlhdHRyaWJ1dGUubG9naW4uaGFuZGxlcg"; + private static final String MULTI_ATTRIBUTE_ENABLE_PROPERTY = "account.multiattributelogin.handler.enable"; + private static final String MULTI_ATTRIBUTE_ALLOWED_ATTRIBUTES_PROPERTY = + "account.multiattributelogin.handler.allowedattributes"; + private static final String MULTI_ATTRIBUTE_ALLOWED_ATTRIBUTES = + "http://wso2.org/claims/username, http://wso2.org/claims/emailaddress"; private FlowExecutionClient flowExecutionClient; private FlowManagementClient flowManagementClient; @@ -143,6 +161,7 @@ public void tearDownClass() throws Exception { if (activeUserId != null) { scim2RestClient.deleteUser(activeUserId); } + setMultiAttributeLoginStatus(false); disablePasswordRecoveryFlow(flowManagementClient); updatePasswordRecoveryFeatureStatus(false); setAccountDisableHandlerStatus(false); @@ -207,8 +226,61 @@ public void testActiveUserResolvesAndAdvances() throws Exception { "Expected the OTP verification step to be rendered after resolving a valid user."); } + @Test(description = "Enable multi-attribute login (username + email) and persist the recovery flow variant whose " + + "identifier field uses the generic userIdentifier, so the recovery identifier can be an email.", + dependsOnMethods = "testActiveUserResolvesAndAdvances") + public void testEnableMultiAttributeLogin() throws Exception { + + setMultiAttributeLoginStatus(true); + persistRecoveryFlowWithUserIdentifierField(); + } + + @Test(description = "With multi-attribute login enabled, submit a non-existent email and expect a user-not-found " + + "error.", dependsOnMethods = "testEnableMultiAttributeLogin") + public void testNonExistentEmailIsNotifiedWithMultiAttributeLogin() throws Exception { + + FlowExecutionResponse response = submitIdentifier(USER_IDENTIFIER_FIELD, NON_EXISTENT_USER_EMAIL); + assertErrorMessage(response, USER_NOT_FOUND_I18N_KEY); + } + + @Test(description = "With multi-attribute login enabled, submit a locked user's email and expect an error.", + dependsOnMethods = "testNonExistentEmailIsNotifiedWithMultiAttributeLogin") + public void testLockedAccountIsNotifiedWithMultiAttributeLogin() throws Exception { + + FlowExecutionResponse response = submitIdentifier(USER_IDENTIFIER_FIELD, LOCKED_USER_EMAIL); + assertErrorMessage(response, ACCOUNT_LOCKED_I18N_KEY_PREFIX); + } + + @Test(description = "With multi-attribute login enabled, submit a disabled user's email and expect an error.", + dependsOnMethods = "testLockedAccountIsNotifiedWithMultiAttributeLogin") + public void testDisabledAccountIsNotifiedWithMultiAttributeLogin() throws Exception { + + FlowExecutionResponse response = submitIdentifier(USER_IDENTIFIER_FIELD, DISABLED_USER_EMAIL); + assertErrorMessage(response, ACCOUNT_DISABLED_I18N_KEY); + } + + @Test(description = "With multi-attribute login enabled, submit a valid active user's email and expect the flow " + + "to advance past user resolution without any enumeration or account-status error.", + dependsOnMethods = "testDisabledAccountIsNotifiedWithMultiAttributeLogin") + public void testActiveUserResolvesViaEmailAndAdvances() throws Exception { + + FlowExecutionResponse response = submitIdentifier(USER_IDENTIFIER_FIELD, ACTIVE_USER_EMAIL); + + // Flow continues as a VIEW step (the next step is rendered), not terminated. + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + + // No enumeration / account-status ERROR was surfaced for a valid active user resolved via email. + Assert.assertFalse(hasErrorMessage(response), + "Unexpected ERROR message for a valid active user: " + response.getData().getMessages()); + + // The flow advanced to the OTP verification step (proves it moved past user resolution). + Assert.assertTrue(containsOtpComponent(response.getData().getComponents()), + "Expected the OTP verification step to be rendered after resolving a valid user via email."); + } + /** - * Initiate a fresh password recovery flow and submit the given username at the user resolution step. + * Initiate a fresh password recovery flow and submit the given username under the username claim field. * * @param username Username submitted to the {@code UserResolveExecutor}. * @return The flow execution response of the user resolution step. @@ -216,6 +288,22 @@ public void testActiveUserResolvesAndAdvances() throws Exception { */ private FlowExecutionResponse submitIdentifier(String username) throws Exception { + return submitIdentifier(USERNAME_CLAIM, username); + } + + /** + * Initiate a fresh password recovery flow and submit the given identifier under the given input field at the + * user resolution step. The field is the username claim for the default flow and {@code userIdentifier} for the + * multi-attribute-login variant. + * + * @param identifierField Input field identifier under which the value is submitted (username claim or + * {@code userIdentifier}). + * @param identifier Identifier value submitted to the {@code UserResolveExecutor}. + * @return The flow execution response of the user resolution step. + * @throws Exception If the flow could not be initiated or executed. + */ + private FlowExecutionResponse submitIdentifier(String identifierField, String identifier) throws Exception { + Object initiationObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); Assert.assertTrue(initiationObj instanceof FlowExecutionResponse); String flowId = ((FlowExecutionResponse) initiationObj).getFlowId(); @@ -225,7 +313,7 @@ private FlowExecutionResponse submitIdentifier(String username) throws Exception request.setFlowId(flowId); request.setActionId(USER_RESOLVE_ACTION_ID); Map inputs = new HashMap<>(); - inputs.put(USERNAME_CLAIM, username); + inputs.put(identifierField, identifier); request.setInputs(inputs); Object responseObj = flowExecutionClient.executeFlow(request); @@ -323,4 +411,64 @@ private void setAccountDisableHandlerStatus(boolean enable) throws IOException { identityGovernanceRestClient.updateConnectors("QWNjb3VudCBNYW5hZ2VtZW50", "YWNjb3VudC5kaXNhYmxlLmhhbmRsZXI", connectorsPatchReq); } + + private void setMultiAttributeLoginStatus(boolean enable) throws IOException { + + ConnectorsPatchReq connectorsPatchReq = new ConnectorsPatchReq(); + connectorsPatchReq.setOperation(ConnectorsPatchReq.OperationEnum.UPDATE); + PropertyReq enableProperty = new PropertyReq(); + enableProperty.setName(MULTI_ATTRIBUTE_ENABLE_PROPERTY); + enableProperty.setValue(enable ? "true" : "false"); + connectorsPatchReq.addProperties(enableProperty); + if (enable) { + PropertyReq allowedAttributesProperty = new PropertyReq(); + allowedAttributesProperty.setName(MULTI_ATTRIBUTE_ALLOWED_ATTRIBUTES_PROPERTY); + allowedAttributesProperty.setValue(MULTI_ATTRIBUTE_ALLOWED_ATTRIBUTES); + connectorsPatchReq.addProperties(allowedAttributesProperty); + } + identityGovernanceRestClient.updateConnectors(ACCOUNT_MGT_CATEGORY, MULTI_ATTRIBUTE_CONNECTOR_ID, + connectorsPatchReq); + } + + /** + * Re-persist the password recovery flow with its identifier field rebound from the username claim to the + * generic {@code userIdentifier}, as expected when multi-attribute login is enabled. The standard flow + * resource is reused and rewritten in memory rather than maintaining a separate flow definition. + */ + private void persistRecoveryFlowWithUserIdentifierField() throws Exception { + + FlowRequest flowRequest = new ObjectMapper() + .readValue(readResource(PASSWORD_RECOVERY_FLOW), FlowRequest.class); + replaceIdentifierField(flowRequest, USERNAME_CLAIM, USER_IDENTIFIER_FIELD); + flowManagementClient.putFlow(flowRequest); + } + + private void replaceIdentifierField(FlowRequest flowRequest, String from, String to) { + + for (Step step : flowRequest.getSteps()) { + if (step.getData() != null) { + replaceIdentifierField(step.getData().getComponents(), from, to); + } + } + } + + @SuppressWarnings("unchecked") + private void replaceIdentifierField( + List components, + String from, String to) { + + if (components == null) { + return; + } + for (org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Component component + : components) { + if (component.getConfig() instanceof Map) { + Map config = (Map) component.getConfig(); + if (from.equals(config.get("identifier"))) { + config.put("identifier", to); + } + } + replaceIdentifierField(component.getComponents(), from, to); + } + } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml index f0714e54d03..afd0ba9f831 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml @@ -575,6 +575,6 @@ - + From d5f80c6406d2aeb2b27d44c8313cd2ea7ef65063 Mon Sep 17 00:00:00 2001 From: VIHANGAGIT Date: Fri, 26 Jun 2026 10:24:08 +0530 Subject: [PATCH 4/5] Revert "Merge branch 'fork-integration-recovery-flow-enumeration-controls' into integration-test-recovery-flow-enumeration-controls" This reverts commit 7b5cebdae2995c1e12aff7172b8afdc1979f6eb3, reversing changes made to f1f0dced482e274e40810a6be49141624c20fd18. --- .../v1/PasswordRecoveryFlowExecutionTest.java | 273 ------------------ .../PasswordRecoveryFlowManagementTest.java | 163 ----------- .../src/test/resources/testng.xml | 2 +- 3 files changed, 1 insertion(+), 437 deletions(-) delete mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java delete mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java deleted file mode 100644 index 26756d35ba4..00000000000 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/PasswordRecoveryFlowExecutionTest.java +++ /dev/null @@ -1,273 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.execution.v1; - -import org.testng.Assert; -import org.testng.annotations.AfterClass; -import org.testng.annotations.BeforeClass; -import org.testng.annotations.DataProvider; -import org.testng.annotations.Factory; -import org.testng.annotations.Test; -import org.wso2.carbon.automation.engine.context.TestUserMode; -import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionRequest; -import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionResponse; -import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; -import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.ConnectorsPatchReq; -import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.PropertyReq; -import org.wso2.identity.integration.test.rest.api.user.common.model.Email; -import org.wso2.identity.integration.test.rest.api.user.common.model.Name; -import org.wso2.identity.integration.test.rest.api.user.common.model.PatchOperationRequestObject; -import org.wso2.identity.integration.test.rest.api.user.common.model.UserItemAddGroupobj; -import org.wso2.identity.integration.test.rest.api.user.common.model.UserObject; -import org.wso2.identity.integration.test.restclients.FlowExecutionClient; -import org.wso2.identity.integration.test.restclients.FlowManagementClient; -import org.wso2.identity.integration.test.restclients.IdentityGovernanceRestClient; -import org.wso2.identity.integration.test.restclients.SCIM2RestClient; - -import java.io.IOException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.FlowExecutionTestBase.FlowTypes.PASSWORD_RECOVERY; - -/** - * Test class for the password recovery flow execution when the {@code UserResolveExecutor} is configured - * with {@code notifyUserExistence} and {@code notifyUserAccountStatus} enabled. - * - *

With both flags enabled, the executor must surface user-facing error messages (instead of silently - * advancing the flow) when the submitted identifier does not resolve to a user, or resolves to a user - * whose account is locked or disabled.

- */ -public class PasswordRecoveryFlowExecutionTest extends FlowExecutionTestBase { - - private static final String USERNAME_CLAIM = "http://wso2.org/claims/username"; - private static final String USER_RESOLVE_ACTION_ID = "button_1ov4"; - - private static final String USER_SYSTEM_SCHEMA_ATTRIBUTE = "urn:scim:wso2:schema"; - private static final String ACCOUNT_LOCKED_ATTRIBUTE = "accountLocked"; - private static final String ACCOUNT_DISABLED_ATTRIBUTE = "accountDisabled"; - - private static final String LOCKED_USER = "PwdRecLockedUser"; - private static final String LOCKED_USER_EMAIL = "pwd.rec.locked@example.com"; - private static final String DISABLED_USER = "PwdRecDisabledUser"; - private static final String DISABLED_USER_EMAIL = "pwd.rec.disabled@example.com"; - private static final String NON_EXISTENT_USER = "PwdRecNonExistentUser"; - private static final String TEST_USER_PASSWORD = "Wso2@Test123"; - - private static final String INVALID_IDENTIFIER_I18N_KEY = "invalid.identifier"; - private static final String ACCOUNT_LOCKED_I18N_KEY_PREFIX = "account.locked"; - private static final String ACCOUNT_DISABLED_I18N_KEY = "account.disabled"; - - private FlowExecutionClient flowExecutionClient; - private FlowManagementClient flowManagementClient; - private IdentityGovernanceRestClient identityGovernanceRestClient; - private SCIM2RestClient scim2RestClient; - - private String lockedUserId; - private String disabledUserId; - - @DataProvider(name = "restAPIUserConfigProvider") - public static Object[][] restAPIUserConfigProvider() { - - return new Object[][]{ - {TestUserMode.SUPER_TENANT_ADMIN}, - {TestUserMode.TENANT_ADMIN} - }; - } - - @Factory(dataProvider = "restAPIUserConfigProvider") - public PasswordRecoveryFlowExecutionTest(TestUserMode userMode) throws Exception { - - super.init(userMode); - this.context = isServer; - this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); - this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); - this.tenant = context.getContextTenant().getDomain(); - } - - @BeforeClass(alwaysRun = true) - public void setupClass() throws Exception { - - super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); - flowExecutionClient = new FlowExecutionClient(serverURL, tenantInfo); - flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); - identityGovernanceRestClient = new IdentityGovernanceRestClient(serverURL, tenantInfo); - scim2RestClient = new SCIM2RestClient(serverURL, tenantInfo); - - updatePasswordRecoveryFeatureStatus(true); - addPasswordRecoveryFlow(flowManagementClient); - enablePasswordRecoveryFlow(flowManagementClient); - - lockedUserId = createUser(LOCKED_USER, LOCKED_USER_EMAIL); - setAccountState(lockedUserId, ACCOUNT_LOCKED_ATTRIBUTE, true); - - disabledUserId = createUser(DISABLED_USER, DISABLED_USER_EMAIL); - setAccountState(disabledUserId, ACCOUNT_DISABLED_ATTRIBUTE, true); - } - - @AfterClass(alwaysRun = true) - public void tearDownClass() throws Exception { - - if (lockedUserId != null) { - scim2RestClient.deleteUser(lockedUserId); - } - if (disabledUserId != null) { - scim2RestClient.deleteUser(disabledUserId); - } - disablePasswordRecoveryFlow(flowManagementClient); - updatePasswordRecoveryFeatureStatus(false); - identityGovernanceRestClient.closeHttpClient(); - flowExecutionClient.closeHttpClient(); - flowManagementClient.closeHttpClient(); - scim2RestClient.closeHttpClient(); - } - - @Test(description = "Initiate the password recovery flow and verify the username collection step is rendered.") - public void testInitiatePasswordRecoveryFlow() throws Exception { - - Object responseObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); - Assert.assertTrue(responseObj instanceof FlowExecutionResponse, - "Expected FlowExecutionResponse on initiation but got: " + describe(responseObj)); - FlowExecutionResponse response = (FlowExecutionResponse) responseObj; - Assert.assertNotNull(response.getFlowId(), "Flow id should be returned on initiation."); - Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); - Assert.assertEquals(response.getType().toString(), TYPE_VIEW); - Assert.assertNotNull(response.getData()); - Assert.assertNotNull(response.getData().getComponents()); - } - - @Test(description = "Submit a non-existent identifier with notifyUserExistence enabled and expect an error.", - dependsOnMethods = "testInitiatePasswordRecoveryFlow") - public void testNonExistentIdentifierIsNotified() throws Exception { - - FlowExecutionResponse response = submitIdentifier(NON_EXISTENT_USER); - assertErrorMessage(response, INVALID_IDENTIFIER_I18N_KEY, - "non-existent identifier with notifyUserExistence enabled"); - } - - @Test(description = "Submit a locked user with notifyUserAccountStatus enabled and expect an error.", - dependsOnMethods = "testNonExistentIdentifierIsNotified") - public void testLockedAccountIsNotified() throws Exception { - - FlowExecutionResponse response = submitIdentifier(LOCKED_USER); - assertErrorMessage(response, ACCOUNT_LOCKED_I18N_KEY_PREFIX, - "locked account with notifyUserAccountStatus enabled"); - } - - @Test(description = "Submit a disabled user with notifyUserAccountStatus enabled and expect an error.", - dependsOnMethods = "testLockedAccountIsNotified") - public void testDisabledAccountIsNotified() throws Exception { - - FlowExecutionResponse response = submitIdentifier(DISABLED_USER); - assertErrorMessage(response, ACCOUNT_DISABLED_I18N_KEY, - "disabled account with notifyUserAccountStatus enabled"); - } - - /** - * Initiate a fresh password recovery flow and submit the given username at the user resolution step. - * - * @param username Username submitted to the {@code UserResolveExecutor}. - * @return The flow execution response of the user resolution step. - * @throws Exception If the flow could not be initiated or executed. - */ - private FlowExecutionResponse submitIdentifier(String username) throws Exception { - - Object initiationObj = flowExecutionClient.initiateFlowExecution(PASSWORD_RECOVERY); - Assert.assertTrue(initiationObj instanceof FlowExecutionResponse, - "Expected FlowExecutionResponse on initiation but got: " + describe(initiationObj)); - String flowId = ((FlowExecutionResponse) initiationObj).getFlowId(); - - FlowExecutionRequest request = new FlowExecutionRequest(); - request.setFlowType(PASSWORD_RECOVERY); - request.setFlowId(flowId); - request.setActionId(USER_RESOLVE_ACTION_ID); - Map inputs = new HashMap<>(); - inputs.put(USERNAME_CLAIM, username); - request.setInputs(inputs); - - Object responseObj = flowExecutionClient.executeFlow(request); - Assert.assertTrue(responseObj instanceof FlowExecutionResponse, - "Expected FlowExecutionResponse on execution but got: " + describe(responseObj)); - return (FlowExecutionResponse) responseObj; - } - - /** - * Assert that the response re-prompts the same step and carries an ERROR message with the expected i18n key. - * - * @param response Flow execution response to validate. - * @param expectedI18nKeyPrefix Expected i18n key (matched as a prefix to tolerate reason-specific variants). - * @param scenario Human-readable scenario description used in assertion messages. - */ - private void assertErrorMessage(FlowExecutionResponse response, String expectedI18nKeyPrefix, String scenario) { - - Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE, - "Expected INCOMPLETE status for " + scenario + "."); - Assert.assertEquals(response.getType().toString(), TYPE_VIEW, - "Expected VIEW type for " + scenario + "."); - Assert.assertNotNull(response.getData(), "Expected response data for " + scenario + "."); - - List messages = response.getData().getMessages(); - Assert.assertNotNull(messages, "Expected messages in response data for " + scenario + "."); - Assert.assertFalse(messages.isEmpty(), "Expected at least one message for " + scenario + "."); - - boolean hasExpectedError = messages.stream().anyMatch(message -> - message.getType() == Message.TypeEnum.ERROR - && message.getI18nKey() != null - && message.getI18nKey().startsWith(expectedI18nKeyPrefix)); - Assert.assertTrue(hasExpectedError, - "Expected an ERROR message with i18n key starting with '" + expectedI18nKeyPrefix - + "' for " + scenario + " but received: " + messages); - } - - private String createUser(String username, String email) throws Exception { - - UserObject user = new UserObject() - .userName(username) - .password(TEST_USER_PASSWORD) - .name(new Name().givenName(username).familyName("User")) - .addEmail(new Email().value(email)); - return scim2RestClient.createUser(user); - } - - private void setAccountState(String userId, String attribute, boolean value) throws IOException { - - UserItemAddGroupobj patchOp = new UserItemAddGroupobj().op(UserItemAddGroupobj.OpEnum.REPLACE); - patchOp.setPath(USER_SYSTEM_SCHEMA_ATTRIBUTE + ":" + attribute); - patchOp.setValue(value); - scim2RestClient.updateUser(new PatchOperationRequestObject().addOperations(patchOp), userId); - } - - private void updatePasswordRecoveryFeatureStatus(boolean enable) throws IOException { - - ConnectorsPatchReq connectorsPatchReq = new ConnectorsPatchReq(); - connectorsPatchReq.setOperation(ConnectorsPatchReq.OperationEnum.UPDATE); - PropertyReq propertyReq = new PropertyReq(); - propertyReq.setName("Recovery.Notification.Password.emailLink.Enable"); - propertyReq.setValue(enable ? "true" : "false"); - connectorsPatchReq.addProperties(propertyReq); - identityGovernanceRestClient.updateConnectors("QWNjb3VudCBNYW5hZ2VtZW50", - "YWNjb3VudC1yZWNvdmVyeQ", connectorsPatchReq); - } - - private static String describe(Object responseObj) { - - return responseObj != null ? responseObj.toString() : "null"; - } -} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java deleted file mode 100644 index 9cfcee9a156..00000000000 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/PasswordRecoveryFlowManagementTest.java +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). - * - * WSO2 LLC. licenses this file to you 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.wso2.identity.integration.test.rest.api.server.flow.management.v1; - -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.testng.Assert; -import org.testng.annotations.AfterClass; -import org.testng.annotations.BeforeClass; -import org.testng.annotations.DataProvider; -import org.testng.annotations.Factory; -import org.testng.annotations.Test; -import org.wso2.carbon.automation.engine.context.TestUserMode; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Action; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Component; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Executor; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowResponse; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Step; -import org.wso2.identity.integration.test.restclients.FlowManagementClient; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -import static org.wso2.identity.integration.test.rest.api.server.flow.management.v1.FlowManagementTestBase.FlowTypes.PASSWORD_RECOVERY; - -/** - * Test class for the password recovery flow management API, focused on round-tripping the - * {@code UserResolveExecutor} metadata flags {@code notifyUserExistence} and {@code notifyUserAccountStatus}. - */ -public class PasswordRecoveryFlowManagementTest extends FlowManagementTestBase { - - private static final String USER_RESOLVE_EXECUTOR = "UserResolveExecutor"; - private static final String NOTIFY_USER_EXISTENCE = "notifyUserExistence"; - private static final String NOTIFY_USER_ACCOUNT_STATUS = "notifyUserAccountStatus"; - - private FlowManagementClient flowManagementClient; - private static String passwordRecoveryFlowRequestJson; - - @DataProvider(name = "restAPIUserConfigProvider") - public static Object[][] restAPIUserConfigProvider() { - - return new Object[][]{ - {TestUserMode.SUPER_TENANT_ADMIN}, - {TestUserMode.TENANT_ADMIN} - }; - } - - @Factory(dataProvider = "restAPIUserConfigProvider") - public PasswordRecoveryFlowManagementTest(TestUserMode userMode) throws Exception { - - super.init(userMode); - this.context = isServer; - this.authenticatingUserName = context.getContextTenant().getTenantAdmin().getUserName(); - this.authenticatingCredential = context.getContextTenant().getTenantAdmin().getPassword(); - this.tenant = context.getContextTenant().getDomain(); - } - - @BeforeClass(alwaysRun = true) - public void init() throws IOException { - - super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); - flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); - passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); - } - - @AfterClass(alwaysRun = true) - public void testCleanup() throws Exception { - - flowManagementClient.closeHttpClient(); - super.testConclude(); - } - - @Test(description = "Update the password recovery flow with notify flags set on the user resolve executor.") - public void testUpdatePasswordRecoveryFlow() throws Exception { - - FlowRequest passwordRecoveryFlowRequest = getFlowRequest(); - flowManagementClient.putFlow(passwordRecoveryFlowRequest); - } - - @Test(description = "Get the password recovery flow and verify the notify flags round-trip on the executor meta.", - dependsOnMethods = "testUpdatePasswordRecoveryFlow") - public void testGetPasswordRecoveryFlow() throws Exception { - - FlowResponse passwordRecoveryFlowResponse = flowManagementClient.getFlow(PASSWORD_RECOVERY); - Assert.assertNotNull(passwordRecoveryFlowResponse, "Password recovery flow response should not be null."); - Assert.assertNotNull(passwordRecoveryFlowResponse.getSteps(), "Password recovery flow steps should not be null."); - - Object meta = findUserResolveExecutorMetaInSteps(passwordRecoveryFlowResponse.getSteps()); - Assert.assertNotNull(meta, "Could not locate the UserResolveExecutor meta in the retrieved flow."); - Assert.assertTrue(meta instanceof Map, - "Expected the UserResolveExecutor meta to be a map but got: " + meta.getClass().getName()); - - Map metaMap = (Map) meta; - Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_EXISTENCE)), "true", - "Expected notifyUserExistence to round-trip as true on the UserResolveExecutor meta."); - Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_ACCOUNT_STATUS)), "true", - "Expected notifyUserAccountStatus to round-trip as true on the UserResolveExecutor meta."); - } - - private FlowRequest getFlowRequest() throws IOException { - - return new ObjectMapper(new JsonFactory()).readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); - } - - /** - * Walk the flow steps and locate the {@code meta} object of the {@code UserResolveExecutor} action. - * - * @param steps Steps of the retrieved flow. - * @return The executor meta object, or {@code null} if the executor is not found. - */ - private Object findUserResolveExecutorMetaInSteps(List steps) { - - for (Step step : steps) { - if (step.getData() == null) { - continue; - } - Object meta = findUserResolveExecutorMeta(step.getData().getComponents()); - if (meta != null) { - return meta; - } - } - return null; - } - - private Object findUserResolveExecutorMeta(List components) { - - if (components == null) { - return null; - } - for (Component component : components) { - Action action = component.getAction(); - if (action != null && action.getExecutor() != null) { - Executor executor = action.getExecutor(); - if (USER_RESOLVE_EXECUTOR.equals(executor.getName())) { - return executor.getMeta(); - } - } - Object nestedMeta = findUserResolveExecutorMeta(component.getComponents()); - if (nestedMeta != null) { - return nestedMeta; - } - } - return null; - } -} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml index f0714e54d03..afd0ba9f831 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/testng.xml @@ -575,6 +575,6 @@ - + From 9ab3715af1349ead2ad70b73452f27f619c0b068 Mon Sep 17 00:00:00 2001 From: VIHANGAGIT Date: Fri, 26 Jun 2026 14:47:17 +0530 Subject: [PATCH 5/5] Refactor --- .../execution/v1/FlowExecutionTestBase.java | 20 ++ ...merationControlsExecutionNegativeTest.java | 4 +- ...merationControlsExecutionPositiveTest.java | 103 ++++---- ...erationControlsManagementNegativeTest.java | 103 ++++++-- ...erationControlsManagementPositiveTest.java | 29 +- ...sword-recovery-controls-disabled-flow.json | 248 ++++++++++++++++++ ...assword-recovery-multi-attribute-flow.json | 248 ++++++++++++++++++ 7 files changed, 655 insertions(+), 100 deletions(-) create mode 100644 modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-controls-disabled-flow.json create mode 100644 modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-multi-attribute-flow.json diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java index c6e20359486..6bad7ad0cb5 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/FlowExecutionTestBase.java @@ -43,6 +43,10 @@ public class FlowExecutionTestBase extends RESTAPIServerTestBase { protected static final String TYPE_REDIRECTION = "REDIRECTION"; protected static final String REGISTRATION_FLOW = "registration-flow.json"; protected static final String PASSWORD_RECOVERY_FLOW = "password-recovery-flow.json"; + protected static final String PASSWORD_RECOVERY_MULTI_ATTRIBUTE_FLOW = + "password-recovery-multi-attribute-flow.json"; + protected static final String PASSWORD_RECOVERY_CONTROLS_DISABLED_FLOW = + "password-recovery-controls-disabled-flow.json"; protected static final String API_DEFINITION_NAME = "flow-execution.yaml"; protected static final String API_VERSION = "v1"; protected static final String API_PACKAGE_NAME = "org.wso2.carbon.identity.api.server.flow.execution.v1"; @@ -91,6 +95,22 @@ protected void addPasswordRecoveryFlow(FlowManagementClient client) throws Excep client.putFlow(flowRequest); } + protected void addPasswordRecoveryMultiAttributeFlow(FlowManagementClient client) throws Exception { + + String passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_MULTI_ATTRIBUTE_FLOW); + FlowRequest flowRequest = new ObjectMapper() + .readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + client.putFlow(flowRequest); + } + + protected void addPasswordRecoveryControlsDisabledFlow(FlowManagementClient client) throws Exception { + + String passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_CONTROLS_DISABLED_FLOW); + FlowRequest flowRequest = new ObjectMapper() + .readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + client.putFlow(flowRequest); + } + protected void enablePasswordRecoveryFlow(FlowManagementClient client) throws Exception { FlowConfig flowConfigDTO = new FlowConfig(); diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java index 0177407e89f..16bc68d9079 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionNegativeTest.java @@ -37,9 +37,7 @@ import static org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.FlowExecutionTestBase.FlowTypes.PASSWORD_RECOVERY; /** - * Negative tests for the password recovery flow execution with the user enumeration and account status controls - * enabled on the {@code UserResolveExecutor}. Covers the generic flow-execution error responses: initiating or - * executing before the flow is enabled, executing with an invalid flow id, and submitting empty inputs. + * Negative tests for the password recovery flow execution with the user enumeration and account status controls. */ public class RecoveryEnumerationControlsExecutionNegativeTest extends FlowExecutionTestBase { diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java index 59d1f817483..ea5ca191019 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/RecoveryEnumerationControlsExecutionPositiveTest.java @@ -18,7 +18,6 @@ package org.wso2.identity.integration.test.rest.api.server.flow.execution.v1; -import com.fasterxml.jackson.databind.ObjectMapper; import org.testng.Assert; import org.testng.annotations.AfterClass; import org.testng.annotations.BeforeClass; @@ -30,8 +29,6 @@ import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionRequest; import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowExecutionResponse; import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.Message; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Step; import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.ConnectorsPatchReq; import org.wso2.identity.integration.test.rest.api.server.identity.governance.v1.dto.PropertyReq; import org.wso2.identity.integration.test.rest.api.user.common.model.Email; @@ -52,12 +49,7 @@ import static org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.FlowExecutionTestBase.FlowTypes.PASSWORD_RECOVERY; /** - * Tests the user enumeration and account status controls of the password recovery flow execution — the - * {@code UserResolveExecutor}'s {@code notifyUserExistence} and {@code notifyUserAccountStatus} flags. - * - *

With both flags enabled, the executor must surface user-facing error messages (instead of silently - * advancing the flow to prevent user enumeration) when the submitted identifier does not resolve to a user, - * or resolves to a user whose account is locked or disabled.

+ * Tests the user enumeration and account status controls of the password recovery flow execution. */ public class RecoveryEnumerationControlsExecutionPositiveTest extends FlowExecutionTestBase { @@ -232,7 +224,7 @@ public void testActiveUserResolvesAndAdvances() throws Exception { public void testEnableMultiAttributeLogin() throws Exception { setMultiAttributeLoginStatus(true); - persistRecoveryFlowWithUserIdentifierField(); + addPasswordRecoveryMultiAttributeFlow(flowManagementClient); } @Test(description = "With multi-attribute login enabled, submit a non-existent email and expect a user-not-found " + @@ -279,6 +271,39 @@ public void testActiveUserResolvesViaEmailAndAdvances() throws Exception { "Expected the OTP verification step to be rendered after resolving a valid user via email."); } + @Test(description = "Disable the enumeration controls by persisting the controls-disabled flow (and disabling " + + "multi-attribute login) so the executor must silently advance instead of notifying the user.", + dependsOnMethods = "testActiveUserResolvesViaEmailAndAdvances") + public void testDisableEnumerationControls() throws Exception { + + setMultiAttributeLoginStatus(false); + addPasswordRecoveryControlsDisabledFlow(flowManagementClient); + } + + @Test(description = "With the controls disabled, submitting a non-existent identifier must silently advance the " + + "flow without any enumeration error.", dependsOnMethods = "testDisableEnumerationControls") + public void testNonExistentIdentifierSilentlyAdvances() throws Exception { + + FlowExecutionResponse response = submitIdentifier(NON_EXISTENT_USER); + assertSilentlyAdvances(response); + } + + @Test(description = "With the controls disabled, submitting a locked user must silently advance the flow without " + + "any account-status error.", dependsOnMethods = "testNonExistentIdentifierSilentlyAdvances") + public void testLockedAccountSilentlyAdvances() throws Exception { + + FlowExecutionResponse response = submitIdentifier(LOCKED_USER); + assertSilentlyAdvances(response); + } + + @Test(description = "With the controls disabled, submitting a disabled user must silently advance the flow " + + "without any account-status error.", dependsOnMethods = "testLockedAccountSilentlyAdvances") + public void testDisabledAccountSilentlyAdvances() throws Exception { + + FlowExecutionResponse response = submitIdentifier(DISABLED_USER); + assertSilentlyAdvances(response); + } + /** * Initiate a fresh password recovery flow and submit the given username under the username claim field. * @@ -339,6 +364,22 @@ private void assertErrorMessage(FlowExecutionResponse response, String expectedI "No ERROR message with i18n key '" + expectedI18nKeyPrefix + "' in: " + messages); } + /** + * Assert that the flow silently advanced past user resolution: it re-renders as a VIEW step, carries no ERROR + * message, and renders the OTP verification step (the same response a valid user would get, so the caller + * cannot tell whether the identifier resolved to a user). + */ + private void assertSilentlyAdvances(FlowExecutionResponse response) { + + Assert.assertEquals(response.getFlowStatus(), STATUS_INCOMPLETE); + Assert.assertEquals(response.getType().toString(), TYPE_VIEW); + Assert.assertFalse(hasErrorMessage(response), + "Unexpected ERROR message while the enumeration controls are disabled: " + + response.getData().getMessages()); + Assert.assertTrue(containsOtpComponent(response.getData().getComponents()), + "Expected the OTP verification step to be rendered when the controls are disabled."); + } + /** * Whether the response carries any ERROR message (e.g. an enumeration or account-status notification). */ @@ -429,46 +470,4 @@ private void setMultiAttributeLoginStatus(boolean enable) throws IOException { identityGovernanceRestClient.updateConnectors(ACCOUNT_MGT_CATEGORY, MULTI_ATTRIBUTE_CONNECTOR_ID, connectorsPatchReq); } - - /** - * Re-persist the password recovery flow with its identifier field rebound from the username claim to the - * generic {@code userIdentifier}, as expected when multi-attribute login is enabled. The standard flow - * resource is reused and rewritten in memory rather than maintaining a separate flow definition. - */ - private void persistRecoveryFlowWithUserIdentifierField() throws Exception { - - FlowRequest flowRequest = new ObjectMapper() - .readValue(readResource(PASSWORD_RECOVERY_FLOW), FlowRequest.class); - replaceIdentifierField(flowRequest, USERNAME_CLAIM, USER_IDENTIFIER_FIELD); - flowManagementClient.putFlow(flowRequest); - } - - private void replaceIdentifierField(FlowRequest flowRequest, String from, String to) { - - for (Step step : flowRequest.getSteps()) { - if (step.getData() != null) { - replaceIdentifierField(step.getData().getComponents(), from, to); - } - } - } - - @SuppressWarnings("unchecked") - private void replaceIdentifierField( - List components, - String from, String to) { - - if (components == null) { - return; - } - for (org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Component component - : components) { - if (component.getConfig() instanceof Map) { - Map config = (Map) component.getConfig(); - if (from.equals(config.get("identifier"))) { - config.put("identifier", to); - } - } - replaceIdentifierField(component.getComponents(), from, to); - } - } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java index e90a451a3d8..af967b061ec 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementNegativeTest.java @@ -27,20 +27,29 @@ import org.testng.annotations.Factory; import org.testng.annotations.Test; import org.wso2.carbon.automation.engine.context.TestUserMode; +import org.wso2.identity.integration.test.rest.api.server.flow.execution.v1.model.FlowConfig; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Action; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Component; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Executor; import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; +import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowResponse; import org.wso2.identity.integration.test.restclients.FlowManagementClient; import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static org.wso2.identity.integration.test.rest.api.server.flow.management.v1.FlowManagementTestBase.FlowTypes.PASSWORD_RECOVERY; /** - * Negative tests for managing the password recovery flow with the user enumeration and account status controls - * present on the {@code UserResolveExecutor}. A structurally invalid flow definition must be rejected with - * HTTP 400 even when the controls are configured. + * Negative tests for managing the password recovery flow's user enumeration and account status controls. */ public class RecoveryEnumerationControlsManagementNegativeTest extends FlowManagementTestBase { - private static final String INVALID_STEP_TYPE = "INVALID"; - private static final String BAD_REQUEST_ERROR = "Error code 400"; + private static final String USER_RESOLVE_EXECUTOR = "UserResolveExecutor"; + private static final String NOTIFY_USER_EXISTENCE = "notifyUserExistence"; + private static final String NOTIFY_USER_ACCOUNT_STATUS = "notifyUserAccountStatus"; + private static final String CONTROLS_DISABLED = "false"; private FlowManagementClient flowManagementClient; private static String passwordRecoveryFlowRequestJson; @@ -65,33 +74,89 @@ public RecoveryEnumerationControlsManagementNegativeTest(TestUserMode userMode) } @BeforeClass(alwaysRun = true) - public void init() throws IOException { + public void init() throws Exception { super.testInit(API_VERSION, swaggerDefinition, tenantInfo.getDomain()); flowManagementClient = new FlowManagementClient(serverURL, tenantInfo); passwordRecoveryFlowRequestJson = readResource(PASSWORD_RECOVERY_FLOW); + updatePasswordRecoveryFlowStatus(true); } @AfterClass(alwaysRun = true) public void testCleanup() throws Exception { + updatePasswordRecoveryFlowStatus(false); flowManagementClient.closeHttpClient(); super.testConclude(); } - @Test(description = "Updating a structurally invalid password recovery flow is rejected with HTTP 400.") - public void testUpdateInvalidPasswordRecoveryFlowIsRejected() throws Exception { - - FlowRequest invalidFlowRequest = new ObjectMapper(new JsonFactory()) - .readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); - // Keep the controls-enabled UserResolveExecutor but corrupt the step type to make the definition invalid. - invalidFlowRequest.getSteps().get(0).setType(INVALID_STEP_TYPE); - try { - flowManagementClient.putFlow(invalidFlowRequest); - Assert.fail("Invalid password recovery flow should be rejected with HTTP 400."); - } catch (Exception e) { - Assert.assertTrue(e.getMessage().contains(BAD_REQUEST_ERROR), - "Expected a 400 error but got: " + e.getMessage()); + @Test(description = "Update the password recovery flow with the enumeration controls disabled on the user " + + "resolve executor.") + public void testUpdateFlowWithControlsDisabled() throws Exception { + + FlowRequest passwordRecoveryFlowRequest = getFlowRequest(); + setNotifyFlags(passwordRecoveryFlowRequest, CONTROLS_DISABLED); + flowManagementClient.putFlow(passwordRecoveryFlowRequest); + } + + @Test(description = "Get the password recovery flow and verify the notify flags round-trip as disabled on the " + + "executor meta.", dependsOnMethods = "testUpdateFlowWithControlsDisabled") + public void testGetFlowReflectsControlsDisabled() throws Exception { + + FlowResponse passwordRecoveryFlowResponse = flowManagementClient.getFlow(PASSWORD_RECOVERY); + Object meta = findUserResolveExecutorMeta( + passwordRecoveryFlowResponse.getSteps().getFirst().getData().getComponents()); + Assert.assertTrue(meta instanceof Map, "UserResolveExecutor meta not found in the retrieved flow."); + + Map metaMap = (Map) meta; + Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_EXISTENCE)), CONTROLS_DISABLED); + Assert.assertEquals(String.valueOf(metaMap.get(NOTIFY_USER_ACCOUNT_STATUS)), CONTROLS_DISABLED); + } + + private FlowRequest getFlowRequest() throws IOException { + + return new ObjectMapper(new JsonFactory()).readValue(passwordRecoveryFlowRequestJson, FlowRequest.class); + } + + /** + * Set both enumeration-control flags on the {@code UserResolveExecutor} meta to the given value. + */ + @SuppressWarnings("unchecked") + private void setNotifyFlags(FlowRequest flowRequest, String value) { + + Object meta = findUserResolveExecutorMeta(flowRequest.getSteps().get(0).getData().getComponents()); + Assert.assertTrue(meta instanceof Map, "UserResolveExecutor meta not found in the flow definition."); + Map metaMap = (Map) meta; + metaMap.put(NOTIFY_USER_EXISTENCE, value); + metaMap.put(NOTIFY_USER_ACCOUNT_STATUS, value); + } + + private void updatePasswordRecoveryFlowStatus(boolean enable) throws Exception { + + FlowConfig flowConfig = new FlowConfig(); + flowConfig.setFlowType(PASSWORD_RECOVERY); + flowConfig.setIsEnabled(enable); + flowManagementClient.updateFlowConfig(flowConfig); + } + + private Object findUserResolveExecutorMeta(List components) { + + if (components == null) { + return null; + } + for (Component component : components) { + Action action = component.getAction(); + if (action != null && action.getExecutor() != null) { + Executor executor = action.getExecutor(); + if (USER_RESOLVE_EXECUTOR.equals(executor.getName())) { + return executor.getMeta(); + } + } + Object nestedMeta = findUserResolveExecutorMeta(component.getComponents()); + if (nestedMeta != null) { + return nestedMeta; + } } + return null; } } diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java index 75d1d5c3f17..d26bacac83b 100644 --- a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/server/flow/management/v1/RecoveryEnumerationControlsManagementPositiveTest.java @@ -33,7 +33,6 @@ import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Executor; import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowRequest; import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.FlowResponse; -import org.wso2.identity.integration.test.rest.api.server.flow.management.v1.model.Step; import org.wso2.identity.integration.test.restclients.FlowManagementClient; import java.io.IOException; @@ -43,10 +42,7 @@ import static org.wso2.identity.integration.test.rest.api.server.flow.management.v1.FlowManagementTestBase.FlowTypes.PASSWORD_RECOVERY; /** - * Positive tests for managing the password recovery flow's user enumeration and account status controls — the - * {@code UserResolveExecutor}'s {@code notifyUserExistence} and {@code notifyUserAccountStatus} flags. While the - * flow config is enabled, the controls-enabled flow is accepted and the notify flags round-trip on the executor - * meta. + * Positive tests for managing the password recovery flow's user enumeration and account status controls. */ public class RecoveryEnumerationControlsManagementPositiveTest extends FlowManagementTestBase { @@ -114,7 +110,8 @@ public void testUpdatePasswordRecoveryFlow() throws Exception { public void testGetPasswordRecoveryFlow() throws Exception { FlowResponse passwordRecoveryFlowResponse = flowManagementClient.getFlow(PASSWORD_RECOVERY); - Object meta = findUserResolveExecutorMetaInSteps(passwordRecoveryFlowResponse.getSteps()); + Object meta = findUserResolveExecutorMeta( + passwordRecoveryFlowResponse.getSteps().getFirst().getData().getComponents()); Assert.assertTrue(meta instanceof Map, "UserResolveExecutor meta not found in the retrieved flow."); Map metaMap = (Map) meta; @@ -135,26 +132,6 @@ private void updatePasswordRecoveryFlowStatus(boolean enable) throws Exception { flowManagementClient.updateFlowConfig(flowConfig); } - /** - * Walk the flow steps and locate the {@code meta} object of the {@code UserResolveExecutor} action. - * - * @param steps Steps of the retrieved flow. - * @return The executor meta object, or {@code null} if the executor is not found. - */ - private Object findUserResolveExecutorMetaInSteps(List steps) { - - for (Step step : steps) { - if (step.getData() == null) { - continue; - } - Object meta = findUserResolveExecutorMeta(step.getData().getComponents()); - if (meta != null) { - return meta; - } - } - return null; - } - private Object findUserResolveExecutorMeta(List components) { if (components == null) { diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-controls-disabled-flow.json b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-controls-disabled-flow.json new file mode 100644 index 00000000000..8dd9a65e976 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-controls-disabled-flow.json @@ -0,0 +1,248 @@ +{ + "steps": [ + { + "id": "view_xx62", + "type": "VIEW", + "size": { + "width": 350, + "height": 556 + }, + "position": { + "x": 300, + "y": 200 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_n6io", + "variant": "H3", + "config": { + "text": "Forgot Password?" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_te8z", + "components": [ + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "
We've got you covered. Enter your username to begin resetting your password.
" + }, + "id": "rich_text_83lf" + }, + { + "category": "FIELD", + "type": "INPUT", + "variant": "TEXT", + "config": { + "type": "text", + "hint": "", + "label": "Username", + "required": true, + "placeholder": "Enter your username", + "identifier": "http://wso2.org/claims/username" + }, + "id": "input_i08a" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_1ov4", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserResolveExecutor", + "meta": { + "notifyUserExistence": "false", + "notifyUserAccountStatus": "false" + } + }, + "next": "ID_csxv" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_lply", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 1140, + "y": 319 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_9ci4", + "variant": "H3", + "config": { + "text": "Reset Password" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_4khj", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "PASSWORD", + "config": { + "identifier": "password", + "type": "password", + "hint": "", + "label": "Password", + "required": true, + "placeholder": "Enter your password" + }, + "id": "input_x92u" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_x19f", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "PasswordProvisioningExecutor" + }, + "next": "END" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_csxv", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 735, + "y": 301 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_a99r", + "variant": "H3", + "config": { + "text": "OTP Verification" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_n6zq", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "OTP", + "config": { + "type": "text", + "hint": "", + "identifier": "otp", + "label": "Enter the code sent to your email", + "required": false, + "placeholder": "" + }, + "id": "input_nccl" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_yzph", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "EmailOTPExecutor" + }, + "next": "ID_lply" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "END", + "type": "END", + "size": { + "width": 350, + "height": 328 + }, + "position": { + "x": 1555, + "y": 420 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_u2oz", + "variant": "H3", + "config": { + "text": "Password Reset Successfully" + } + }, + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "

You have successfully changed your password. You can now sign in with your new password.

" + }, + "id": "display_hgvm" + } + ], + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserProvisioningExecutor" + } + } + } + } + ], + "flowType": "PASSWORD_RECOVERY" +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-multi-attribute-flow.json b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-multi-attribute-flow.json new file mode 100644 index 00000000000..da30d56aba4 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/resources/org/wso2/identity/integration/test/rest/api/server/flow/execution/v1/password-recovery-multi-attribute-flow.json @@ -0,0 +1,248 @@ +{ + "steps": [ + { + "id": "view_xx62", + "type": "VIEW", + "size": { + "width": 350, + "height": 556 + }, + "position": { + "x": 300, + "y": 200 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_n6io", + "variant": "H3", + "config": { + "text": "Forgot Password?" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_te8z", + "components": [ + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "
We've got you covered. Enter your username to begin resetting your password.
" + }, + "id": "rich_text_83lf" + }, + { + "category": "FIELD", + "type": "INPUT", + "variant": "TEXT", + "config": { + "type": "text", + "hint": "", + "label": "Username", + "required": true, + "placeholder": "Enter your username", + "identifier": "userIdentifier" + }, + "id": "input_i08a" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_1ov4", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserResolveExecutor", + "meta": { + "notifyUserExistence": "true", + "notifyUserAccountStatus": "true" + } + }, + "next": "ID_csxv" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_lply", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 1140, + "y": 319 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_9ci4", + "variant": "H3", + "config": { + "text": "Reset Password" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_4khj", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "PASSWORD", + "config": { + "identifier": "password", + "type": "password", + "hint": "", + "label": "Password", + "required": true, + "placeholder": "Enter your password" + }, + "id": "input_x92u" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_x19f", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "PasswordProvisioningExecutor" + }, + "next": "END" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "ID_csxv", + "type": "VIEW", + "size": { + "width": 350, + "height": 449 + }, + "position": { + "x": 735, + "y": 301 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_a99r", + "variant": "H3", + "config": { + "text": "OTP Verification" + } + }, + { + "category": "BLOCK", + "type": "FORM", + "config": {}, + "id": "form_n6zq", + "components": [ + { + "category": "FIELD", + "type": "INPUT", + "variant": "OTP", + "config": { + "type": "text", + "hint": "", + "identifier": "otp", + "label": "Enter the code sent to your email", + "required": false, + "placeholder": "" + }, + "id": "input_nccl" + }, + { + "category": "ACTION", + "type": "BUTTON", + "id": "button_yzph", + "variant": "PRIMARY", + "action": { + "type": "EXECUTOR", + "executor": { + "name": "EmailOTPExecutor" + }, + "next": "ID_lply" + }, + "config": { + "type": "submit", + "text": "Continue" + } + } + ] + } + ] + } + }, + { + "id": "END", + "type": "END", + "size": { + "width": 350, + "height": 328 + }, + "position": { + "x": 1555, + "y": 420 + }, + "data": { + "components": [ + { + "category": "DISPLAY", + "type": "TYPOGRAPHY", + "id": "typography_u2oz", + "variant": "H3", + "config": { + "text": "Password Reset Successfully" + } + }, + { + "category": "DISPLAY", + "type": "RICH_TEXT", + "config": { + "text": "

You have successfully changed your password. You can now sign in with your new password.

" + }, + "id": "display_hgvm" + } + ], + "action": { + "type": "EXECUTOR", + "executor": { + "name": "UserProvisioningExecutor" + } + } + } + } + ], + "flowType": "PASSWORD_RECOVERY" +}