From 2a378a6ea92d71aca3864e1259fadd420b0e2198 Mon Sep 17 00:00:00 2001 From: Pamoda Wimalasiri Date: Wed, 10 Jun 2026 15:13:49 +0530 Subject: [PATCH] Register credential management v2 REST API in product-IS --- .../api-resources/api-resources-full/pom.xml | 8 + .../src/main/webapp/WEB-INF/web.xml | 3 +- modules/api-resources/pom.xml | 10 + .../v2/CredentialManagementTestBase.java | 112 +++++++ .../CredentialManagementV2NegativeTest.java | 289 +++++++++++++++++ .../CredentialManagementV2PositiveTest.java | 302 ++++++++++++++++++ .../v2/model/CredentialCreationResponse.java | 73 +++++ .../management/v2/model/CredentialEntry.java | 71 ++++ .../v2/model/CredentialsByType.java | 87 +++++ .../CredentialManagementV2RestClient.java | 228 +++++++++++++ .../src/test/resources/testng.xml | 2 + 11 files changed, 1184 insertions(+), 1 deletion(-) create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementTestBase.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2NegativeTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2PositiveTest.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialCreationResponse.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialEntry.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialsByType.java create mode 100644 modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/restclients/CredentialManagementV2RestClient.java diff --git a/modules/api-resources/api-resources-full/pom.xml b/modules/api-resources/api-resources-full/pom.xml index 0448f7a155..be868c3d30 100644 --- a/modules/api-resources/api-resources-full/pom.xml +++ b/modules/api-resources/api-resources-full/pom.xml @@ -244,6 +244,14 @@ org.wso2.carbon.identity.user.api org.wso2.carbon.identity.api.user.password.common + + org.wso2.carbon.identity.user.api + org.wso2.carbon.identity.rest.api.user.credential.v2 + + + org.wso2.carbon.identity.user.api + org.wso2.carbon.identity.api.user.credential.common + org.wso2.carbon.identity.server.api diff --git a/modules/api-resources/api-resources-full/src/main/webapp/WEB-INF/web.xml b/modules/api-resources/api-resources-full/src/main/webapp/WEB-INF/web.xml index 66c99726b7..f37979879a 100644 --- a/modules/api-resources/api-resources-full/src/main/webapp/WEB-INF/web.xml +++ b/modules/api-resources/api-resources-full/src/main/webapp/WEB-INF/web.xml @@ -445,7 +445,8 @@ org.wso2.carbon.identity.rest.api.user.authorized.apps.v2.UserIdApi, org.wso2.carbon.identity.rest.api.user.authorized.apps.v2.AuthorizedAppsApi, org.wso2.carbon.identity.rest.api.user.recovery.v2.RecoveryApi, - org.wso2.carbon.identity.rest.api.user.approval.v2.MeApi + org.wso2.carbon.identity.rest.api.user.approval.v2.MeApi, + org.wso2.carbon.identity.rest.api.user.credential.v2.UsersApi diff --git a/modules/api-resources/pom.xml b/modules/api-resources/pom.xml index c397777f4a..35e868dd7e 100644 --- a/modules/api-resources/pom.xml +++ b/modules/api-resources/pom.xml @@ -128,6 +128,16 @@ org.wso2.carbon.identity.api.user.password.common ${identity.user.api.version} + + org.wso2.carbon.identity.user.api + org.wso2.carbon.identity.rest.api.user.credential.v2 + ${identity.user.api.version} + + + org.wso2.carbon.identity.user.api + org.wso2.carbon.identity.api.user.credential.common + ${identity.user.api.version} + diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementTestBase.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementTestBase.java new file mode 100644 index 0000000000..fc881c606f --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementTestBase.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025, 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.user.credential.management.v2; + +import io.restassured.RestAssured; +import org.apache.commons.lang.StringUtils; +import org.json.JSONException; +import org.json.JSONObject; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.wso2.carbon.base.MultitenantConstants; +import org.wso2.identity.integration.test.rest.api.user.common.RESTAPIUserTestBase; + +import java.io.IOException; + +/** + * Base test class for Credential Management REST API v2. + */ +public class CredentialManagementTestBase extends RESTAPIUserTestBase { + + private static final String API_DEFINITION_NAME = "credential-management.yaml"; + protected static final String API_VERSION = "v2"; + private static final String API_PACKAGE_NAME = "org.wso2.carbon.identity.rest.api.user.credential.v2"; + + public static final String PATH_SEPARATOR = "/"; + protected static final String ORGANIZATION_PATH_SPECIFIER = "/o"; + protected static final String TENANTED_URL_PATH_SPECIFIER = "/t/"; + + protected static final String TYPE_PASSKEY = "passkey"; + protected static final String TYPE_PUSH_AUTH = "push-auth"; + protected static final String TYPE_BACKUP_CODE = "backup-code"; + + protected static final String SECONDARY_ORG_USER_ID = "secondary-org-user-789"; + protected static final String TEST_PASSKEY_CREDENTIAL_ID = "test-passkey-credential-001"; + + protected static String swaggerDefinition; + + static { + try { + swaggerDefinition = getAPISwaggerDefinition(API_PACKAGE_NAME, API_DEFINITION_NAME); + } catch (IOException e) { + Assert.fail(String.format("Unable to read the swagger definition %s from %s", API_DEFINITION_NAME, + API_PACKAGE_NAME), e); + } + } + + @AfterClass(alwaysRun = true) + public void testConclude() throws Exception { + + super.conclude(); + } + + @BeforeMethod(alwaysRun = true) + public void testInit() { + + RestAssured.basePath = basePath; + } + + @AfterMethod(alwaysRun = true) + public void testFinish() { + + RestAssured.basePath = StringUtils.EMPTY; + } + + /** + * Get the list of APIs that need to be authorized for credential management v2 operations. + * + * @return A JSON object containing the API and scopes list. + * @throws JSONException If an error occurs while creating the JSON object. + */ + protected JSONObject getAuthorizedAPIList() throws JSONException { + + JSONObject jsonObject = new JSONObject(); + jsonObject.put("/api/server/v1/organizations", + new String[]{"internal_organization_create", "internal_organization_delete", + "internal_organization_view"}); + return jsonObject; + } + + /** + * Convert tenant base path to organization base path. + * + * @param basePath Tenant base path. + * @return Organization base path. + */ + protected String convertToOrgBasePath(String basePath) { + + if (StringUtils.equals(MultitenantConstants.SUPER_TENANT_DOMAIN_NAME, tenant)) { + return TENANTED_URL_PATH_SPECIFIER + tenant + ORGANIZATION_PATH_SPECIFIER + basePath; + } else { + return basePath.replace(tenant, tenant + ORGANIZATION_PATH_SPECIFIER); + } + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2NegativeTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2NegativeTest.java new file mode 100644 index 0000000000..dc80925a02 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2NegativeTest.java @@ -0,0 +1,289 @@ +/* + * Copyright (c) 2025, 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.user.credential.management.v2; + +import io.restassured.RestAssured; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpStatus; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +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.user.common.model.UserObject; +import org.wso2.identity.integration.test.restclients.CredentialManagementV2RestClient; +import org.wso2.identity.integration.test.restclients.OrgMgtRestClient; +import org.wso2.identity.integration.test.restclients.SCIM2RestClient; + +/** + * Integration tests for Credential Management API v2 negative scenarios. + * Verifies that unsupported operations, invalid types/IDs, and cross-org access + * are rejected with the correct HTTP error codes. + */ +public class CredentialManagementV2NegativeTest extends CredentialManagementTestBase { + + private static final String PARENT_ORG_NAME = "parentOrganizationV2Neg"; + private static final String SECONDARY_ORG_NAME = "secondaryOrganizationV2Neg"; + + private static final String TEST_USER_USERNAME = "credMgtV2NegTestUser"; + private static final String TEST_USER_PASSWORD = "Admin1234!"; + private static final String NON_EXISTENT_USER_ID = "non-existent-user-v2-99999"; + private static final String NON_EXISTENT_CREDENTIAL_ID = "non-existent-credential-v2-99999"; + private static final String INVALID_CREDENTIAL_TYPE = "INVALID_TYPE_V2"; + private static final String VALID_CREDENTIAL_ID = "valid-credential-id-v2"; + + private CredentialManagementV2RestClient credentialManagementV2RestClient; + private OrgMgtRestClient orgMgtRestClient; + private SCIM2RestClient scim2RestClient; + private String testUserId; + private String parentOrgId; + private String secondaryOrgId; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public CredentialManagementV2NegativeTest(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, tenant); + credentialManagementV2RestClient = new CredentialManagementV2RestClient(serverURL, tenantInfo); + scim2RestClient = new SCIM2RestClient(serverURL, tenantInfo); + orgMgtRestClient = new OrgMgtRestClient(context, context.getContextTenant(), serverURL, + getAuthorizedAPIList()); + testUserId = scim2RestClient.createUser(new UserObject().userName(TEST_USER_USERNAME) + .password(TEST_USER_PASSWORD)); + createOrganizationHierarchy(); + } + + @AfterClass(alwaysRun = true) + public void testCleanup() throws Exception { + + if (testUserId != null) { + scim2RestClient.deleteUser(testUserId); + } + deleteOrganizationHierarchy(); + if (scim2RestClient != null) { + scim2RestClient.closeHttpClient(); + } + if (credentialManagementV2RestClient != null) { + credentialManagementV2RestClient.closeHttpClient(); + } + if (orgMgtRestClient != null) { + orgMgtRestClient.closeHttpClient(); + } + super.testConclude(); + } + + @BeforeMethod(alwaysRun = true) + public void testInit() { + + RestAssured.basePath = basePath; + } + + @AfterMethod(alwaysRun = true) + public void testFinish() { + + RestAssured.basePath = StringUtils.EMPTY; + } + + @Test(description = "POST /credentials/passkey must return 400 — passkey creation is not supported in v2") + public void testCreatePasskeyUnsupported() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.createCredentialByType(testUserId, TYPE_PASSKEY); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "Creating a passkey via POST should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60005", + "Creating a passkey via POST should return error code UCM-60005"); + } + + @Test(description = "POST /credentials/push-auth must return 400 — push-auth creation is not supported in v2") + public void testCreatePushAuthUnsupported() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.createCredentialByType(testUserId, TYPE_PUSH_AUTH); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "Creating a push-auth credential via POST should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60005", + "Creating a push-auth credential via POST should return error code UCM-60005"); + } + + @Test(description = "POST /credentials/{invalid-type} must return 400 for an unrecognized type") + public void testCreateCredentialWithInvalidType() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.createCredentialByType(testUserId, INVALID_CREDENTIAL_TYPE); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "Creating a credential with an invalid type should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60002", + "Creating a credential with an invalid type should return error code UCM-60002"); + } + + @Test(description = "DELETE /credentials/passkey must return 400 — deleting passkey by type is not supported in v2") + public void testDeletePasskeyByTypeUnsupported() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.deleteCredentialsByType(testUserId, TYPE_PASSKEY); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "DELETE /credentials/passkey (by type) should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60006", + "DELETE /credentials/passkey (by type) should return error code UCM-60006"); + } + + @Test(description = "DELETE /credentials/push-auth must return 400 — deleting push-auth by type is not supported in v2") + public void testDeletePushAuthByTypeUnsupported() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.deleteCredentialsByType(testUserId, TYPE_PUSH_AUTH); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "DELETE /credentials/push-auth (by type) should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60006", + "DELETE /credentials/push-auth (by type) should return error code UCM-60006"); + } + + @Test(description = "DELETE /credentials/{invalid-type} must return 400 for an unrecognized type") + public void testDeleteCredentialsByTypeWithInvalidType() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.deleteCredentialsByType(testUserId, INVALID_CREDENTIAL_TYPE); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "DELETE /credentials with invalid type should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60002", + "DELETE /credentials with invalid type should return error code UCM-60002"); + } + + @Test(description = "DELETE /credentials/backup-code/{id} must return 400 — backup-code does not support delete by ID") + public void testDeleteBackupCodeByIdUnsupported() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.deleteCredentialById( + testUserId, TYPE_BACKUP_CODE, VALID_CREDENTIAL_ID); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "DELETE /credentials/backup-code/{id} should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60007", + "DELETE /credentials/backup-code/{id} should return error code UCM-60007"); + } + + @Test(description = "DELETE /credentials/passkey/{id} with a non-existent credential ID returns 400 or 404") + public void testDeletePasskeyWithNonExistentCredentialId() throws Exception { + + int status = credentialManagementV2RestClient.deleteCredentialById( + testUserId, TYPE_PASSKEY, NON_EXISTENT_CREDENTIAL_ID).getStatusCode(); + Assert.assertTrue(status == HttpStatus.SC_BAD_REQUEST || status == HttpStatus.SC_NOT_FOUND, + "DELETE passkey with non-existent credential ID should return 400 or 404, got: " + status); + } + + @Test(description = "DELETE /credentials/{type}/{id} with an invalid type must return 400") + public void testDeleteCredentialByIdWithInvalidType() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.deleteCredentialById( + testUserId, INVALID_CREDENTIAL_TYPE, VALID_CREDENTIAL_ID); + Assert.assertEquals(response.getStatusCode(), HttpStatus.SC_BAD_REQUEST, + "DELETE credential by ID with invalid type should return 400"); + Assert.assertEquals(response.getErrorCode(), "UCM-60002", + "DELETE credential by ID with invalid type should return error code UCM-60002"); + } + + @Test(description = "GET /credentials for a non-existent user must return 404") + public void testGetCredentialsForNonExistentUser() throws Exception { + + try { + credentialManagementV2RestClient.getUserCredentials(NON_EXISTENT_USER_ID); + Assert.fail("Expected an exception for non-existent user, but none was thrown"); + } catch (Exception e) { + Assert.assertTrue(e.getMessage().contains("404") || e.getMessage().contains("not found"), + "GET credentials for non-existent user should yield a 404, got: " + e.getMessage()); + } + } + + @Test(description = "POST /credentials/backup-code for a non-existent user must return 404") + public void testCreateBackupCodeForNonExistentUser() throws Exception { + + try { + credentialManagementV2RestClient.createBackupCode(NON_EXISTENT_USER_ID); + Assert.fail("Expected an exception for non-existent user, but none was thrown"); + } catch (Exception e) { + Assert.assertTrue(e.getMessage().contains("404") || e.getMessage().contains("not found"), + "POST backup-code for non-existent user should yield a 404, got: " + e.getMessage()); + } + } + + @Test(description = "Admin from org A cannot GET credentials of a user from org B (cross-org isolation)") + public void testCrossOrganizationGetCredentialsIsolation() throws Exception { + + try { + credentialManagementV2RestClient.getUserCredentials(SECONDARY_ORG_USER_ID); + Assert.fail("Cross-org GET credentials must not succeed; expected 403 or 404 but the call returned successfully"); + } catch (Exception e) { + Assert.assertTrue(e.getMessage().contains("404") || e.getMessage().contains("403"), + "Cross-org GET credentials should yield 403 or 404, got: " + e.getMessage()); + } + } + + @Test(description = "Admin from org A cannot DELETE credentials of a user from org B (cross-org isolation)") + public void testCrossOrganizationDeleteCredentialIsolation() throws Exception { + + CredentialManagementV2RestClient.ApiErrorResponse response = + credentialManagementV2RestClient.deleteCredentialById( + SECONDARY_ORG_USER_ID, TYPE_PASSKEY, TEST_PASSKEY_CREDENTIAL_ID); + int status = response.getStatusCode(); + Assert.assertTrue(status == HttpStatus.SC_NOT_FOUND || status == HttpStatus.SC_BAD_REQUEST + || status == HttpStatus.SC_FORBIDDEN, + "Cross-org DELETE should not succeed; expected 403/404/400, got: " + status); + } + + private void createOrganizationHierarchy() throws Exception { + + parentOrgId = orgMgtRestClient.addOrganization(PARENT_ORG_NAME); + secondaryOrgId = orgMgtRestClient.addOrganization(SECONDARY_ORG_NAME); + } + + private void deleteOrganizationHierarchy() throws Exception { + + if (orgMgtRestClient != null) { + if (parentOrgId != null) { + orgMgtRestClient.deleteOrganization(parentOrgId); + } + if (secondaryOrgId != null) { + orgMgtRestClient.deleteOrganization(secondaryOrgId); + } + } + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2PositiveTest.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2PositiveTest.java new file mode 100644 index 0000000000..3620ffa800 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/CredentialManagementV2PositiveTest.java @@ -0,0 +1,302 @@ +/* + * Copyright (c) 2025, 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.user.credential.management.v2; + +import io.restassured.RestAssured; +import org.apache.commons.lang.StringUtils; +import org.apache.http.HttpStatus; +import org.testng.Assert; +import org.testng.annotations.AfterClass; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +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.user.common.model.UserObject; +import org.wso2.identity.integration.test.rest.api.user.credential.management.v2.model.CredentialCreationResponse; +import org.wso2.identity.integration.test.rest.api.user.credential.management.v2.model.CredentialsByType; +import org.wso2.identity.integration.test.restclients.CredentialManagementV2RestClient; +import org.wso2.identity.integration.test.restclients.OrgMgtRestClient; +import org.wso2.identity.integration.test.restclients.SCIM2RestClient; + +/** + * Integration tests for Credential Management API v2 positive scenarios. + * Covers listing credentials (all types), creating backup codes, deleting backup codes by type, + * and deleting passkey/push-auth credentials by ID. + */ +public class CredentialManagementV2PositiveTest extends CredentialManagementTestBase { + + private static final String PARENT_ORG_NAME = "parentOrganizationV2"; + private static final String SECONDARY_ORG_NAME = "secondaryOrganizationV2"; + + private static final String PARENT_ORG_USER_USERNAME = "credMgtV2PosParentUser"; + private static final String SECONDARY_ORG_USER_USERNAME = "credMgtV2PosSecondaryUser"; + private static final String SUB_ORG_USER_USERNAME = "credMgtV2PosSubOrgUser"; + private static final String TEST_USER_PASSWORD = "Admin1234!"; + + private static final String PARENT_ORG_USER_PASSKEY_CREDENTIAL_ID = "parent-org-passkey-001"; + private static final String PARENT_ORG_USER_PUSH_AUTH_CREDENTIAL_ID = "parent-org-push-auth-001"; + private static final String SUB_ORG_USER_PASSKEY_CREDENTIAL_ID = "sub-org-passkey-001"; + + private CredentialManagementV2RestClient credentialManagementV2RestClient; + private OrgMgtRestClient orgMgtRestClient; + private SCIM2RestClient scim2RestClient; + private String parentOrgUserId; + private String secondaryOrgUserId; + private String subOrgUserId; + private String parentOrgId; + private String secondaryOrgId; + + @DataProvider(name = "restAPIUserConfigProvider") + public static Object[][] restAPIUserConfigProvider() { + + return new Object[][]{ + {TestUserMode.SUPER_TENANT_ADMIN}, + {TestUserMode.TENANT_ADMIN} + }; + } + + @Factory(dataProvider = "restAPIUserConfigProvider") + public CredentialManagementV2PositiveTest(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, tenant); + credentialManagementV2RestClient = new CredentialManagementV2RestClient(serverURL, tenantInfo); + scim2RestClient = new SCIM2RestClient(serverURL, tenantInfo); + orgMgtRestClient = new OrgMgtRestClient(context, context.getContextTenant(), serverURL, + getAuthorizedAPIList()); + parentOrgUserId = scim2RestClient.createUser( + new UserObject().userName(PARENT_ORG_USER_USERNAME).password(TEST_USER_PASSWORD)); + secondaryOrgUserId = scim2RestClient.createUser( + new UserObject().userName(SECONDARY_ORG_USER_USERNAME).password(TEST_USER_PASSWORD)); + subOrgUserId = scim2RestClient.createUser( + new UserObject().userName(SUB_ORG_USER_USERNAME).password(TEST_USER_PASSWORD)); + createOrganizationHierarchy(); + } + + @AfterClass(alwaysRun = true) + public void testCleanup() throws Exception { + + deleteOrganizationHierarchy(); + if (parentOrgUserId != null) { + scim2RestClient.deleteUser(parentOrgUserId); + } + if (secondaryOrgUserId != null) { + scim2RestClient.deleteUser(secondaryOrgUserId); + } + if (subOrgUserId != null) { + scim2RestClient.deleteUser(subOrgUserId); + } + if (scim2RestClient != null) { + scim2RestClient.closeHttpClient(); + } + if (credentialManagementV2RestClient != null) { + credentialManagementV2RestClient.closeHttpClient(); + } + if (orgMgtRestClient != null) { + orgMgtRestClient.closeHttpClient(); + } + } + + @BeforeMethod(alwaysRun = true) + public void testInit() { + + RestAssured.basePath = basePath; + } + + @AfterMethod(alwaysRun = true) + public void testFinish() { + + RestAssured.basePath = StringUtils.EMPTY; + } + + @Test(description = "GET /credentials returns a response with all three credential types present") + public void testGetCredentialsReturnsAllTypes() throws Exception { + + CredentialsByType credentials = credentialManagementV2RestClient.getUserCredentials(parentOrgUserId); + Assert.assertNotNull(credentials, "Response should not be null"); + Assert.assertNotNull(credentials.getPasskey(), + "passkey field should always be present in v2 response"); + Assert.assertNotNull(credentials.getPushAuth(), + "push-auth field should always be present in v2 response"); + Assert.assertNotNull(credentials.getBackupCode(), + "backup-code field should always be present in v2 response"); + } + + @Test(description = "GET /credentials returns empty lists and false for a user with no credentials") + public void testGetCredentialsEmptyForUserWithNoCredentials() throws Exception { + + CredentialsByType credentials = credentialManagementV2RestClient.getUserCredentials(secondaryOrgUserId); + Assert.assertNotNull(credentials, "Response should not be null"); + Assert.assertTrue(credentials.getPasskey().isEmpty(), + "passkey list should be empty for user with no enrolled passkeys"); + Assert.assertTrue(credentials.getPushAuth().isEmpty(), + "push-auth list should be empty for user with no push-auth devices"); + Assert.assertFalse(credentials.getBackupCode(), + "backup-code should be false for user with no backup codes"); + } + + @Test(description = "POST /credentials/backup-code creates backup codes and returns 201 with code list") + public void testCreateBackupCode() throws Exception { + + CredentialCreationResponse response = credentialManagementV2RestClient.createBackupCode(parentOrgUserId); + Assert.assertNotNull(response, "Backup code creation response should not be null"); + Assert.assertEquals(response.getType(), TYPE_BACKUP_CODE, + "type field should be 'backup-code'"); + Assert.assertNotNull(response.getCredentials(), + "credentials list should not be null"); + Assert.assertFalse(response.getCredentials().isEmpty(), + "credentials list should contain at least one backup code"); + } + + @Test(description = "POST /credentials/backup-code regenerates codes, replacing existing ones", + dependsOnMethods = "testCreateBackupCode") + public void testRegenerateBackupCodeReplacesExisting() throws Exception { + + CredentialCreationResponse firstResponse = + credentialManagementV2RestClient.createBackupCode(parentOrgUserId); + CredentialCreationResponse secondResponse = + credentialManagementV2RestClient.createBackupCode(parentOrgUserId); + + Assert.assertNotNull(secondResponse.getCredentials(), "Regenerated codes should not be null"); + Assert.assertFalse(secondResponse.getCredentials().isEmpty(), "Regenerated codes list should not be empty"); + Assert.assertNotEquals(firstResponse.getCredentials(), secondResponse.getCredentials(), + "Regenerated codes should differ from the previous set"); + } + + @Test(description = "GET /credentials shows backup-code as true after creation", + dependsOnMethods = "testCreateBackupCode") + public void testGetCredentialsShowsBackupCodeTrueAfterCreation() throws Exception { + + credentialManagementV2RestClient.createBackupCode(parentOrgUserId); + CredentialsByType credentials = credentialManagementV2RestClient.getUserCredentials(parentOrgUserId); + Assert.assertTrue(credentials.getBackupCode(), + "backup-code should be true after backup codes have been created"); + } + + @Test(description = "DELETE /credentials/backup-code returns 204 and removes backup codes", + dependsOnMethods = "testCreateBackupCode") + public void testDeleteBackupCodeByType() throws Exception { + + credentialManagementV2RestClient.createBackupCode(parentOrgUserId); + int status = credentialManagementV2RestClient.deleteCredentialsByType( + parentOrgUserId, TYPE_BACKUP_CODE).getStatusCode(); + Assert.assertEquals(status, HttpStatus.SC_NO_CONTENT, + "DELETE /credentials/backup-code should return 204"); + } + + @Test(description = "GET /credentials shows backup-code as false after deletion", + dependsOnMethods = "testDeleteBackupCodeByType") + public void testGetCredentialsShowsBackupCodeFalseAfterDeletion() throws Exception { + + credentialManagementV2RestClient.createBackupCode(parentOrgUserId); + credentialManagementV2RestClient.deleteCredentialsByType(parentOrgUserId, TYPE_BACKUP_CODE); + CredentialsByType credentials = credentialManagementV2RestClient.getUserCredentials(parentOrgUserId); + Assert.assertFalse(credentials.getBackupCode(), + "backup-code should be false after deletion"); + } + + @Test(description = "DELETE /credentials/passkey/{id} returns 204, 400, or 404") + public void testDeletePasskeyById() throws Exception { + + int status = credentialManagementV2RestClient.deleteCredentialById( + parentOrgUserId, TYPE_PASSKEY, PARENT_ORG_USER_PASSKEY_CREDENTIAL_ID).getStatusCode(); + Assert.assertTrue(status == HttpStatus.SC_NO_CONTENT || status == HttpStatus.SC_BAD_REQUEST + || status == HttpStatus.SC_NOT_FOUND, + "DELETE passkey by ID should return 204, 400, or 404, got: " + status); + } + + @Test(description = "DELETE /credentials/push-auth/{id} returns 204, 400, or 404") + public void testDeletePushAuthById() throws Exception { + + int status = credentialManagementV2RestClient.deleteCredentialById( + parentOrgUserId, TYPE_PUSH_AUTH, PARENT_ORG_USER_PUSH_AUTH_CREDENTIAL_ID).getStatusCode(); + Assert.assertTrue(status == HttpStatus.SC_NO_CONTENT || status == HttpStatus.SC_BAD_REQUEST + || status == HttpStatus.SC_NOT_FOUND, + "DELETE push-auth by ID should return 204, 400, or 404, got: " + status); + } + + @Test(description = "Parent org admin can GET credentials for a sub-org user") + public void testGetCredentialsForSubOrgUser() throws Exception { + + RestAssured.basePath = convertToOrgBasePath(this.basePath); + CredentialsByType credentials = credentialManagementV2RestClient.getUserCredentials(subOrgUserId); + Assert.assertNotNull(credentials, "Credentials should not be null for sub-org user"); + Assert.assertNotNull(credentials.getPasskey(), "passkey field must always be present"); + Assert.assertNotNull(credentials.getPushAuth(), "push-auth field must always be present"); + Assert.assertNotNull(credentials.getBackupCode(), "backup-code field must always be present"); + } + + @Test(description = "DELETE passkey by ID works for a sub-org user via org base path") + public void testDeletePasskeyByIdForSubOrgUser() throws Exception { + + RestAssured.basePath = convertToOrgBasePath(this.basePath); + int status = credentialManagementV2RestClient.deleteCredentialById( + subOrgUserId, TYPE_PASSKEY, SUB_ORG_USER_PASSKEY_CREDENTIAL_ID).getStatusCode(); + Assert.assertTrue(status == HttpStatus.SC_NO_CONTENT || status == HttpStatus.SC_BAD_REQUEST + || status == HttpStatus.SC_NOT_FOUND, + "DELETE passkey for sub-org user should return 204, 400, or 404, got: " + status); + } + + @Test(description = "Credential isolation: secondary org user's credentials are not visible to parent org admin") + public void testCredentialIsolationBetweenOrgs() throws Exception { + + CredentialsByType parentUserCredentials = + credentialManagementV2RestClient.getUserCredentials(parentOrgUserId); + Assert.assertNotNull(parentUserCredentials, "Parent org user credentials should not be null"); + + CredentialsByType secondaryUserCredentials = + credentialManagementV2RestClient.getUserCredentials(secondaryOrgUserId); + Assert.assertTrue(secondaryUserCredentials.getPasskey().isEmpty(), + "passkey list should be empty for secondary org user as seen by parent org admin"); + Assert.assertTrue(secondaryUserCredentials.getPushAuth().isEmpty(), + "push-auth list should be empty for secondary org user as seen by parent org admin"); + Assert.assertFalse(secondaryUserCredentials.getBackupCode(), + "backup-code should be false for secondary org user as seen by parent org admin"); + } + + private void createOrganizationHierarchy() throws Exception { + + parentOrgId = orgMgtRestClient.addOrganization(PARENT_ORG_NAME); + secondaryOrgId = orgMgtRestClient.addOrganization(SECONDARY_ORG_NAME); + } + + private void deleteOrganizationHierarchy() throws Exception { + + if (orgMgtRestClient != null) { + if (secondaryOrgId != null) { + orgMgtRestClient.deleteOrganization(secondaryOrgId); + } + if (parentOrgId != null) { + orgMgtRestClient.deleteOrganization(parentOrgId); + } + } + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialCreationResponse.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialCreationResponse.java new file mode 100644 index 0000000000..55cded4968 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialCreationResponse.java @@ -0,0 +1,73 @@ +/* + * 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.user.credential.management.v2.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +/** + * Model class for the v2 POST /credentials/{type} response (backup-code creation). + * Contains the credential type and the list of generated backup codes. + */ +public class CredentialCreationResponse { + + @JsonProperty("type") + private String type; + + @JsonProperty("credentials") + private List credentials; + + public String getType() { + + return type; + } + + public void setType(String type) { + + this.type = type; + } + + public List getCredentials() { + + return credentials; + } + + public void setCredentials(List credentials) { + + this.credentials = credentials; + } + + @Override + public boolean equals(Object o) { + + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CredentialCreationResponse that = (CredentialCreationResponse) o; + return Objects.equals(type, that.type) && + Objects.equals(credentials, that.credentials); + } + + @Override + public int hashCode() { + + return Objects.hash(type, credentials); + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialEntry.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialEntry.java new file mode 100644 index 0000000000..7cca65a660 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialEntry.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025, 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.user.credential.management.v2.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Model class for a single credential entry in the v2 response (passkey or push-auth). + */ +public class CredentialEntry { + + @JsonProperty("credentialId") + private String credentialId; + + @JsonProperty("displayName") + private String displayName; + + public String getCredentialId() { + + return credentialId; + } + + public void setCredentialId(String credentialId) { + + this.credentialId = credentialId; + } + + public String getDisplayName() { + + return displayName; + } + + public void setDisplayName(String displayName) { + + this.displayName = displayName; + } + + @Override + public boolean equals(Object o) { + + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CredentialEntry that = (CredentialEntry) o; + return Objects.equals(credentialId, that.credentialId) && + Objects.equals(displayName, that.displayName); + } + + @Override + public int hashCode() { + + return Objects.hash(credentialId, displayName); + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialsByType.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialsByType.java new file mode 100644 index 0000000000..e5b522ffc2 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/rest/api/user/credential/management/v2/model/CredentialsByType.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025, 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.user.credential.management.v2.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Objects; + +/** + * Model class for the v2 GET /credentials response. + * All fields are always present: passkey and push-auth as arrays, backup-code as a boolean. + */ +public class CredentialsByType { + + @JsonProperty("passkey") + private List passkey; + + @JsonProperty("push-auth") + private List pushAuth; + + @JsonProperty("backup-code") + private Boolean backupCode; + + public List getPasskey() { + + return passkey; + } + + public void setPasskey(List passkey) { + + this.passkey = passkey; + } + + public List getPushAuth() { + + return pushAuth; + } + + public void setPushAuth(List pushAuth) { + + this.pushAuth = pushAuth; + } + + public Boolean getBackupCode() { + + return backupCode; + } + + public void setBackupCode(Boolean backupCode) { + + this.backupCode = backupCode; + } + + @Override + public boolean equals(Object o) { + + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + CredentialsByType that = (CredentialsByType) o; + return Objects.equals(passkey, that.passkey) && + Objects.equals(pushAuth, that.pushAuth) && + Objects.equals(backupCode, that.backupCode); + } + + @Override + public int hashCode() { + + return Objects.hash(passkey, pushAuth, backupCode); + } +} diff --git a/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/restclients/CredentialManagementV2RestClient.java b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/restclients/CredentialManagementV2RestClient.java new file mode 100644 index 0000000000..b279523ce3 --- /dev/null +++ b/modules/integration/tests-integration/tests-backend/src/test/java/org/wso2/identity/integration/test/restclients/CredentialManagementV2RestClient.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2025, 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.restclients; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.http.ContentType; +import org.apache.commons.codec.binary.Base64; +import org.apache.http.Header; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.message.BasicHeader; +import org.apache.http.util.EntityUtils; +import org.json.JSONObject; +import org.wso2.carbon.automation.engine.context.beans.Tenant; +import org.wso2.carbon.utils.multitenancy.MultitenantConstants; +import org.wso2.identity.integration.test.rest.api.user.credential.management.v2.model.CredentialCreationResponse; +import org.wso2.identity.integration.test.rest.api.user.credential.management.v2.model.CredentialsByType; +import org.wso2.identity.integration.test.utils.OAuth2Constant; + +import java.io.IOException; + +/** + * Rest client for the Credential Management REST API v2 (/api/users/v2). + */ +public class CredentialManagementV2RestClient extends RestBaseClient { + + private static final String API_USERS_V2_PATH = "api/users/v2"; + private static final String CREDENTIALS_PATH = "/credentials"; + + private final String tenantDomain; + private final String username; + private final String password; + private final String credentialManagementBasePath; + + public CredentialManagementV2RestClient(String serverUrl, Tenant tenantInfo) { + + this.tenantDomain = tenantInfo.getContextUser().getUserDomain(); + this.username = tenantInfo.getContextUser().getUserName(); + this.password = tenantInfo.getContextUser().getPassword(); + this.credentialManagementBasePath = buildBasePath(serverUrl, tenantDomain); + } + + /** + * GET /api/users/v2/{user-id}/credentials — retrieve all credentials grouped by type. + * + * @param userId User ID. + * @return CredentialsByType response. + * @throws Exception If the request fails or returns an unexpected status. + */ + public CredentialsByType getUserCredentials(String userId) throws Exception { + + String url = credentialManagementBasePath + PATH_SEPARATOR + userId + CREDENTIALS_PATH; + try (CloseableHttpResponse response = getResponseOfHttpGet(url, getHeadersWithBasicAuth())) { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + throw new Exception("Unexpected status " + statusCode + " while getting credentials for user: " + + userId); + } + String body = EntityUtils.toString(response.getEntity()); + return new ObjectMapper(new JsonFactory()).readValue(body, CredentialsByType.class); + } + } + + /** + * POST /api/users/v2/{user-id}/credentials/backup-code — create (or regenerate) backup codes. + * + * @param userId User ID. + * @return CredentialCreationResponse containing the generated backup codes. + * @throws Exception If the request fails or returns an unexpected status. + */ + public CredentialCreationResponse createBackupCode(String userId) throws Exception { + + String url = credentialManagementBasePath + PATH_SEPARATOR + userId + CREDENTIALS_PATH + + PATH_SEPARATOR + "backup-code"; + try (CloseableHttpResponse response = getResponseOfHttpPost(url, "", getHeadersWithBasicAuth())) { + int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_CREATED) { + throw new Exception("Unexpected status " + statusCode + + " while creating backup codes for user: " + userId); + } + String body = EntityUtils.toString(response.getEntity()); + return new ObjectMapper(new JsonFactory()).readValue(body, CredentialCreationResponse.class); + } + } + + /** + * POST /api/users/v2/{user-id}/credentials/{type} — attempt to create credential for an unsupported type. + * Only backup-code is creatable; passkey and push-auth must return 400. + * + * @param userId User ID. + * @param type Credential type (e.g. "passkey", "push-auth"). + * @return ApiErrorResponse containing HTTP status code and UCM error code (if any). + * @throws Exception If an I/O error occurs. + */ + public ApiErrorResponse createCredentialByType(String userId, String type) throws Exception { + + String url = credentialManagementBasePath + PATH_SEPARATOR + userId + CREDENTIALS_PATH + + PATH_SEPARATOR + type; + try (CloseableHttpResponse response = getResponseOfHttpPost(url, "", getHeadersWithBasicAuth())) { + int statusCode = response.getStatusLine().getStatusCode(); + String errorCode = statusCode >= 400 ? extractErrorCode(EntityUtils.toString(response.getEntity())) : null; + return new ApiErrorResponse(statusCode, errorCode); + } + } + + /** + * DELETE /api/users/v2/{user-id}/credentials/{type} — delete all credentials of a type. + * Only backup-code supports this; passkey and push-auth must return 400. + * + * @param userId User ID. + * @param type Credential type. + * @return ApiErrorResponse containing HTTP status code and UCM error code (if any). + * @throws Exception If an I/O error occurs. + */ + public ApiErrorResponse deleteCredentialsByType(String userId, String type) throws Exception { + + String url = credentialManagementBasePath + PATH_SEPARATOR + userId + CREDENTIALS_PATH + + PATH_SEPARATOR + type; + try (CloseableHttpResponse response = getResponseOfHttpDelete(url, getHeadersWithBasicAuth())) { + int statusCode = response.getStatusLine().getStatusCode(); + String errorCode = statusCode >= 400 ? extractErrorCode(EntityUtils.toString(response.getEntity())) : null; + return new ApiErrorResponse(statusCode, errorCode); + } + } + + /** + * DELETE /api/users/v2/{user-id}/credentials/{type}/{credential-id} — delete a specific credential. + * Supported for passkey and push-auth; backup-code must return 400. + * + * @param userId User ID. + * @param type Credential type. + * @param credentialId Credential ID. + * @return ApiErrorResponse containing HTTP status code and UCM error code (if any). + * @throws Exception If an I/O error occurs. + */ + public ApiErrorResponse deleteCredentialById(String userId, String type, String credentialId) throws Exception { + + String url = credentialManagementBasePath + PATH_SEPARATOR + userId + CREDENTIALS_PATH + + PATH_SEPARATOR + type + PATH_SEPARATOR + credentialId; + try (CloseableHttpResponse response = getResponseOfHttpDelete(url, getHeadersWithBasicAuth())) { + int statusCode = response.getStatusLine().getStatusCode(); + String errorCode = statusCode >= 400 ? extractErrorCode(EntityUtils.toString(response.getEntity())) : null; + return new ApiErrorResponse(statusCode, errorCode); + } + } + + /** + * Close the HTTP client. + * + * @throws IOException If an error occurred while closing. + */ + public void closeHttpClient() throws IOException { + + client.close(); + } + + private String extractErrorCode(String responseBody) { + + try { + if (responseBody != null && !responseBody.isEmpty()) { + return new JSONObject(responseBody).optString("code", null); + } + } catch (Exception ignored) { + // not valid JSON or missing code field + } + return null; + } + + /** + * Holds the HTTP status code and UCM error code extracted from an API error response body. + */ + public static class ApiErrorResponse { + + private final int statusCode; + private final String errorCode; + + public ApiErrorResponse(int statusCode, String errorCode) { + + this.statusCode = statusCode; + this.errorCode = errorCode; + } + + public int getStatusCode() { + + return statusCode; + } + + public String getErrorCode() { + + return errorCode; + } + } + + private String buildBasePath(String serverUrl, String tenantDomain) { + + if (tenantDomain.equals(MultitenantConstants.SUPER_TENANT_DOMAIN_NAME)) { + return serverUrl + API_USERS_V2_PATH; + } + return serverUrl + TENANT_PATH + tenantDomain + PATH_SEPARATOR + API_USERS_V2_PATH; + } + + private Header[] getHeadersWithBasicAuth() { + + Header[] headerList = new Header[3]; + headerList[0] = new BasicHeader(USER_AGENT_ATTRIBUTE, OAuth2Constant.USER_AGENT); + headerList[1] = new BasicHeader(AUTHORIZATION_ATTRIBUTE, BASIC_AUTHORIZATION_ATTRIBUTE + + Base64.encodeBase64String((username + ":" + password).getBytes()).trim()); + headerList[2] = new BasicHeader(CONTENT_TYPE_ATTRIBUTE, String.valueOf(ContentType.JSON)); + return headerList; + } +} 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 f58b65e2b4..a834e45e21 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 @@ -339,6 +339,8 @@ + +