From 669f6b174e179a25fde7fbb197e9ce4773d2e7b1 Mon Sep 17 00:00:00 2001 From: Ankit Tiwari Date: Fri, 8 Mar 2024 20:04:15 +0530 Subject: [PATCH] feat: Add ProcessBulkImportUsers cron job --- src/main/java/io/supertokens/Main.java | 4 + .../io/supertokens/authRecipe/AuthRecipe.java | 200 +++++--- .../io/supertokens/bulkimport/BulkImport.java | 2 + .../bulkimport/BulkImportUserUtils.java | 114 ++++- .../bulkimport/ProcessBulkImportUsers.java | 431 ++++++++++++++++++ .../emailpassword/EmailPassword.java | 84 ++-- .../multitenancy/Multitenancy.java | 252 ++++++---- .../passwordless/Passwordless.java | 97 ++-- .../io/supertokens/thirdparty/ThirdParty.java | 53 ++- .../useridmapping/UserIdMapping.java | 34 +- .../usermetadata/UserMetadata.java | 38 +- .../io/supertokens/userroles/UserRoles.java | 13 + .../api/bulkimport/BulkImportAPI.java | 7 +- .../test/bulkimport/BulkImportTest.java | 4 +- .../test/bulkimport/BulkImportTestUtils.java | 12 +- .../apis/AddBulkImportUsersTest.java | 191 ++++++-- .../apis/GetBulkImportUsersTest.java | 11 +- 17 files changed, 1226 insertions(+), 321 deletions(-) create mode 100644 src/main/java/io/supertokens/cronjobs/bulkimport/ProcessBulkImportUsers.java diff --git a/src/main/java/io/supertokens/Main.java b/src/main/java/io/supertokens/Main.java index 2998efb7b..fc46dd5d5 100644 --- a/src/main/java/io/supertokens/Main.java +++ b/src/main/java/io/supertokens/Main.java @@ -20,6 +20,7 @@ import io.supertokens.config.Config; import io.supertokens.config.CoreConfig; import io.supertokens.cronjobs.Cronjobs; +import io.supertokens.cronjobs.bulkimport.ProcessBulkImportUsers; import io.supertokens.cronjobs.deleteExpiredAccessTokenSigningKeys.DeleteExpiredAccessTokenSigningKeys; import io.supertokens.cronjobs.deleteExpiredDashboardSessions.DeleteExpiredDashboardSessions; import io.supertokens.cronjobs.deleteExpiredEmailVerificationTokens.DeleteExpiredEmailVerificationTokens; @@ -254,6 +255,9 @@ private void init() throws IOException, StorageQueryException { // starts DeleteExpiredAccessTokenSigningKeys cronjob if the access token signing keys can change Cronjobs.addCronjob(this, DeleteExpiredAccessTokenSigningKeys.init(this, uniqueUserPoolIdsTenants)); + // starts ProcessBulkImportUsers cronjob + Cronjobs.addCronjob(this, ProcessBulkImportUsers.init(this, uniqueUserPoolIdsTenants)); + // this is to ensure tenantInfos are in sync for the new cron job as well MultitenancyHelper.getInstance(this).refreshCronjobs(); diff --git a/src/main/java/io/supertokens/authRecipe/AuthRecipe.java b/src/main/java/io/supertokens/authRecipe/AuthRecipe.java index 1c3a2621a..dbdb6d6db 100644 --- a/src/main/java/io/supertokens/authRecipe/AuthRecipe.java +++ b/src/main/java/io/supertokens/authRecipe/AuthRecipe.java @@ -336,41 +336,84 @@ public static LinkAccountsResult linkAccounts(Main main, AppIdentifierWithStorag AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException, InputUserIdIsNotAPrimaryUserException, UnknownUserIdException, TenantOrAppNotFoundException, FeatureNotEnabledException { + AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) appIdentifierWithStorage.getAuthRecipeStorage(); + try { + return storage.startTransaction(con -> { + return linkAccountsInternal(main, con, appIdentifierWithStorage, _recipeUserId, _primaryUserId); + }); + } catch (StorageTransactionLogicException e) { + return handleLinkAccountsExceptions(e); + } + } + + public static LinkAccountsResult bulkImport_linkAccounts_Transaction(Main main, TransactionConnection con, + AppIdentifierWithStorage appIdentifierWithStorage, + String _recipeUserId, String _primaryUserId) + throws StorageQueryException, + AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, + RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException, InputUserIdIsNotAPrimaryUserException, + UnknownUserIdException, TenantOrAppNotFoundException, FeatureNotEnabledException { + try { + return linkAccountsInternal(main, con, appIdentifierWithStorage, _recipeUserId, _primaryUserId); + } catch (StorageTransactionLogicException e) { + return handleLinkAccountsExceptions(e); + } + } - if (Arrays.stream(FeatureFlag.getInstance(main, appIdentifierWithStorage).getEnabledFeatures()) - .noneMatch(t -> (t == EE_FEATURES.ACCOUNT_LINKING || t == EE_FEATURES.MFA))) { - throw new FeatureNotEnabledException( - "Account linking feature is not enabled for this app. Please contact support to enable it."); + private static LinkAccountsResult handleLinkAccountsExceptions(StorageTransactionLogicException e) + throws StorageQueryException, + AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, + RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException, + InputUserIdIsNotAPrimaryUserException, UnknownUserIdException, TenantOrAppNotFoundException, + FeatureNotEnabledException { + if (e.actualException instanceof UnknownUserIdException) { + throw (UnknownUserIdException) e.actualException; + } else if (e.actualException instanceof InputUserIdIsNotAPrimaryUserException) { + throw (InputUserIdIsNotAPrimaryUserException) e.actualException; + } else if (e.actualException instanceof RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException) { + throw (RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException) e.actualException; + } else if (e.actualException instanceof AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) { + throw (AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) e.actualException; + } else if (e.actualException instanceof TenantOrAppNotFoundException) { + throw (TenantOrAppNotFoundException) e.actualException; + } else if (e.actualException instanceof FeatureNotEnabledException) { + throw (FeatureNotEnabledException) e.actualException; } + throw new StorageQueryException(e); + } + + private static LinkAccountsResult linkAccountsInternal(Main main, TransactionConnection con, + AppIdentifierWithStorage appIdentifierWithStorage, + String _recipeUserId, String _primaryUserId) + throws StorageTransactionLogicException { - AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) appIdentifierWithStorage.getAuthRecipeStorage(); try { - LinkAccountsResult result = storage.startTransaction(con -> { + if (Arrays.stream(FeatureFlag.getInstance(main, appIdentifierWithStorage).getEnabledFeatures()) + .noneMatch(t -> (t == EE_FEATURES.ACCOUNT_LINKING || t == EE_FEATURES.MFA))) { + throw new FeatureNotEnabledException( + "Account linking feature is not enabled for this app. Please contact support to enable it."); + } - try { - CanLinkAccountsResult canLinkAccounts = canLinkAccountsHelper(con, appIdentifierWithStorage, - _recipeUserId, _primaryUserId); + AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) appIdentifierWithStorage.getAuthRecipeStorage(); - if (canLinkAccounts.alreadyLinked) { - return new LinkAccountsResult(getUserById(appIdentifierWithStorage, canLinkAccounts.primaryUserId), true); - } - // now we can link accounts in the db. - storage.linkAccounts_Transaction(appIdentifierWithStorage, con, canLinkAccounts.recipeUserId, - canLinkAccounts.primaryUserId); + CanLinkAccountsResult canLinkAccounts = canLinkAccountsHelper(con, appIdentifierWithStorage, + _recipeUserId, _primaryUserId); - storage.commitTransaction(con); + if (canLinkAccounts.alreadyLinked) { + return new LinkAccountsResult(getUserById(appIdentifierWithStorage, canLinkAccounts.primaryUserId), + true); + } + // now we can link accounts in the db. + storage.linkAccounts_Transaction(appIdentifierWithStorage, con, canLinkAccounts.recipeUserId, + canLinkAccounts.primaryUserId); - return new LinkAccountsResult(getUserById(appIdentifierWithStorage, canLinkAccounts.primaryUserId), false); - } catch (UnknownUserIdException | InputUserIdIsNotAPrimaryUserException | - RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException | - AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException e) { - throw new StorageTransactionLogicException(e); - } - }); + + LinkAccountsResult result = new LinkAccountsResult( + getUserById(appIdentifierWithStorage, canLinkAccounts.primaryUserId), false); if (!result.wasAlreadyLinked) { - io.supertokens.pluginInterface.useridmapping.UserIdMapping mappingResult = - io.supertokens.useridmapping.UserIdMapping.getUserIdMapping( + io.supertokens.pluginInterface.useridmapping.UserIdMapping mappingResult = io.supertokens.useridmapping.UserIdMapping + .getUserIdMapping( appIdentifierWithStorage, _recipeUserId, UserIdType.SUPERTOKENS); // finally, we revoke all sessions of the recipeUser Id cause their user ID has changed. @@ -379,17 +422,11 @@ public static LinkAccountsResult linkAccounts(Main main, AppIdentifierWithStorag } return result; - } catch (StorageTransactionLogicException e) { - if (e.actualException instanceof UnknownUserIdException) { - throw (UnknownUserIdException) e.actualException; - } else if (e.actualException instanceof InputUserIdIsNotAPrimaryUserException) { - throw (InputUserIdIsNotAPrimaryUserException) e.actualException; - } else if (e.actualException instanceof RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException) { - throw (RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException) e.actualException; - } else if (e.actualException instanceof AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) { - throw (AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) e.actualException; - } - throw new StorageQueryException(e); + } catch (FeatureNotEnabledException | TenantOrAppNotFoundException | StorageQueryException + | UnknownUserIdException | InputUserIdIsNotAPrimaryUserException + | RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException + | AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException e) { + throw new StorageTransactionLogicException(e); } } @@ -543,43 +580,76 @@ public static CreatePrimaryUserResult createPrimaryUser(Main main, RecipeUserIdAlreadyLinkedWithPrimaryUserIdException, UnknownUserIdException, TenantOrAppNotFoundException, FeatureNotEnabledException { - if (Arrays.stream(FeatureFlag.getInstance(main, appIdentifierWithStorage).getEnabledFeatures()) - .noneMatch(t -> (t == EE_FEATURES.ACCOUNT_LINKING || t == EE_FEATURES.MFA))) { - throw new FeatureNotEnabledException( - "Account linking feature is not enabled for this app. Please contact support to enable it."); - } - AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) appIdentifierWithStorage.getAuthRecipeStorage(); try { return storage.startTransaction(con -> { + return createPrimaryUserInternal(main, con, appIdentifierWithStorage, recipeUserId); + }); + } catch (StorageTransactionLogicException e) { + return handleCreatePrimaryUserExceptions(e); + } + } - try { - CreatePrimaryUserResult result = canCreatePrimaryUserHelper(con, appIdentifierWithStorage, - recipeUserId); - if (result.wasAlreadyAPrimaryUser) { - return result; - } - storage.makePrimaryUser_Transaction(appIdentifierWithStorage, con, result.user.getSupertokensUserId()); + public static CreatePrimaryUserResult bulkImport_createPrimaryUser_Transaction(Main main, TransactionConnection con, + AppIdentifierWithStorage appIdentifierWithStorage, + String recipeUserId) + throws StorageQueryException, AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, + RecipeUserIdAlreadyLinkedWithPrimaryUserIdException, UnknownUserIdException, TenantOrAppNotFoundException, + FeatureNotEnabledException { + try { + return createPrimaryUserInternal(main, con, appIdentifierWithStorage, recipeUserId); + } catch (StorageTransactionLogicException e) { + return handleCreatePrimaryUserExceptions(e); + } + } - storage.commitTransaction(con); + public static CreatePrimaryUserResult handleCreatePrimaryUserExceptions(StorageTransactionLogicException e) + throws StorageQueryException, AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException, + RecipeUserIdAlreadyLinkedWithPrimaryUserIdException, UnknownUserIdException, TenantOrAppNotFoundException, + FeatureNotEnabledException { + if (e.actualException instanceof UnknownUserIdException) { + throw (UnknownUserIdException) e.actualException; + } else if (e.actualException instanceof RecipeUserIdAlreadyLinkedWithPrimaryUserIdException) { + throw (RecipeUserIdAlreadyLinkedWithPrimaryUserIdException) e.actualException; + } else if (e.actualException instanceof AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) { + throw (AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) e.actualException; + } else if (e.actualException instanceof TenantOrAppNotFoundException) { + throw (TenantOrAppNotFoundException) e.actualException; + } else if (e.actualException instanceof FeatureNotEnabledException) { + throw (FeatureNotEnabledException) e.actualException; + } + throw new StorageQueryException(e); + } - result.user.isPrimaryUser = true; + public static CreatePrimaryUserResult createPrimaryUserInternal(Main main, TransactionConnection con, + AppIdentifierWithStorage appIdentifierWithStorage, + String recipeUserId) + throws StorageTransactionLogicException { + try { + if (Arrays.stream(FeatureFlag.getInstance(main, appIdentifierWithStorage).getEnabledFeatures()) + .noneMatch(t -> (t == EE_FEATURES.ACCOUNT_LINKING || t == EE_FEATURES.MFA))) { + throw new FeatureNotEnabledException( + "Account linking feature is not enabled for this app. Please contact support to enable it."); + } - return result; - } catch (UnknownUserIdException | RecipeUserIdAlreadyLinkedWithPrimaryUserIdException | - AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException e) { - throw new StorageTransactionLogicException(e); - } - }); - } catch (StorageTransactionLogicException e) { - if (e.actualException instanceof UnknownUserIdException) { - throw (UnknownUserIdException) e.actualException; - } else if (e.actualException instanceof RecipeUserIdAlreadyLinkedWithPrimaryUserIdException) { - throw (RecipeUserIdAlreadyLinkedWithPrimaryUserIdException) e.actualException; - } else if (e.actualException instanceof AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) { - throw (AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException) e.actualException; + AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) appIdentifierWithStorage.getAuthRecipeStorage(); + + CreatePrimaryUserResult result = canCreatePrimaryUserHelper(con, appIdentifierWithStorage, + recipeUserId); + if (result.wasAlreadyAPrimaryUser) { + return result; } - throw new StorageQueryException(e); + storage.makePrimaryUser_Transaction(appIdentifierWithStorage, con, result.user.getSupertokensUserId()); + + + result.user.isPrimaryUser = true; + + return result; + + } catch (TenantOrAppNotFoundException | FeatureNotEnabledException | StorageQueryException + | UnknownUserIdException | RecipeUserIdAlreadyLinkedWithPrimaryUserIdException + | AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException e) { + throw new StorageTransactionLogicException(e); } } diff --git a/src/main/java/io/supertokens/bulkimport/BulkImport.java b/src/main/java/io/supertokens/bulkimport/BulkImport.java index 329fc7e63..e15e9eb5a 100644 --- a/src/main/java/io/supertokens/bulkimport/BulkImport.java +++ b/src/main/java/io/supertokens/bulkimport/BulkImport.java @@ -35,6 +35,8 @@ public class BulkImport { public static final int GET_USERS_PAGINATION_LIMIT = 500; public static final int GET_USERS_DEFAULT_LIMIT = 100; public static final int DELETE_USERS_LIMIT = 500; + public static final int PROCESS_USERS_BATCH_SIZE = 1000; + public static final int PROCESS_USERS_INTERVAL = 60; // 60 seconds public static void addUsers(AppIdentifierWithStorage appIdentifierWithStorage, List users) throws StorageQueryException, TenantOrAppNotFoundException { diff --git a/src/main/java/io/supertokens/bulkimport/BulkImportUserUtils.java b/src/main/java/io/supertokens/bulkimport/BulkImportUserUtils.java index c006c3640..15db771ab 100644 --- a/src/main/java/io/supertokens/bulkimport/BulkImportUserUtils.java +++ b/src/main/java/io/supertokens/bulkimport/BulkImportUserUtils.java @@ -34,14 +34,17 @@ import io.supertokens.featureflag.EE_FEATURES; import io.supertokens.featureflag.FeatureFlag; import io.supertokens.multitenancy.Multitenancy; +import io.supertokens.pluginInterface.Storage; import io.supertokens.pluginInterface.bulkimport.BulkImportUser; import io.supertokens.pluginInterface.bulkimport.BulkImportUser.LoginMethod; +import io.supertokens.pluginInterface.bulkimport.BulkImportUser.UserRole; import io.supertokens.pluginInterface.bulkimport.BulkImportUser.TotpDevice; import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.multitenancy.AppIdentifier; import io.supertokens.pluginInterface.multitenancy.TenantConfig; import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.storageLayer.StorageLayer; import io.supertokens.utils.Utils; import io.supertokens.utils.JsonValidatorUtils.ValueType; @@ -49,7 +52,7 @@ import static io.supertokens.utils.JsonValidatorUtils.validateJsonFieldType; public class BulkImportUserUtils { - public static BulkImportUser createBulkImportUserFromJSON(Main main, AppIdentifier appIdentifier, JsonObject userData, String id, String[] allUserRoles) + public static BulkImportUser createBulkImportUserFromJSON(Main main, AppIdentifier appIdentifier, JsonObject userData, String id, String[] allUserRoles, Set allExternalUserIds) throws InvalidBulkImportDataException, StorageQueryException, TenantOrAppNotFoundException { List errors = new ArrayList<>(); @@ -57,11 +60,13 @@ public static BulkImportUser createBulkImportUserFromJSON(Main main, AppIdentifi errors, "."); JsonObject userMetadata = parseAndValidateFieldType(userData, "userMetadata", ValueType.OBJECT, false, JsonObject.class, errors, "."); - List userRoles = getParsedUserRoles(userData, allUserRoles, errors); + List userRoles = getParsedUserRoles(main, appIdentifier, userData, allUserRoles, errors); List totpDevices = getParsedTotpDevices(userData, errors); List loginMethods = getParsedLoginMethods(main, appIdentifier, userData, errors); - externalUserId = validateAndNormaliseExternalUserId(externalUserId, errors); + externalUserId = validateAndNormaliseExternalUserId(externalUserId, allExternalUserIds, errors); + + validateTenantIdsForRoleAndLoginMethods(main, appIdentifier, userRoles, loginMethods, errors); if (!errors.isEmpty()) { throw new InvalidBulkImportDataException(errors); @@ -69,23 +74,34 @@ public static BulkImportUser createBulkImportUserFromJSON(Main main, AppIdentifi return new BulkImportUser(id, externalUserId, userMetadata, userRoles, totpDevices, loginMethods); } - private static List getParsedUserRoles(JsonObject userData, String[] allUserRoles, List errors) { - JsonArray jsonUserRoles = parseAndValidateFieldType(userData, "userRoles", ValueType.ARRAY_OF_STRING, - false, - JsonArray.class, errors, "."); + private static List getParsedUserRoles(Main main, AppIdentifier appIdentifier, JsonObject userData, String[] allUserRoles, List errors) throws StorageQueryException, TenantOrAppNotFoundException { + JsonArray jsonUserRoles = parseAndValidateFieldType(userData, "userRoles", ValueType.ARRAY_OF_OBJECT, false, JsonArray.class, errors, "."); if (jsonUserRoles == null) { return null; } - // We already know that the jsonUserRoles is an array of non-empty strings, we will normalise each role now - List userRoles = new ArrayList<>(); - jsonUserRoles.forEach(role -> userRoles.add(validateAndNormaliseUserRole(role.getAsString(), allUserRoles, errors))); + List userRoles = new ArrayList<>(); + + for (JsonElement jsonUserRoleEl : jsonUserRoles) { + JsonObject jsonUserRole = jsonUserRoleEl.getAsJsonObject(); + + String role = parseAndValidateFieldType(jsonUserRole, "role", ValueType.STRING, true, String.class, errors, " for a user role."); + JsonArray jsonTenantIds = parseAndValidateFieldType(jsonUserRole, "tenantIds", ValueType.ARRAY_OF_STRING, true, JsonArray.class, errors, " for a user role."); + + role = validateAndNormaliseUserRole(role, allUserRoles, errors); + List normalisedTenantIds = validateAndNormaliseTenantIds(main, appIdentifier, jsonTenantIds, errors, " for a user role."); + + if (role != null && normalisedTenantIds != null) { + userRoles.add(new UserRole(role, normalisedTenantIds)); + } + } return userRoles; } private static List getParsedTotpDevices(JsonObject userData, List errors) { JsonArray jsonTotpDevices = parseAndValidateFieldType(userData, "totpDevices", ValueType.ARRAY_OF_OBJECT, false, JsonArray.class, errors, "."); + if (jsonTotpDevices == null) { return null; } @@ -132,13 +148,13 @@ private static List getParsedLoginMethods(Main main, AppIdentifier JsonObject jsonLoginMethodObj = jsonLoginMethod.getAsJsonObject(); String recipeId = parseAndValidateFieldType(jsonLoginMethodObj, "recipeId", ValueType.STRING, true, String.class, errors, " for a loginMethod."); - String tenantId = parseAndValidateFieldType(jsonLoginMethodObj, "tenantId", ValueType.STRING, false, String.class, errors, " for a loginMethod."); + JsonArray tenantIds = parseAndValidateFieldType(jsonLoginMethodObj, "tenantIds", ValueType.ARRAY_OF_STRING, false, JsonArray.class, errors, " for a loginMethod."); Boolean isVerified = parseAndValidateFieldType(jsonLoginMethodObj, "isVerified", ValueType.BOOLEAN, false, Boolean.class, errors, " for a loginMethod."); Boolean isPrimary = parseAndValidateFieldType(jsonLoginMethodObj, "isPrimary", ValueType.BOOLEAN, false, Boolean.class, errors, " for a loginMethod."); Long timeJoined = parseAndValidateFieldType(jsonLoginMethodObj, "timeJoinedInMSSinceEpoch", ValueType.LONG, false, Long.class, errors, " for a loginMethod"); recipeId = validateAndNormaliseRecipeId(recipeId, errors); - tenantId= validateAndNormaliseTenantId(main, appIdentifier, tenantId, recipeId, errors); + List normalisedTenantIds = validateAndNormaliseTenantIds(main, appIdentifier, tenantIds, errors, " for " + recipeId + " recipe."); isPrimary = validateAndNormaliseIsPrimary(isPrimary); isVerified = validateAndNormaliseIsVerified(isVerified); @@ -154,7 +170,7 @@ private static List getParsedLoginMethods(Main main, AppIdentifier hashingAlgorithm = normalisedHashingAlgorithm != null ? normalisedHashingAlgorithm.toString() : hashingAlgorithm; passwordHash = validateAndNormalisePasswordHash(main, appIdentifier, normalisedHashingAlgorithm, passwordHash, errors); - loginMethods.add(new LoginMethod(tenantId, recipeId, isVerified, isPrimary, timeJoinedInMSSinceEpoch, email, passwordHash, hashingAlgorithm, null, null, null)); + loginMethods.add(new LoginMethod(normalisedTenantIds, recipeId, isVerified, isPrimary, timeJoinedInMSSinceEpoch, email, passwordHash, hashingAlgorithm, null, null, null)); } else if ("thirdparty".equals(recipeId)) { String email = parseAndValidateFieldType(jsonLoginMethodObj, "email", ValueType.STRING, true, String.class, errors, " for a thirdparty recipe."); String thirdPartyId = parseAndValidateFieldType(jsonLoginMethodObj, "thirdPartyId", ValueType.STRING, true, String.class, errors, " for a thirdparty recipe."); @@ -164,7 +180,7 @@ private static List getParsedLoginMethods(Main main, AppIdentifier thirdPartyId = validateAndNormaliseThirdPartyId(thirdPartyId, errors); thirdPartyUserId = validateAndNormaliseThirdPartyUserId(thirdPartyUserId, errors); - loginMethods.add(new LoginMethod(tenantId, recipeId, isVerified, isPrimary, timeJoinedInMSSinceEpoch, email, null, null, thirdPartyId, thirdPartyUserId, null)); + loginMethods.add(new LoginMethod(normalisedTenantIds, recipeId, isVerified, isPrimary, timeJoinedInMSSinceEpoch, email, null, null, thirdPartyId, thirdPartyUserId, null)); } else if ("passwordless".equals(recipeId)) { String email = parseAndValidateFieldType(jsonLoginMethodObj, "email", ValueType.STRING, false, String.class, errors, " for a passwordless recipe."); String phoneNumber = parseAndValidateFieldType(jsonLoginMethodObj, "phoneNumber", ValueType.STRING, false, String.class, errors, " for a passwordless recipe."); @@ -172,13 +188,17 @@ private static List getParsedLoginMethods(Main main, AppIdentifier email = validateAndNormaliseEmail(email, errors); phoneNumber = validateAndNormalisePhoneNumber(phoneNumber, errors); - loginMethods.add(new LoginMethod(tenantId, recipeId, isVerified, isPrimary, timeJoinedInMSSinceEpoch, email, null, null, null, null, phoneNumber)); + if (email == null && phoneNumber == null) { + errors.add("Either email or phoneNumber is required for a passwordless recipe."); + } + + loginMethods.add(new LoginMethod(normalisedTenantIds, recipeId, isVerified, isPrimary, timeJoinedInMSSinceEpoch, email, null, null, null, null, phoneNumber)); } } return loginMethods; } - private static String validateAndNormaliseExternalUserId(String externalUserId, List errors) { + private static String validateAndNormaliseExternalUserId(String externalUserId, Set allExternalUserIds, List errors) { if (externalUserId == null ) { return null; } @@ -187,6 +207,10 @@ private static String validateAndNormaliseExternalUserId(String externalUserId, errors.add("externalUserId " + externalUserId + " is too long. Max length is 128."); } + if (!allExternalUserIds.add(externalUserId)) { + errors.add("externalUserId " + externalUserId + " is not unique. It is already used by another user."); + } + // We just trim the externalUserId as per the UpdateExternalUserIdInfoAPI.java return externalUserId.trim(); } @@ -287,7 +311,26 @@ private static String validateAndNormaliseRecipeId(String recipeId, List return recipeId; } - private static String validateAndNormaliseTenantId(Main main, AppIdentifier appIdentifier, String tenantId, String recipeId, List errors) + private static List validateAndNormaliseTenantIds(Main main, AppIdentifier appIdentifier, JsonArray tenantIds, List errors, String errorSuffix) + throws StorageQueryException, TenantOrAppNotFoundException { + if (tenantIds == null) { + return List.of(TenantIdentifier.DEFAULT_TENANT_ID); // Default to DEFAULT_TENANT_ID ("public") + } + + List normalisedTenantIds = new ArrayList<>(); + + for (JsonElement tenantIdEl : tenantIds) { + String tenantId = tenantIdEl.getAsString(); + tenantId = validateAndNormaliseTenantId(main, appIdentifier, tenantId, errors, errorSuffix); + + if (tenantId != null) { + normalisedTenantIds.add(tenantId); + } + } + return normalisedTenantIds; + } + + private static String validateAndNormaliseTenantId(Main main, AppIdentifier appIdentifier, String tenantId, List errors, String errorSuffix) throws StorageQueryException, TenantOrAppNotFoundException { if (tenantId == null || tenantId.equals(TenantIdentifier.DEFAULT_TENANT_ID)) { return tenantId; @@ -296,7 +339,7 @@ private static String validateAndNormaliseTenantId(Main main, AppIdentifier appI if (Arrays.stream(FeatureFlag.getInstance(main, appIdentifier).getEnabledFeatures()) .noneMatch(t -> t == EE_FEATURES.MULTI_TENANCY)) { errors.add("Multitenancy must be enabled before importing users to a different tenant."); - return tenantId; + return null; } // We make the tenantId lowercase while parsing from the request in WebserverAPI.java @@ -307,7 +350,8 @@ private static String validateAndNormaliseTenantId(Main main, AppIdentifier appI .forEach(tenantConfig -> validTenantIds.add(tenantConfig.tenantIdentifier.getTenantId())); if (!validTenantIds.contains(normalisedTenantId)) { - errors.add("Invalid tenantId: " + tenantId + " for " + recipeId + " recipe."); + errors.add("Invalid tenantId: " + tenantId + errorSuffix); + return null; } return normalisedTenantId; } @@ -426,4 +470,36 @@ private static String validateAndNormalisePhoneNumber(String phoneNumber, List userRoles, List loginMethods, List errors) throws TenantOrAppNotFoundException { + if (loginMethods == null) { + return; + } + + // First validate that tenantIds provided for userRoles also exist in the loginMethods + if (userRoles != null) { + for (UserRole userRole : userRoles) { + for (String tenantId : userRole.tenantIds) { + if (!tenantId.equals(TenantIdentifier.DEFAULT_TENANT_ID) && loginMethods.stream().noneMatch(loginMethod -> loginMethod.tenantIds.contains(tenantId))) { + errors.add("TenantId " + tenantId + " for a user role does not exist in loginMethods."); + } + } + } + } + + // Now validate that all the tenants share the same storage + String commonTenantUserPoolId = null; + for (LoginMethod loginMethod : loginMethods) { + for (String tenantId : loginMethod.tenantIds) { + TenantIdentifier tenantIdentifier = new TenantIdentifier(appIdentifier.getConnectionUriDomain(), appIdentifier.getAppId(), tenantId); + Storage storage = StorageLayer.getStorage(tenantIdentifier, main); + String tenantUserPoolId = storage.getUserPoolId(); + + if (commonTenantUserPoolId == null) { + commonTenantUserPoolId = tenantUserPoolId; + } else if (!commonTenantUserPoolId.equals(tenantUserPoolId)) { + errors.add("All tenants for a user must share the same storage."); + } + } + } + } } diff --git a/src/main/java/io/supertokens/cronjobs/bulkimport/ProcessBulkImportUsers.java b/src/main/java/io/supertokens/cronjobs/bulkimport/ProcessBulkImportUsers.java new file mode 100644 index 000000000..7afbcec47 --- /dev/null +++ b/src/main/java/io/supertokens/cronjobs/bulkimport/ProcessBulkImportUsers.java @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * 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 io.supertokens.cronjobs.bulkimport; + +import java.util.List; + +import io.supertokens.Main; +import io.supertokens.authRecipe.AuthRecipe; +import io.supertokens.authRecipe.exception.AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException; +import io.supertokens.authRecipe.exception.InputUserIdIsNotAPrimaryUserException; +import io.supertokens.authRecipe.exception.RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException; +import io.supertokens.authRecipe.exception.RecipeUserIdAlreadyLinkedWithPrimaryUserIdException; +import io.supertokens.bulkimport.BulkImport; +import io.supertokens.cronjobs.CronTask; +import io.supertokens.cronjobs.CronTaskTest; +import io.supertokens.emailpassword.EmailPassword; +import io.supertokens.emailpassword.EmailPassword.ImportUserResponse; +import io.supertokens.featureflag.exceptions.FeatureNotEnabledException; +import io.supertokens.multitenancy.Multitenancy; +import io.supertokens.multitenancy.exception.AnotherPrimaryUserWithEmailAlreadyExistsException; +import io.supertokens.multitenancy.exception.AnotherPrimaryUserWithPhoneNumberAlreadyExistsException; +import io.supertokens.multitenancy.exception.AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException; +import io.supertokens.passwordless.Passwordless; +import io.supertokens.passwordless.exceptions.RestartFlowException; +import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.authRecipe.AuthRecipeUserInfo; +import io.supertokens.pluginInterface.bulkimport.BulkImportUser; +import io.supertokens.pluginInterface.bulkimport.BulkImportStorage.BULK_IMPORT_USER_STATUS; +import io.supertokens.pluginInterface.bulkimport.BulkImportUser.LoginMethod; +import io.supertokens.pluginInterface.bulkimport.BulkImportUser.UserRole; +import io.supertokens.pluginInterface.bulkimport.sqlStorage.BulkImportSQLStorage; +import io.supertokens.pluginInterface.emailpassword.exceptions.DuplicateEmailException; +import io.supertokens.pluginInterface.emailpassword.exceptions.UnknownUserIdException; +import io.supertokens.pluginInterface.emailverification.sqlStorage.EmailVerificationSQLStorage; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; +import io.supertokens.pluginInterface.multitenancy.AppIdentifier; +import io.supertokens.pluginInterface.multitenancy.AppIdentifierWithStorage; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifierWithStorage; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.passwordless.exception.DuplicatePhoneNumberException; +import io.supertokens.pluginInterface.sqlStorage.SQLStorage; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; +import io.supertokens.pluginInterface.thirdparty.exception.DuplicateThirdPartyUserException; +import io.supertokens.pluginInterface.useridmapping.exception.UnknownSuperTokensUserIdException; +import io.supertokens.pluginInterface.useridmapping.exception.UserIdMappingAlreadyExistsException; +import io.supertokens.pluginInterface.userroles.exception.UnknownRoleException; +import io.supertokens.storageLayer.StorageLayer; +import io.supertokens.thirdparty.ThirdParty; +import io.supertokens.thirdparty.ThirdParty.SignInUpResponse; +import io.supertokens.useridmapping.UserIdMapping; +import io.supertokens.usermetadata.UserMetadata; +import io.supertokens.userroles.UserRoles; +import jakarta.servlet.ServletException; + +public class ProcessBulkImportUsers extends CronTask { + + public static final String RESOURCE_KEY = "io.supertokens.ee.cronjobs.ProcessBulkImportUsers"; + + private ProcessBulkImportUsers(Main main, List> tenantsInfo) { + super("ProcessBulkImportUsers", main, tenantsInfo, true); + } + + public static ProcessBulkImportUsers init(Main main, List> tenantsInfo) { + return (ProcessBulkImportUsers) main.getResourceDistributor() + .setResource(new TenantIdentifier(null, null, null), RESOURCE_KEY, + new ProcessBulkImportUsers(main, tenantsInfo)); + } + + @Override + protected void doTaskPerApp(AppIdentifier app) throws TenantOrAppNotFoundException, StorageQueryException { + if (StorageLayer.getBaseStorage(main).getType() != STORAGE_TYPE.SQL) { + return; + } + + BulkImportSQLStorage bulkImportSQLStorage = (BulkImportSQLStorage) StorageLayer + .getStorage(app.getAsPublicTenantIdentifier(), main); + + AppIdentifierWithStorage appIdentifierWithStorage = new AppIdentifierWithStorage( + app.getConnectionUriDomain(), app.getAppId(), + StorageLayer.getStorage(app.getAsPublicTenantIdentifier(), main)); + + List users = bulkImportSQLStorage.getBulkImportUsersForProcessing(appIdentifierWithStorage, + BulkImport.PROCESS_USERS_BATCH_SIZE); + + for (BulkImportUser user : users) { + processUser(appIdentifierWithStorage, user); + } + } + + @Override + public int getIntervalTimeSeconds() { + if (Main.isTesting) { + Integer interval = CronTaskTest.getInstance(main).getIntervalInSeconds(RESOURCE_KEY); + if (interval != null) { + return interval; + } + } + return BulkImport.PROCESS_USERS_INTERVAL; + } + + @Override + public int getInitialWaitTimeSeconds() { + if (Main.isTesting) { + Integer interval = CronTaskTest.getInstance(main).getIntervalInSeconds(RESOURCE_KEY); + if (interval != null) { + return interval; + } + } + return 0; + } + + private void processUser(AppIdentifierWithStorage appIdentifierWithStorage, BulkImportUser user) + throws TenantOrAppNotFoundException, StorageQueryException { + // Since all the tenants of a user must share the storage, we will just use the + // storage of the first tenantId of the first loginMethod + SQLStorage userStorage = (SQLStorage) StorageLayer + .getStorage(new TenantIdentifier(appIdentifierWithStorage.getConnectionUriDomain(), + appIdentifierWithStorage.getAppId(), user.loginMethods.get(0).tenantIds.get(0)), main); + + BulkImportSQLStorage bulkImportSQLStorage = (BulkImportSQLStorage) userStorage; + + LoginMethod primaryLM = getPrimaryLoginMethod(user); + + try { + userStorage.startTransaction(con -> { + for (LoginMethod lm : user.loginMethods) { + processUserLoginMethod(appIdentifierWithStorage, userStorage, con, lm); + } + + createPrimaryUserAndLinkAccounts(main, con, appIdentifierWithStorage, user, primaryLM); + createUserIdMapping(appIdentifierWithStorage, con, user, primaryLM); + verifyEmailForAllLoginMethods(appIdentifierWithStorage, con, userStorage, user.loginMethods); + createUserMetadata(appIdentifierWithStorage, con, user, primaryLM); + createUserRoles(appIdentifierWithStorage, con, user); + + bulkImportSQLStorage.deleteBulkImportUser_Transaction(appIdentifierWithStorage, con, user.id); + + return null; + }); + + // We are intentionally catching all exceptions here because we want to mark the user as failed and process the next user + } catch (Exception e) { + handleProcessUserExceptions(appIdentifierWithStorage, user, bulkImportSQLStorage, e); + } + } + + private void handleProcessUserExceptions(AppIdentifierWithStorage appIdentifierWithStorage, BulkImportUser user, + BulkImportSQLStorage bulkImportSQLStorage, Exception e) + throws StorageQueryException { + + // Java doesn't allow us to reassign local variables inside a lambda expression + // so we have to use an array. + String[] errorMessage = { e.getMessage() }; + + if (e instanceof StorageTransactionLogicException) { + StorageTransactionLogicException exception = (StorageTransactionLogicException) e; + errorMessage[0] = exception.actualException.getMessage(); + } + + String[] userId = { user.id }; + + try { + bulkImportSQLStorage.startTransaction(con -> { + bulkImportSQLStorage.updateBulkImportUserStatus_Transaction(appIdentifierWithStorage, con, userId, + BULK_IMPORT_USER_STATUS.FAILED, errorMessage[0]); + return null; + }); + } catch (StorageTransactionLogicException e1) { + throw new StorageQueryException(e1.actualException); + } + } + + private void processUserLoginMethod(AppIdentifierWithStorage appIdentifierWithStorage, SQLStorage userStorage, + TransactionConnection con, + LoginMethod lm) throws StorageTransactionLogicException { + String firstTenant = lm.tenantIds.get(0); + + TenantIdentifierWithStorage tenantIdentifierWithStorage = new TenantIdentifierWithStorage( + appIdentifierWithStorage.getConnectionUriDomain(), appIdentifierWithStorage.getAppId(), firstTenant, + userStorage); + + if (lm.recipeId.equals("emailpassword")) { + processEmailPasswordLoginMethod(tenantIdentifierWithStorage, con, lm); + } else if (lm.recipeId.equals("thirdparty")) { + processThirdPartyLoginMethod(tenantIdentifierWithStorage, con, lm); + } else if (lm.recipeId.equals("passwordless")) { + processPasswordlessLoginMethod(tenantIdentifierWithStorage, con, lm); + } else { + throw new StorageTransactionLogicException( + new IllegalArgumentException("Unknown recipeId " + lm.recipeId + " for loginMethod ")); + } + + associateUserToTenants(main, con, appIdentifierWithStorage, lm, firstTenant, userStorage); + } + + private void processEmailPasswordLoginMethod(TenantIdentifierWithStorage tenantIdentifierWithStorage, + TransactionConnection con, + LoginMethod lm) throws StorageTransactionLogicException { + try { + ImportUserResponse userInfo = EmailPassword.bulkImport_createUserWithPasswordHash_Transaction(con, + tenantIdentifierWithStorage, lm.email, lm.passwordHash, lm.timeJoinedInMSSinceEpoch); + + lm.superTokensOrExternalUserId = userInfo.user.getSupertokensUserId(); + } catch (StorageQueryException | TenantOrAppNotFoundException e) { + throw new StorageTransactionLogicException(e); + } catch (DuplicateEmailException e) { + throw new StorageTransactionLogicException( + new Exception("A user with email " + lm.email + " already exists")); + } + } + + private void processThirdPartyLoginMethod(TenantIdentifierWithStorage tenantIdentifierWithStorage, + TransactionConnection con, + LoginMethod lm) throws StorageTransactionLogicException { + try { + SignInUpResponse userInfo = ThirdParty.bulkImport_createThirdPartyUser_Transaction(con, + tenantIdentifierWithStorage, lm.thirdPartyId, + lm.thirdPartyUserId, lm.email, lm.timeJoinedInMSSinceEpoch); + + lm.superTokensOrExternalUserId = userInfo.user.getSupertokensUserId(); + } catch (StorageQueryException | TenantOrAppNotFoundException e) { + throw new StorageTransactionLogicException(e); + } catch (DuplicateThirdPartyUserException e) { + throw new StorageTransactionLogicException(new Exception("A user with thirdPartyId " + lm.thirdPartyId + + " and thirdPartyUserId " + lm.thirdPartyUserId + " already exists")); + } + } + + private void processPasswordlessLoginMethod(TenantIdentifierWithStorage tenantIdentifierWithStorage, + TransactionConnection con, + LoginMethod lm) throws StorageTransactionLogicException { + try { + AuthRecipeUserInfo userInfo = Passwordless.bulkImport_createPasswordlessUser_Transaction(con, + tenantIdentifierWithStorage, lm.email, lm.phoneNumber, lm.timeJoinedInMSSinceEpoch); + + lm.superTokensOrExternalUserId = userInfo.getSupertokensUserId(); + } catch (StorageQueryException | TenantOrAppNotFoundException | RestartFlowException e) { + throw new StorageTransactionLogicException(e); + } + } + + private void associateUserToTenants(Main main, TransactionConnection con, + AppIdentifierWithStorage appIdentifierWithStorage, LoginMethod lm, String firstTenant, + SQLStorage userStorage) throws StorageTransactionLogicException { + for (String tenantId : lm.tenantIds) { + try { + if (tenantId.equals(firstTenant)) { + continue; + } + + TenantIdentifierWithStorage tIWithStorage = new TenantIdentifierWithStorage( + appIdentifierWithStorage.getConnectionUriDomain(), appIdentifierWithStorage.getAppId(), + tenantId, userStorage); + Multitenancy.bulkImport_addUserIdToTenant_Transaction(main, con, tIWithStorage, + lm.superTokensOrExternalUserId); + } catch (TenantOrAppNotFoundException | UnknownUserIdException | StorageQueryException + | FeatureNotEnabledException | DuplicateEmailException | DuplicatePhoneNumberException + | DuplicateThirdPartyUserException | AnotherPrimaryUserWithPhoneNumberAlreadyExistsException + | AnotherPrimaryUserWithEmailAlreadyExistsException + | AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException e) { + throw new StorageTransactionLogicException(e); + } + } + } + + private void createPrimaryUserAndLinkAccounts(Main main, TransactionConnection con, + AppIdentifierWithStorage appIdentifierWithStorage, BulkImportUser user, LoginMethod primaryLM) + throws StorageTransactionLogicException { + if (user.loginMethods.size() == 1) { + return; + } + + try { + AuthRecipe.bulkImport_createPrimaryUser_Transaction(main, con, appIdentifierWithStorage, + primaryLM.superTokensOrExternalUserId); + } catch (TenantOrAppNotFoundException | FeatureNotEnabledException | StorageQueryException e) { + throw new StorageTransactionLogicException(e); + } catch (UnknownUserIdException e) { + throw new StorageTransactionLogicException(new Exception( + "We tried to create the primary user for the userId " + primaryLM.superTokensOrExternalUserId + + " but it doesn't exist. This should not happen. Please contact support")); + } catch (RecipeUserIdAlreadyLinkedWithPrimaryUserIdException + | AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException e) { + throw new StorageTransactionLogicException( + new Exception(e.getMessage() + " This should not happen. Please contact support.")); + } + + for (LoginMethod lm : user.loginMethods) { + try { + if (lm.superTokensOrExternalUserId.equals(primaryLM.superTokensOrExternalUserId)) { + continue; + } + + AuthRecipe.bulkImport_linkAccounts_Transaction(main, con, appIdentifierWithStorage, + lm.superTokensOrExternalUserId, primaryLM.superTokensOrExternalUserId); + + } catch (TenantOrAppNotFoundException | FeatureNotEnabledException | StorageQueryException e) { + throw new StorageTransactionLogicException(e); + } catch (UnknownUserIdException e) { + throw new StorageTransactionLogicException( + new Exception("We tried to link the userId " + lm.superTokensOrExternalUserId + + " to the primary userId " + primaryLM.superTokensOrExternalUserId + + " but it doesn't exist. This should not happen. Please contact support")); + } catch (InputUserIdIsNotAPrimaryUserException e) { + throw new StorageTransactionLogicException( + new Exception("We tried to link the userId " + lm.superTokensOrExternalUserId + + " to the primary userId " + primaryLM.superTokensOrExternalUserId + + " but it is not a primary user. This should not happen. Please contact support")); + } catch (AccountInfoAlreadyAssociatedWithAnotherPrimaryUserIdException + | RecipeUserIdAlreadyLinkedWithAnotherPrimaryUserIdException e) { + throw new StorageTransactionLogicException( + new Exception(e.getMessage() + " This should not happen. Please contact support.")); + } + } + } + + private void createUserIdMapping(AppIdentifierWithStorage appIdentifierWithStorage, TransactionConnection con, + BulkImportUser user, LoginMethod primaryLM) throws StorageTransactionLogicException { + if (user.externalUserId != null) { + try { + UserIdMapping.bulkImport_createUserIdMapping_Transaction(main, con, + appIdentifierWithStorage, primaryLM.superTokensOrExternalUserId, user.externalUserId, + null, false, true); + + primaryLM.superTokensOrExternalUserId = user.externalUserId; + } catch (StorageQueryException | ServletException | TenantOrAppNotFoundException e) { + throw new StorageTransactionLogicException(e); + } catch (UserIdMappingAlreadyExistsException e) { + throw new StorageTransactionLogicException( + new Exception("A user with externalId " + user.externalUserId + " already exists")); + } catch (UnknownSuperTokensUserIdException e) { + throw new StorageTransactionLogicException( + new Exception("We tried to create the externalUserId mapping for the superTokenUserId " + + primaryLM.superTokensOrExternalUserId + + " but it doesn't exist. This should not happen. Please contact support")); + } + } + } + + private void createUserMetadata(AppIdentifierWithStorage appIdentifierWithStorage, TransactionConnection con, + BulkImportUser user, LoginMethod primaryLM) throws StorageTransactionLogicException { + if (user.userMetadata != null) { + try { + UserMetadata.bulkImport_updateUserMetadata_Transaction(con, + appIdentifierWithStorage, + primaryLM.superTokensOrExternalUserId, user.userMetadata); + } catch (StorageQueryException | TenantOrAppNotFoundException e) { + throw new StorageTransactionLogicException(e); + } + } + } + + private void createUserRoles(AppIdentifierWithStorage appIdentifierWithStorage, TransactionConnection con, + BulkImportUser user) throws StorageTransactionLogicException { + if (user.userRoles != null) { + for (UserRole userRole : user.userRoles) { + try { + for (String tenantId : userRole.tenantIds) { + TenantIdentifierWithStorage tenantIdentifierWithStorage = new TenantIdentifierWithStorage( + appIdentifierWithStorage.getConnectionUriDomain(), appIdentifierWithStorage.getAppId(), + tenantId, + appIdentifierWithStorage.getStorage()); + + UserRoles.bulkImport_addRoleToUser_Transaction(con, tenantIdentifierWithStorage, + user.externalUserId, userRole.role); + } + } catch (TenantOrAppNotFoundException | StorageQueryException e) { + throw new StorageTransactionLogicException(e); + } catch (UnknownRoleException e) { + throw new StorageTransactionLogicException(new Exception("Role " + userRole.role + + " does not exist! You need pre-create the role before assigning it to the user.")); + } + } + } + } + + private void verifyEmailForAllLoginMethods(AppIdentifierWithStorage appIdentifierWithStorage, + TransactionConnection con, + SQLStorage userStorage, List loginMethods) throws StorageTransactionLogicException { + + for (LoginMethod lm : loginMethods) { + try { + + TenantIdentifierWithStorage tenantIdentifierWithStorage = new TenantIdentifierWithStorage( + appIdentifierWithStorage.getConnectionUriDomain(), appIdentifierWithStorage.getAppId(), + lm.tenantIds.get(0), + userStorage); + + EmailVerificationSQLStorage emailVerificationSQLStorage = tenantIdentifierWithStorage + .getEmailVerificationStorage(); + emailVerificationSQLStorage + .updateIsEmailVerified_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, + lm.superTokensOrExternalUserId, lm.email, true); + } catch (TenantOrAppNotFoundException | StorageQueryException e) { + throw new StorageTransactionLogicException(e); + } + } + } + + // Returns the primary loginMethod of the user. If no loginMethod is marked as + // primary, then the oldest loginMethod is returned. + private BulkImportUser.LoginMethod getPrimaryLoginMethod(BulkImportUser user) { + BulkImportUser.LoginMethod oldestLM = user.loginMethods.get(0); + for (BulkImportUser.LoginMethod lm : user.loginMethods) { + if (lm.isPrimary) { + return lm; + } + + if (lm.timeJoinedInMSSinceEpoch < oldestLM.timeJoinedInMSSinceEpoch) { + oldestLM = lm; + } + } + return oldestLM; + } +} diff --git a/src/main/java/io/supertokens/emailpassword/EmailPassword.java b/src/main/java/io/supertokens/emailpassword/EmailPassword.java index 97772a433..32494fd92 100644 --- a/src/main/java/io/supertokens/emailpassword/EmailPassword.java +++ b/src/main/java/io/supertokens/emailpassword/EmailPassword.java @@ -44,6 +44,7 @@ import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; import io.supertokens.pluginInterface.multitenancy.TenantIdentifierWithStorage; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.utils.Utils; import io.supertokens.webserver.WebserverAPI; @@ -184,45 +185,72 @@ public static ImportUserResponse importUserWithPasswordHash(TenantIdentifierWith tenantIdentifierWithStorage.toAppIdentifier(), main, passwordHash, hashingAlgorithm); - while (true) { - String userId = Utils.getUUID(); + EmailPasswordSQLStorage storage = tenantIdentifierWithStorage.getEmailPasswordStorage(); + + ImportUserResponse response = null; + try { long timeJoined = System.currentTimeMillis(); + response = createUserWithPasswordHash(null, tenantIdentifierWithStorage, email, passwordHash, timeJoined); + } catch (DuplicateEmailException e) { + AuthRecipeUserInfo[] allUsers = storage.listPrimaryUsersByEmail(tenantIdentifierWithStorage, email); + AuthRecipeUserInfo userInfoToBeUpdated = null; + LoginMethod loginMethod = null; + for (AuthRecipeUserInfo currUser : allUsers) { + for (LoginMethod currLM : currUser.loginMethods) { + if (currLM.email.equals(email) && currLM.recipeId == RECIPE_ID.EMAIL_PASSWORD && currLM.tenantIds.contains(tenantIdentifierWithStorage.getTenantId())) { + userInfoToBeUpdated = currUser; + loginMethod = currLM; + break; + } + } + } - EmailPasswordSQLStorage storage = tenantIdentifierWithStorage.getEmailPasswordStorage(); + if (userInfoToBeUpdated != null) { + LoginMethod finalLoginMethod = loginMethod; + storage.startTransaction(con -> { + storage.updateUsersPassword_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, + finalLoginMethod.getSupertokensUserId(), passwordHash); + return null; + }); + response = new ImportUserResponse(true, userInfoToBeUpdated); + } + } + + return response; + } + private static ImportUserResponse createUserWithPasswordHash(TransactionConnection con, + TenantIdentifierWithStorage tenantIdentifierWithStorage, + @Nonnull String email, + @Nonnull String passwordHash, @Nullable long timeJoined) + throws StorageQueryException, DuplicateEmailException, TenantOrAppNotFoundException { + EmailPasswordSQLStorage storage = tenantIdentifierWithStorage.getEmailPasswordStorage(); + + while (true) { + String userId = Utils.getUUID(); try { - AuthRecipeUserInfo userInfo = storage.signUp(tenantIdentifierWithStorage, userId, email, passwordHash, - timeJoined); + AuthRecipeUserInfo userInfo = null; + if (con == null) { + userInfo = storage.signUp(tenantIdentifierWithStorage, userId, email, passwordHash, timeJoined); + } else { + userInfo = storage.bulkImport_signUp_Transaction(con, tenantIdentifierWithStorage, userId, email, + passwordHash, timeJoined); + } return new ImportUserResponse(false, userInfo); } catch (DuplicateUserIdException e) { // we retry with a new userId - } catch (DuplicateEmailException e) { - AuthRecipeUserInfo[] allUsers = storage.listPrimaryUsersByEmail(tenantIdentifierWithStorage, email); - AuthRecipeUserInfo userInfoToBeUpdated = null; - LoginMethod loginMethod = null; - for (AuthRecipeUserInfo currUser : allUsers) { - for (LoginMethod currLM : currUser.loginMethods) { - if (currLM.email.equals(email) && currLM.recipeId == RECIPE_ID.EMAIL_PASSWORD && currLM.tenantIds.contains(tenantIdentifierWithStorage.getTenantId())) { - userInfoToBeUpdated = currUser; - loginMethod = currLM; - break; - } - } - } - - if (userInfoToBeUpdated != null) { - LoginMethod finalLoginMethod = loginMethod; - storage.startTransaction(con -> { - storage.updateUsersPassword_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, - finalLoginMethod.getSupertokensUserId(), passwordHash); - return null; - }); - return new ImportUserResponse(true, userInfoToBeUpdated); - } } } } + public static ImportUserResponse bulkImport_createUserWithPasswordHash_Transaction(TransactionConnection con, + TenantIdentifierWithStorage tenantIdentifierWithStorage, + @Nonnull String email, + @Nonnull String passwordHash, @Nullable long timeJoined) + throws StorageQueryException, DuplicateEmailException, TenantOrAppNotFoundException { + return createUserWithPasswordHash(con, tenantIdentifierWithStorage, email, passwordHash, timeJoined); + } + @TestOnly public static ImportUserResponse importUserWithPasswordHash(Main main, @Nonnull String email, @Nonnull String passwordHash) diff --git a/src/main/java/io/supertokens/multitenancy/Multitenancy.java b/src/main/java/io/supertokens/multitenancy/Multitenancy.java index 2cb068855..35c025a3e 100644 --- a/src/main/java/io/supertokens/multitenancy/Multitenancy.java +++ b/src/main/java/io/supertokens/multitenancy/Multitenancy.java @@ -43,6 +43,7 @@ import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.pluginInterface.multitenancy.sqlStorage.MultitenancySQLStorage; import io.supertokens.pluginInterface.passwordless.exception.DuplicatePhoneNumberException; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; import io.supertokens.pluginInterface.thirdparty.exception.DuplicateThirdPartyUserException; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.thirdparty.InvalidProviderConfigException; @@ -439,131 +440,186 @@ public static boolean addUserIdToTenant(Main main, TenantIdentifierWithStorage t DuplicateThirdPartyUserException, AnotherPrimaryUserWithPhoneNumberAlreadyExistsException, AnotherPrimaryUserWithEmailAlreadyExistsException, AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException { - if (Arrays.stream(FeatureFlag.getInstance(main, new AppIdentifier(null, null)).getEnabledFeatures()) - .noneMatch(ee_features -> ee_features == EE_FEATURES.MULTI_TENANCY)) { - throw new FeatureNotEnabledException(EE_FEATURES.MULTI_TENANCY); - } - AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) tenantIdentifierWithStorage.getAuthRecipeStorage(); try { return storage.startTransaction(con -> { - String tenantId = tenantIdentifierWithStorage.getTenantId(); - AuthRecipeUserInfo userToAssociate = storage.getPrimaryUserById_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, userId); - - if (userToAssociate != null && userToAssociate.isPrimaryUser) { - Set emails = new HashSet<>(); - Set phoneNumbers = new HashSet<>(); - Set thirdParties = new HashSet<>(); - - // Loop through all the emails, phoneNumbers and thirdPartyInfos and check for conflicts - for (LoginMethod lM : userToAssociate.loginMethods) { - if (lM.email != null) { - emails.add(lM.email); - } - if (lM.phoneNumber != null) { - phoneNumbers.add(lM.phoneNumber); - } - if (lM.thirdParty != null) { - thirdParties.add(lM.thirdParty); - } + return addUserIdToTenantInternal(main, con, tenantIdentifierWithStorage, userId); + }); + } catch (StorageTransactionLogicException e) { + return handleAddUserIdToTenantExceptions(e); + } + } + + public static boolean bulkImport_addUserIdToTenant_Transaction(Main main, TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, + String userId) + throws TenantOrAppNotFoundException, UnknownUserIdException, StorageQueryException, + FeatureNotEnabledException, DuplicateEmailException, DuplicatePhoneNumberException, + DuplicateThirdPartyUserException, AnotherPrimaryUserWithPhoneNumberAlreadyExistsException, + AnotherPrimaryUserWithEmailAlreadyExistsException, + AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException { + try { + return addUserIdToTenantInternal(main, con, tenantIdentifierWithStorage, userId); + } catch (StorageTransactionLogicException e) { + return handleAddUserIdToTenantExceptions(e); + } + } + + private static boolean handleAddUserIdToTenantExceptions(StorageTransactionLogicException e) + throws TenantOrAppNotFoundException, UnknownUserIdException, StorageQueryException, + FeatureNotEnabledException, DuplicateEmailException, DuplicatePhoneNumberException, + DuplicateThirdPartyUserException, AnotherPrimaryUserWithPhoneNumberAlreadyExistsException, + AnotherPrimaryUserWithEmailAlreadyExistsException, + AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException { + if (e.actualException instanceof DuplicateEmailException) { + throw (DuplicateEmailException) e.actualException; + } else if (e.actualException instanceof FeatureNotEnabledException) { + throw (FeatureNotEnabledException) e.actualException; + } else if (e.actualException instanceof DuplicatePhoneNumberException) { + throw (DuplicatePhoneNumberException) e.actualException; + } else if (e.actualException instanceof DuplicateThirdPartyUserException) { + throw (DuplicateThirdPartyUserException) e.actualException; + } else if (e.actualException instanceof TenantOrAppNotFoundException) { + throw (TenantOrAppNotFoundException) e.actualException; + } else if (e.actualException instanceof UnknownUserIdException) { + throw (UnknownUserIdException) e.actualException; + } else if (e.actualException instanceof AnotherPrimaryUserWithPhoneNumberAlreadyExistsException) { + throw (AnotherPrimaryUserWithPhoneNumberAlreadyExistsException) e.actualException; + } else if (e.actualException instanceof AnotherPrimaryUserWithEmailAlreadyExistsException) { + throw (AnotherPrimaryUserWithEmailAlreadyExistsException) e.actualException; + } else if (e.actualException instanceof AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException) { + throw (AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException) e.actualException; + } + throw new StorageQueryException(e.actualException); + } + + private static boolean addUserIdToTenantInternal(Main main, TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, + String userId) + throws StorageTransactionLogicException { + try { + + if (Arrays.stream(FeatureFlag.getInstance(main, new AppIdentifier(null, null)).getEnabledFeatures()) + .noneMatch(ee_features -> ee_features == EE_FEATURES.MULTI_TENANCY)) { + throw new FeatureNotEnabledException(EE_FEATURES.MULTI_TENANCY); + } + + AuthRecipeSQLStorage storage = (AuthRecipeSQLStorage) tenantIdentifierWithStorage.getAuthRecipeStorage(); + String tenantId = tenantIdentifierWithStorage.getTenantId(); + AuthRecipeUserInfo userToAssociate = storage + .getPrimaryUserById_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, userId); + + if (userToAssociate != null && userToAssociate.isPrimaryUser) { + Set emails = new HashSet<>(); + Set phoneNumbers = new HashSet<>(); + Set thirdParties = new HashSet<>(); + + // Loop through all the emails, phoneNumbers and thirdPartyInfos and check for conflicts + for (LoginMethod lM : userToAssociate.loginMethods) { + if (lM.email != null) { + emails.add(lM.email); + } + if (lM.phoneNumber != null) { + phoneNumbers.add(lM.phoneNumber); } + if (lM.thirdParty != null) { + thirdParties.add(lM.thirdParty); + } + } - for (String email : emails) { - AuthRecipeUserInfo[] usersWithSameEmail = storage.listPrimaryUsersByEmail_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, email); - for (AuthRecipeUserInfo userWithSameEmail : usersWithSameEmail) { - if (userWithSameEmail.getSupertokensUserId().equals(userToAssociate.getSupertokensUserId())) { - continue; // it's the same user, no need to check anything - } - if (userWithSameEmail.isPrimaryUser && userWithSameEmail.tenantIds.contains(tenantId) && !userWithSameEmail.getSupertokensUserId().equals(userId)) { - for (LoginMethod lm1 : userWithSameEmail.loginMethods) { - if (lm1.tenantIds.contains(tenantId)) { - for (LoginMethod lm2 : userToAssociate.loginMethods) { - if (lm1.recipeId.equals(lm2.recipeId) && email.equals(lm1.email) && lm1.email.equals(lm2.email)) { - throw new StorageTransactionLogicException(new DuplicateEmailException()); - } + for (String email : emails) { + AuthRecipeUserInfo[] usersWithSameEmail = storage.listPrimaryUsersByEmail_Transaction( + tenantIdentifierWithStorage.toAppIdentifier(), con, email); + for (AuthRecipeUserInfo userWithSameEmail : usersWithSameEmail) { + if (userWithSameEmail.getSupertokensUserId().equals(userToAssociate.getSupertokensUserId())) { + continue; // it's the same user, no need to check anything + } + if (userWithSameEmail.isPrimaryUser && userWithSameEmail.tenantIds.contains(tenantId) + && !userWithSameEmail.getSupertokensUserId().equals(userId)) { + for (LoginMethod lm1 : userWithSameEmail.loginMethods) { + if (lm1.tenantIds.contains(tenantId)) { + for (LoginMethod lm2 : userToAssociate.loginMethods) { + if (lm1.recipeId.equals(lm2.recipeId) && email.equals(lm1.email) + && lm1.email.equals(lm2.email)) { + throw new StorageTransactionLogicException(new DuplicateEmailException()); } } } - throw new StorageTransactionLogicException(new AnotherPrimaryUserWithEmailAlreadyExistsException(userWithSameEmail.getSupertokensUserId())); } + throw new StorageTransactionLogicException( + new AnotherPrimaryUserWithEmailAlreadyExistsException( + userWithSameEmail.getSupertokensUserId())); } } + } - for (String phoneNumber : phoneNumbers) { - AuthRecipeUserInfo[] usersWithSamePhoneNumber = storage.listPrimaryUsersByPhoneNumber_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, phoneNumber); - for (AuthRecipeUserInfo userWithSamePhoneNumber : usersWithSamePhoneNumber) { - if (userWithSamePhoneNumber.getSupertokensUserId().equals(userToAssociate.getSupertokensUserId())) { - continue; // it's the same user, no need to check anything - } - if (userWithSamePhoneNumber.tenantIds.contains(tenantId) && !userWithSamePhoneNumber.getSupertokensUserId().equals(userId)) { - for (LoginMethod lm1 : userWithSamePhoneNumber.loginMethods) { - if (lm1.tenantIds.contains(tenantId)) { - for (LoginMethod lm2 : userToAssociate.loginMethods) { - if (lm1.recipeId.equals(lm2.recipeId) && phoneNumber.equals(lm1.phoneNumber) && lm1.phoneNumber.equals(lm2.phoneNumber)) { - throw new StorageTransactionLogicException(new DuplicatePhoneNumberException()); - } + for (String phoneNumber : phoneNumbers) { + AuthRecipeUserInfo[] usersWithSamePhoneNumber = storage.listPrimaryUsersByPhoneNumber_Transaction( + tenantIdentifierWithStorage.toAppIdentifier(), con, phoneNumber); + for (AuthRecipeUserInfo userWithSamePhoneNumber : usersWithSamePhoneNumber) { + if (userWithSamePhoneNumber.getSupertokensUserId() + .equals(userToAssociate.getSupertokensUserId())) { + continue; // it's the same user, no need to check anything + } + if (userWithSamePhoneNumber.tenantIds.contains(tenantId) + && !userWithSamePhoneNumber.getSupertokensUserId().equals(userId)) { + for (LoginMethod lm1 : userWithSamePhoneNumber.loginMethods) { + if (lm1.tenantIds.contains(tenantId)) { + for (LoginMethod lm2 : userToAssociate.loginMethods) { + if (lm1.recipeId.equals(lm2.recipeId) && phoneNumber.equals(lm1.phoneNumber) + && lm1.phoneNumber.equals(lm2.phoneNumber)) { + throw new StorageTransactionLogicException( + new DuplicatePhoneNumberException()); } } } - throw new StorageTransactionLogicException(new AnotherPrimaryUserWithPhoneNumberAlreadyExistsException(userWithSamePhoneNumber.getSupertokensUserId())); } + throw new StorageTransactionLogicException( + new AnotherPrimaryUserWithPhoneNumberAlreadyExistsException( + userWithSamePhoneNumber.getSupertokensUserId())); } } + } - for (LoginMethod.ThirdParty tp : thirdParties) { - AuthRecipeUserInfo[] usersWithSameThirdPartyInfo = storage.listPrimaryUsersByThirdPartyInfo_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, tp.id, tp.userId); - for (AuthRecipeUserInfo userWithSameThirdPartyInfo : usersWithSameThirdPartyInfo) { - if (userWithSameThirdPartyInfo.getSupertokensUserId().equals(userToAssociate.getSupertokensUserId())) { - continue; // it's the same user, no need to check anything - } - if (userWithSameThirdPartyInfo.tenantIds.contains(tenantId) && !userWithSameThirdPartyInfo.getSupertokensUserId().equals(userId)) { - for (LoginMethod lm1 : userWithSameThirdPartyInfo.loginMethods) { - if (lm1.tenantIds.contains(tenantId)) { - for (LoginMethod lm2 : userToAssociate.loginMethods) { - if (lm1.recipeId.equals(lm2.recipeId) && tp.equals(lm1.thirdParty) && lm1.thirdParty.equals(lm2.thirdParty)) { - throw new StorageTransactionLogicException(new DuplicateThirdPartyUserException()); - } + for (LoginMethod.ThirdParty tp : thirdParties) { + AuthRecipeUserInfo[] usersWithSameThirdPartyInfo = storage + .listPrimaryUsersByThirdPartyInfo_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), + con, tp.id, tp.userId); + for (AuthRecipeUserInfo userWithSameThirdPartyInfo : usersWithSameThirdPartyInfo) { + if (userWithSameThirdPartyInfo.getSupertokensUserId() + .equals(userToAssociate.getSupertokensUserId())) { + continue; // it's the same user, no need to check anything + } + if (userWithSameThirdPartyInfo.tenantIds.contains(tenantId) + && !userWithSameThirdPartyInfo.getSupertokensUserId().equals(userId)) { + for (LoginMethod lm1 : userWithSameThirdPartyInfo.loginMethods) { + if (lm1.tenantIds.contains(tenantId)) { + for (LoginMethod lm2 : userToAssociate.loginMethods) { + if (lm1.recipeId.equals(lm2.recipeId) && tp.equals(lm1.thirdParty) + && lm1.thirdParty.equals(lm2.thirdParty)) { + throw new StorageTransactionLogicException( + new DuplicateThirdPartyUserException()); } } } - - throw new StorageTransactionLogicException(new AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException(userWithSameThirdPartyInfo.getSupertokensUserId())); } + throw new StorageTransactionLogicException( + new AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException( + userWithSameThirdPartyInfo.getSupertokensUserId())); } } } - - // userToAssociate may be null if the user is not associated to any tenants, we can still try and - // associate it. This happens only in CDI 3.0 where we allow disassociation from all tenants - // This will not happen in CDI >= 4.0 because we will not allow disassociation from all tenants - try { - boolean result = ((MultitenancySQLStorage) storage).addUserIdToTenant_Transaction(tenantIdentifierWithStorage, con, userId); - storage.commitTransaction(con); - return result; - } catch (TenantOrAppNotFoundException | UnknownUserIdException | DuplicatePhoneNumberException | - DuplicateThirdPartyUserException | DuplicateEmailException e) { - throw new StorageTransactionLogicException(e); - } - }); - } catch (StorageTransactionLogicException e) { - if (e.actualException instanceof DuplicateEmailException) { - throw (DuplicateEmailException) e.actualException; - } else if (e.actualException instanceof DuplicatePhoneNumberException) { - throw (DuplicatePhoneNumberException) e.actualException; - } else if (e.actualException instanceof DuplicateThirdPartyUserException) { - throw (DuplicateThirdPartyUserException) e.actualException; - } else if (e.actualException instanceof TenantOrAppNotFoundException) { - throw (TenantOrAppNotFoundException) e.actualException; - } else if (e.actualException instanceof UnknownUserIdException) { - throw (UnknownUserIdException) e.actualException; - } else if (e.actualException instanceof AnotherPrimaryUserWithPhoneNumberAlreadyExistsException) { - throw (AnotherPrimaryUserWithPhoneNumberAlreadyExistsException) e.actualException; - } else if (e.actualException instanceof AnotherPrimaryUserWithEmailAlreadyExistsException) { - throw (AnotherPrimaryUserWithEmailAlreadyExistsException) e.actualException; - } else if (e.actualException instanceof AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException) { - throw (AnotherPrimaryUserWithThirdPartyInfoAlreadyExistsException) e.actualException; } - throw new StorageQueryException(e.actualException); + + // userToAssociate may be null if the user is not associated to any tenants, we can still try and + // associate it. This happens only in CDI 3.0 where we allow disassociation from all tenants + // This will not happen in CDI >= 4.0 because we will not allow disassociation from all tenants + boolean result = ((MultitenancySQLStorage) storage) + .addUserIdToTenant_Transaction(tenantIdentifierWithStorage, con, userId); + + return result; + } catch (StorageQueryException | FeatureNotEnabledException | TenantOrAppNotFoundException + | UnknownUserIdException | DuplicatePhoneNumberException | DuplicateThirdPartyUserException + | DuplicateEmailException e) { + throw new StorageTransactionLogicException(e); } } diff --git a/src/main/java/io/supertokens/passwordless/Passwordless.java b/src/main/java/io/supertokens/passwordless/Passwordless.java index 6131006f0..36d28b31f 100644 --- a/src/main/java/io/supertokens/passwordless/Passwordless.java +++ b/src/main/java/io/supertokens/passwordless/Passwordless.java @@ -41,6 +41,7 @@ import io.supertokens.pluginInterface.passwordless.PasswordlessDevice; import io.supertokens.pluginInterface.passwordless.exception.*; import io.supertokens.pluginInterface.passwordless.sqlStorage.PasswordlessSQLStorage; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.utils.Utils; import org.jetbrains.annotations.TestOnly; @@ -440,52 +441,34 @@ public static ConsumeCodeResponse consumeCode(TenantIdentifierWithStorage tenant if (user == null) { if (createRecipeUserIfNotExists) { - while (true) { + long timeJoined = System.currentTimeMillis(); + user = createPasswordlessUser(null, tenantIdentifierWithStorage, consumedDevice.email, consumedDevice.phoneNumber, timeJoined); + // Set email as verified, if using email + if (setEmailVerified && consumedDevice.email != null) { try { - String userId = Utils.getUUID(); - long timeJoined = System.currentTimeMillis(); - user = passwordlessStorage.createUser(tenantIdentifierWithStorage, userId, consumedDevice.email, - consumedDevice.phoneNumber, timeJoined); - - // Set email as verified, if using email - if (setEmailVerified && consumedDevice.email != null) { + AuthRecipeUserInfo finalUser = user; + tenantIdentifierWithStorage.getEmailVerificationStorage().startTransaction(con -> { try { - AuthRecipeUserInfo finalUser = user; - tenantIdentifierWithStorage.getEmailVerificationStorage().startTransaction(con -> { - try { - tenantIdentifierWithStorage.getEmailVerificationStorage() - .updateIsEmailVerified_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, - finalUser.getSupertokensUserId(), consumedDevice.email, true); - tenantIdentifierWithStorage.getEmailVerificationStorage() - .commitTransaction(con); - - return null; - } catch (TenantOrAppNotFoundException e) { - throw new StorageTransactionLogicException(e); - } - }); - user.loginMethods[0].setVerified(); // newly created user has only one loginMethod - } catch (StorageTransactionLogicException e) { - if (e.actualException instanceof TenantOrAppNotFoundException) { - throw (TenantOrAppNotFoundException) e.actualException; - } - throw new StorageQueryException(e); + tenantIdentifierWithStorage.getEmailVerificationStorage() + .updateIsEmailVerified_Transaction(tenantIdentifierWithStorage.toAppIdentifier(), con, + finalUser.getSupertokensUserId(), consumedDevice.email, true); + tenantIdentifierWithStorage.getEmailVerificationStorage() + .commitTransaction(con); + + return null; + } catch (TenantOrAppNotFoundException e) { + throw new StorageTransactionLogicException(e); } + }); + user.loginMethods[0].setVerified(); // newly created user has only one loginMethod + } catch (StorageTransactionLogicException e) { + if (e.actualException instanceof TenantOrAppNotFoundException) { + throw (TenantOrAppNotFoundException) e.actualException; } - - return new ConsumeCodeResponse(true, user, consumedDevice.email, consumedDevice.phoneNumber, consumedDevice); - } catch (DuplicateEmailException | DuplicatePhoneNumberException e) { - // Getting these would mean that between getting the user and trying creating it: - // 1. the user managed to do a full create+consume flow - // 2. the users email or phoneNumber was updated to the new one (including device cleanup) - // These should be almost impossibly rare, so it's safe to just ask the user to restart. - // Also, both would make the current login fail if done before the transaction - // by cleaning up the device/code this consume would've used. - throw new RestartFlowException(); - } catch (DuplicateUserIdException e) { - // We can retry.. + throw new StorageQueryException(e); } } + return new ConsumeCodeResponse(true, user, consumedDevice.email, consumedDevice.phoneNumber, consumedDevice); } } else { // We do not need this cleanup if we are creating the user, since it uses the email/phoneNumber of the @@ -526,6 +509,40 @@ public static ConsumeCodeResponse consumeCode(TenantIdentifierWithStorage tenant return new ConsumeCodeResponse(false, user, consumedDevice.email, consumedDevice.phoneNumber, consumedDevice); } + + private static AuthRecipeUserInfo createPasswordlessUser(TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, String email, String phoneNumber, long timeJoined) + throws TenantOrAppNotFoundException, StorageQueryException, RestartFlowException { + PasswordlessSQLStorage passwordlessStorage = tenantIdentifierWithStorage.getPasswordlessStorage(); + + while (true) { + try { + String userId = Utils.getUUID(); + if (con == null) { + return passwordlessStorage.createUser(tenantIdentifierWithStorage, userId, email, + phoneNumber, timeJoined); + } else { + return passwordlessStorage.bulkImport_createUser_Transaction(con, tenantIdentifierWithStorage, + userId, email, phoneNumber, timeJoined); + } + } catch (DuplicateEmailException | DuplicatePhoneNumberException e) { + // Getting these would mean that between getting the user and trying creating it: + // 1. the user managed to do a full create+consume flow + // 2. the users email or phoneNumber was updated to the new one (including device cleanup) + // These should be almost impossibly rare, so it's safe to just ask the user to restart. + // Also, both would make the current login fail if done before the transaction + // by cleaning up the device/code this consume would've used. + throw new RestartFlowException(); + } catch (DuplicateUserIdException e) { + // We can retry.. + } + } + } + + public static AuthRecipeUserInfo bulkImport_createPasswordlessUser_Transaction(TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, String email, String phoneNumber, long timeJoined) + throws TenantOrAppNotFoundException, StorageQueryException, RestartFlowException { + return createPasswordlessUser(con, tenantIdentifierWithStorage, email, phoneNumber, timeJoined); + } + @TestOnly public static void removeCode(Main main, String codeId) throws StorageQueryException, StorageTransactionLogicException { diff --git a/src/main/java/io/supertokens/thirdparty/ThirdParty.java b/src/main/java/io/supertokens/thirdparty/ThirdParty.java index 69bdcf193..8b8ef6592 100644 --- a/src/main/java/io/supertokens/thirdparty/ThirdParty.java +++ b/src/main/java/io/supertokens/thirdparty/ThirdParty.java @@ -29,6 +29,7 @@ import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.*; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; import io.supertokens.pluginInterface.thirdparty.exception.DuplicateThirdPartyUserException; import io.supertokens.pluginInterface.thirdparty.exception.DuplicateUserIdException; import io.supertokens.pluginInterface.thirdparty.sqlStorage.ThirdPartySQLStorage; @@ -205,22 +206,11 @@ private static SignInUpResponse signInUpHelper(TenantIdentifierWithStorage tenan while (true) { // loop for sign in + sign up - while (true) { - // loop for sign up - String userId = Utils.getUUID(); - long timeJoined = System.currentTimeMillis(); - - try { - AuthRecipeUserInfo createdUser = storage.signUp(tenantIdentifierWithStorage, userId, email, - new LoginMethod.ThirdParty(thirdPartyId, thirdPartyUserId), timeJoined); - - return new SignInUpResponse(true, createdUser); - } catch (DuplicateUserIdException e) { - // we try again.. - } catch (DuplicateThirdPartyUserException e) { - // we try to sign in - break; - } + long timeJoined = System.currentTimeMillis(); + try { + return createThirdPartyUser(null, tenantIdentifierWithStorage, thirdPartyId, thirdPartyUserId, email, timeJoined); + } catch (DuplicateThirdPartyUserException e) { + // The user already exists, we will try to update the email if needed below } // we try to get user and update their email @@ -341,6 +331,37 @@ private static SignInUpResponse signInUpHelper(TenantIdentifierWithStorage tenan } } + private static SignInUpResponse createThirdPartyUser(TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, + String thirdPartyId, String thirdPartyUserId, String email, long timeJoined) + throws StorageQueryException, TenantOrAppNotFoundException, DuplicateThirdPartyUserException { + ThirdPartySQLStorage storage = tenantIdentifierWithStorage.getThirdPartyStorage(); + while (true) { + // loop for sign up + String userId = Utils.getUUID(); + + try { + AuthRecipeUserInfo createdUser; + if (con == null) { + createdUser = storage.signUp(tenantIdentifierWithStorage, userId, email, + new LoginMethod.ThirdParty(thirdPartyId, thirdPartyUserId), timeJoined); + } else { + createdUser = storage.bulkImport_signUp_Transaction(con, tenantIdentifierWithStorage, userId, email, + new LoginMethod.ThirdParty(thirdPartyId, thirdPartyUserId), timeJoined); + } + + return new SignInUpResponse(true, createdUser); + } catch (DuplicateUserIdException e) { + // we try again.. + } + } + } + + public static SignInUpResponse bulkImport_createThirdPartyUser_Transaction(TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, + String thirdPartyId, String thirdPartyUserId, String email, long timeJoined) + throws StorageQueryException, TenantOrAppNotFoundException, DuplicateThirdPartyUserException { + return createThirdPartyUser(con, tenantIdentifierWithStorage, thirdPartyId, thirdPartyUserId, email, timeJoined); + } + @Deprecated public static AuthRecipeUserInfo getUser(AppIdentifierWithStorage appIdentifierWithStorage, String userId) throws StorageQueryException { diff --git a/src/main/java/io/supertokens/useridmapping/UserIdMapping.java b/src/main/java/io/supertokens/useridmapping/UserIdMapping.java index 56a220152..7e884c653 100644 --- a/src/main/java/io/supertokens/useridmapping/UserIdMapping.java +++ b/src/main/java/io/supertokens/useridmapping/UserIdMapping.java @@ -65,6 +65,26 @@ public static void createUserIdMapping(Main main, AppIdentifierWithStorage appId throws UnknownSuperTokensUserIdException, UserIdMappingAlreadyExistsException, StorageQueryException, ServletException, TenantOrAppNotFoundException { + createUserIdMappingInternal(null, main, appIdentifierWithStorage, superTokensUserId, externalUserId, + externalUserIdInfo, force, makeExceptionForEmailVerification); + } + + public static void bulkImport_createUserIdMapping_Transaction(Main main, TransactionConnection con, AppIdentifierWithStorage appIdentifierWithStorage, + String superTokensUserId, String externalUserId, + String externalUserIdInfo, boolean force, boolean makeExceptionForEmailVerification) + throws UnknownSuperTokensUserIdException, + UserIdMappingAlreadyExistsException, StorageQueryException, ServletException, + TenantOrAppNotFoundException { + createUserIdMappingInternal(con, main, appIdentifierWithStorage, superTokensUserId, externalUserId, + externalUserIdInfo, force, makeExceptionForEmailVerification); + } + + private static void createUserIdMappingInternal(TransactionConnection con, Main main, AppIdentifierWithStorage appIdentifierWithStorage, + String superTokensUserId, String externalUserId, + String externalUserIdInfo, boolean force, boolean makeExceptionForEmailVerification) + throws UnknownSuperTokensUserIdException, + UserIdMappingAlreadyExistsException, StorageQueryException, ServletException, + TenantOrAppNotFoundException { // We first need to check if the external user id exists across all app storages because we do not want // 2 users from different user pool but same app to point to same external user id. @@ -127,14 +147,20 @@ public static void createUserIdMapping(Main main, AppIdentifierWithStorage appId } else { findNonAuthStoragesWhereUserIdIsUsedOrAssertIfUsed(appIdentifierWithStorage, superTokensUserId, true); } + } - + if (con == null) { + appIdentifierWithStorage.getUserIdMappingStorage() + .createUserIdMapping(appIdentifierWithStorage, superTokensUserId, + externalUserId, externalUserIdInfo); + } else { + appIdentifierWithStorage.getUserIdMappingStorage() + .bulkImport_createUserIdMapping_Transaction(con, appIdentifierWithStorage, superTokensUserId, + externalUserId, externalUserIdInfo); } - appIdentifierWithStorage.getUserIdMappingStorage() - .createUserIdMapping(appIdentifierWithStorage, superTokensUserId, - externalUserId, externalUserIdInfo); } + @TestOnly public static void createUserIdMapping(Main main, String superTokensUserId, String externalUserId, diff --git a/src/main/java/io/supertokens/usermetadata/UserMetadata.java b/src/main/java/io/supertokens/usermetadata/UserMetadata.java index e0f0d37fd..3eebec503 100644 --- a/src/main/java/io/supertokens/usermetadata/UserMetadata.java +++ b/src/main/java/io/supertokens/usermetadata/UserMetadata.java @@ -22,9 +22,8 @@ import io.supertokens.pluginInterface.exceptions.StorageQueryException; import io.supertokens.pluginInterface.exceptions.StorageTransactionLogicException; import io.supertokens.pluginInterface.multitenancy.AppIdentifierWithStorage; -import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; -import io.supertokens.pluginInterface.multitenancy.TenantIdentifierWithStorage; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; import io.supertokens.pluginInterface.usermetadata.sqlStorage.UserMetadataSQLStorage; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.utils.MetadataUtils; @@ -55,19 +54,12 @@ public static JsonObject updateUserMetadata(AppIdentifierWithStorage appIdentifi try { return storage.startTransaction((con) -> { - JsonObject originalMetadata = storage.getUserMetadata_Transaction(appIdentifierWithStorage, con, - userId); - - JsonObject updatedMetadata = originalMetadata == null ? new JsonObject() : originalMetadata; - MetadataUtils.shallowMergeMetadataUpdate(updatedMetadata, metadataUpdate); - try { - storage.setUserMetadata_Transaction(appIdentifierWithStorage, con, userId, updatedMetadata); - } catch (TenantOrAppNotFoundException e) { + return updateUserMetadataInternal(con, appIdentifierWithStorage, userId, metadataUpdate); + } + catch (TenantOrAppNotFoundException e) { throw new StorageTransactionLogicException(e); } - - return updatedMetadata; }); } catch (StorageTransactionLogicException e) { if (e.actualException instanceof TenantOrAppNotFoundException) { @@ -77,6 +69,28 @@ public static JsonObject updateUserMetadata(AppIdentifierWithStorage appIdentifi } } + public static JsonObject bulkImport_updateUserMetadata_Transaction(TransactionConnection con, AppIdentifierWithStorage appIdentifierWithStorage, + @Nonnull String userId, @Nonnull JsonObject metadataUpdate) + throws StorageQueryException, TenantOrAppNotFoundException { + return updateUserMetadataInternal(con, appIdentifierWithStorage, userId, metadataUpdate); + } + + public static JsonObject updateUserMetadataInternal(TransactionConnection con, AppIdentifierWithStorage appIdentifierWithStorage, + @Nonnull String userId, @Nonnull JsonObject metadataUpdate) + throws StorageQueryException, TenantOrAppNotFoundException { + UserMetadataSQLStorage storage = appIdentifierWithStorage.getUserMetadataStorage(); + + JsonObject originalMetadata = storage.getUserMetadata_Transaction(appIdentifierWithStorage, con, + userId); + + JsonObject updatedMetadata = originalMetadata == null ? new JsonObject() : originalMetadata; + MetadataUtils.shallowMergeMetadataUpdate(updatedMetadata, metadataUpdate); + + storage.setUserMetadata_Transaction(appIdentifierWithStorage, con, userId, updatedMetadata); + + return updatedMetadata; + } + @TestOnly public static JsonObject getUserMetadata(Main main, @Nonnull String userId) throws StorageQueryException { Storage storage = StorageLayer.getStorage(main); diff --git a/src/main/java/io/supertokens/userroles/UserRoles.java b/src/main/java/io/supertokens/userroles/UserRoles.java index 6ff0b88e4..94ce25fc6 100644 --- a/src/main/java/io/supertokens/userroles/UserRoles.java +++ b/src/main/java/io/supertokens/userroles/UserRoles.java @@ -23,6 +23,7 @@ import io.supertokens.pluginInterface.multitenancy.AppIdentifierWithStorage; import io.supertokens.pluginInterface.multitenancy.TenantIdentifierWithStorage; import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; +import io.supertokens.pluginInterface.sqlStorage.TransactionConnection; import io.supertokens.pluginInterface.userroles.exception.DuplicateUserRoleMappingException; import io.supertokens.pluginInterface.userroles.exception.UnknownRoleException; import io.supertokens.pluginInterface.userroles.sqlStorage.UserRolesSQLStorage; @@ -46,6 +47,18 @@ public static boolean addRoleToUser(TenantIdentifierWithStorage tenantIdentifier } } + public static boolean bulkImport_addRoleToUser_Transaction(TransactionConnection con, TenantIdentifierWithStorage tenantIdentifierWithStorage, String userId, + String role) + throws StorageQueryException, UnknownRoleException, TenantOrAppNotFoundException { + try { + tenantIdentifierWithStorage.getUserRolesStorage().bulkImport_addRoleToUser_Transaction(con, tenantIdentifierWithStorage, userId, role); + return true; + } catch (DuplicateUserRoleMappingException e) { + // user already has role + return false; + } + } + @TestOnly public static boolean addRoleToUser(Main main, String userId, String role) throws StorageQueryException, UnknownRoleException { diff --git a/src/main/java/io/supertokens/webserver/api/bulkimport/BulkImportAPI.java b/src/main/java/io/supertokens/webserver/api/bulkimport/BulkImportAPI.java index af2a29c9c..e6a405766 100644 --- a/src/main/java/io/supertokens/webserver/api/bulkimport/BulkImportAPI.java +++ b/src/main/java/io/supertokens/webserver/api/bulkimport/BulkImportAPI.java @@ -18,7 +18,9 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import com.google.gson.JsonArray; import com.google.gson.JsonObject; @@ -139,11 +141,12 @@ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws S } JsonArray errorsJson = new JsonArray(); - ArrayList usersToAdd = new ArrayList<>(); + Set allExternalUserIds = new HashSet<>(); + List usersToAdd = new ArrayList<>(); for (int i = 0; i < users.size(); i++) { try { - BulkImportUser user = BulkImportUserUtils.createBulkImportUserFromJSON(main, appIdentifierWithStorage, users.get(i).getAsJsonObject(), Utils.getUUID(), allUserRoles); + BulkImportUser user = BulkImportUserUtils.createBulkImportUserFromJSON(main, appIdentifierWithStorage, users.get(i).getAsJsonObject(), Utils.getUUID(), allUserRoles, allExternalUserIds); usersToAdd.add(user); } catch (io.supertokens.bulkimport.exceptions.InvalidBulkImportDataException e) { JsonObject errorObj = new JsonObject(); diff --git a/src/test/java/io/supertokens/test/bulkimport/BulkImportTest.java b/src/test/java/io/supertokens/test/bulkimport/BulkImportTest.java index dba270c3b..7b52e190a 100644 --- a/src/test/java/io/supertokens/test/bulkimport/BulkImportTest.java +++ b/src/test/java/io/supertokens/test/bulkimport/BulkImportTest.java @@ -165,7 +165,7 @@ public void testGetUsersStatusFilter() throws Exception { String[] userIds = users.stream().map(user -> user.id).toArray(String[]::new); storage.startTransaction(con -> { - storage.updateBulkImportUserStatus_Transaction(appIdentifierWithStorage, con, userIds, BULK_IMPORT_USER_STATUS.PROCESSING); + storage.updateBulkImportUserStatus_Transaction(appIdentifierWithStorage, con, userIds, BULK_IMPORT_USER_STATUS.PROCESSING, null); storage.commitTransaction(con); return null; }); @@ -183,7 +183,7 @@ public void testGetUsersStatusFilter() throws Exception { String[] userIds = users.stream().map(user -> user.id).toArray(String[]::new); storage.startTransaction(con -> { - storage.updateBulkImportUserStatus_Transaction(appIdentifierWithStorage, con, userIds, BULK_IMPORT_USER_STATUS.FAILED); + storage.updateBulkImportUserStatus_Transaction(appIdentifierWithStorage, con, userIds, BULK_IMPORT_USER_STATUS.FAILED, null); storage.commitTransaction(con); return null; }); diff --git a/src/test/java/io/supertokens/test/bulkimport/BulkImportTestUtils.java b/src/test/java/io/supertokens/test/bulkimport/BulkImportTestUtils.java index 1aecc66c6..70fd21a0a 100644 --- a/src/test/java/io/supertokens/test/bulkimport/BulkImportTestUtils.java +++ b/src/test/java/io/supertokens/test/bulkimport/BulkImportTestUtils.java @@ -26,6 +26,7 @@ import io.supertokens.pluginInterface.bulkimport.BulkImportUser; import io.supertokens.pluginInterface.bulkimport.BulkImportUser.LoginMethod; import io.supertokens.pluginInterface.bulkimport.BulkImportUser.TotpDevice; +import io.supertokens.pluginInterface.bulkimport.BulkImportUser.UserRole; public class BulkImportTestUtils { public static List generateBulkImportUser(int numberOfUsers) { @@ -39,15 +40,18 @@ public static List generateBulkImportUser(int numberOfUsers) { JsonObject userMetadata = parser.parse("{\"key1\":\"value1\",\"key2\":{\"key3\":\"value3\"}}").getAsJsonObject(); - List userRoles = new ArrayList<>(); + List userRoles = new ArrayList<>(); + userRoles.add(new UserRole("role1", List.of("public"))); + userRoles.add(new UserRole("role2", List.of("public"))); List totpDevices = new ArrayList<>(); totpDevices.add(new TotpDevice("secretKey", 30, 1, "deviceName")); List loginMethods = new ArrayList<>(); - loginMethods.add(new LoginMethod("public", "emailpassword", true, true, 0, email, "$2a", "BCRYPT", null, null, null)); - loginMethods.add(new LoginMethod("public", "thirdparty", true, false, 0, email, null, null, "thirdPartyId", "thirdPartyUserId", null)); - loginMethods.add(new LoginMethod("public", "passwordless", true, false, 0, email, null, null, null, null, "+911234567890")); + long currentTimeMillis = System.currentTimeMillis(); + loginMethods.add(new LoginMethod(List.of("public", "t1"), "emailpassword", true, true, currentTimeMillis, email, "$2a", "BCRYPT", null, null, null)); + loginMethods.add(new LoginMethod(List.of("public", "t1"), "thirdparty", true, false, currentTimeMillis, email, null, null, "thirdPartyId", "thirdPartyUserId", null)); + loginMethods.add(new LoginMethod(List.of("public", "t1"), "passwordless", true, false, currentTimeMillis, email, null, null, null, null, "+911234567890")); users.add(new BulkImportUser(id, externalId, userMetadata, userRoles, totpDevices, loginMethods)); } return users; diff --git a/src/test/java/io/supertokens/test/bulkimport/apis/AddBulkImportUsersTest.java b/src/test/java/io/supertokens/test/bulkimport/apis/AddBulkImportUsersTest.java index b3e37cbf3..3303ebca7 100644 --- a/src/test/java/io/supertokens/test/bulkimport/apis/AddBulkImportUsersTest.java +++ b/src/test/java/io/supertokens/test/bulkimport/apis/AddBulkImportUsersTest.java @@ -18,7 +18,9 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; +import java.io.IOException; import java.util.HashMap; import java.util.UUID; @@ -33,14 +35,28 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import io.supertokens.Main; import io.supertokens.ProcessState; import io.supertokens.featureflag.EE_FEATURES; import io.supertokens.featureflag.FeatureFlagTestContent; +import io.supertokens.featureflag.exceptions.FeatureNotEnabledException; +import io.supertokens.multitenancy.Multitenancy; +import io.supertokens.multitenancy.exception.BadPermissionException; +import io.supertokens.multitenancy.exception.CannotModifyBaseConfigException; import io.supertokens.pluginInterface.STORAGE_TYPE; +import io.supertokens.pluginInterface.exceptions.InvalidConfigException; +import io.supertokens.pluginInterface.exceptions.StorageQueryException; +import io.supertokens.pluginInterface.multitenancy.EmailPasswordConfig; +import io.supertokens.pluginInterface.multitenancy.PasswordlessConfig; +import io.supertokens.pluginInterface.multitenancy.TenantConfig; +import io.supertokens.pluginInterface.multitenancy.TenantIdentifier; +import io.supertokens.pluginInterface.multitenancy.ThirdPartyConfig; +import io.supertokens.pluginInterface.multitenancy.exceptions.TenantOrAppNotFoundException; import io.supertokens.storageLayer.StorageLayer; import io.supertokens.test.TestingProcessManager; import io.supertokens.test.Utils; import io.supertokens.test.httpRequest.HttpRequestForTesting; +import io.supertokens.thirdparty.InvalidProviderConfigException; import io.supertokens.userroles.UserRoles; public class AddBulkImportUsersTest { @@ -72,6 +88,11 @@ public void shouldThrow400Error() throws Exception { return; } + // Create user roles + { + UserRoles.createNewRoleOrModifyItsPermissions(process.getProcess(), "role1", null); + } + String genericErrMsg = "Data has missing or invalid fields. Please check the users field for more details."; // users is required in the json body @@ -82,6 +103,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -93,6 +115,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -107,6 +130,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -119,6 +143,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -131,6 +156,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -146,11 +172,27 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); + } catch (io.supertokens.test.httpRequest.HttpResponseException e) { + String responseString = getResponseMessageFromError(e.getMessage()); + assertEquals(400, e.statusCode); + assertEquals(responseString, + "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"externalUserId should be of type string.\",\"userRoles should be of type array of object.\",\"totpDevices should be of type array of object.\",\"loginMethods is required.\"]}]}"); + } + // Non-unique externalUserIds + try { + JsonObject request = new JsonParser() + .parse("{\"users\":[{\"externalUserId\":\"id1\"}, {\"externalUserId\":\"id1\"}]}") + .getAsJsonObject(); + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/bulk-import/users", + request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); assertEquals(responseString, - "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"externalUserId should be of type string.\",\"userRoles should be of type array of string.\",\"totpDevices should be of type array of object.\",\"loginMethods is required.\"]}]}"); + "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"loginMethods is required.\"]},{\"index\":1,\"errors\":[\"loginMethods is required.\",\"externalUserId id1 is not unique. It is already used by another user.\"]}]}"); } // secretKey is required in totpDevices try { @@ -160,20 +202,38 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); assertEquals(responseString, "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"secretKey is required for a totp device.\",\"loginMethods is required.\"]}]}"); } - // Invalid role (does not exist) + // Invalid role (tenantIds is required) try { JsonObject request = new JsonParser() - .parse("{\"users\":[{\"userRoles\":[\"role5\"]}]}") + .parse("{\"users\":[{\"userRoles\":[{\"role\":\"role1\"}]}]}") .getAsJsonObject(); HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); + } catch (io.supertokens.test.httpRequest.HttpResponseException e) { + String responseString = getResponseMessageFromError(e.getMessage()); + assertEquals(400, e.statusCode); + + assertEquals(responseString, + "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"tenantIds is required for a user role.\",\"loginMethods is required.\"]}]}"); + } + // Invalid role (role doesn't exist) + try { + JsonObject request = new JsonParser() + .parse("{\"users\":[{\"userRoles\":[{\"role\":\"role5\", \"tenantIds\": [\"public\"]}]}]}") + .getAsJsonObject(); + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/bulk-import/users", + request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -186,16 +246,17 @@ public void shouldThrow400Error() throws Exception { { try { JsonObject request = new JsonParser().parse( - "{\"users\":[{\"loginMethods\":[{\"recipeId\":[],\"tenantId\":[],\"isPrimary\":[],\"isVerified\":[],\"timeJoinedInMSSinceEpoch\":[]}]}]}") + "{\"users\":[{\"loginMethods\":[{\"recipeId\":[],\"tenantIds\":{},\"isPrimary\":[],\"isVerified\":[],\"timeJoinedInMSSinceEpoch\":[]}]}]}") .getAsJsonObject(); HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); assertEquals(responseString, - "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"recipeId should be of type string for a loginMethod.\",\"tenantId should be of type string for a loginMethod.\",\"isVerified should be of type boolean for a loginMethod.\",\"isPrimary should be of type boolean for a loginMethod.\",\"timeJoinedInMSSinceEpoch should be of type integer for a loginMethod\"]}]}"); + "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"recipeId should be of type string for a loginMethod.\",\"tenantIds should be of type array of string for a loginMethod.\",\"isVerified should be of type boolean for a loginMethod.\",\"isPrimary should be of type boolean for a loginMethod.\",\"timeJoinedInMSSinceEpoch should be of type integer for a loginMethod\"]}]}"); } } // Invalid recipeId @@ -207,6 +268,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -223,6 +285,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -237,6 +300,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -251,6 +315,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -267,6 +332,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -281,6 +347,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -297,6 +364,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -311,46 +379,70 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); assertEquals(responseString, - "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"email should be of type string for a passwordless recipe.\",\"phoneNumber should be of type string for a passwordless recipe.\"]}]}"); + "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"email should be of type string for a passwordless recipe.\",\"phoneNumber should be of type string for a passwordless recipe.\",\"Either email or phoneNumber is required for a passwordless recipe.\"]}]}"); } } // Validate tenantId { - // CASE 1: Different tenantId when multitenancy is not enabled + // CASE 1: Invalid tenantId when multitenancy is not enabled try { JsonObject request = new JsonParser().parse( - "{\"users\":[{\"loginMethods\":[{\"tenantId\":\"invalid\",\"recipeId\":\"passwordless\",\"email\":\"johndoe@gmail.com\"}]}]}") + "{\"users\":[{\"loginMethods\":[{\"tenantIds\":[\"invalid\"],\"recipeId\":\"passwordless\",\"email\":\"johndoe@gmail.com\"}]}]}") .getAsJsonObject(); HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); assertEquals(responseString, "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"Multitenancy must be enabled before importing users to a different tenant.\"]}]}"); } - // CASE 2: Different tenantId when multitenancy is enabled + // CASE 2: Invalid tenantId when multitenancy is enabled try { FeatureFlagTestContent.getInstance(process.getProcess()) .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); JsonObject request = new JsonParser().parse( - "{\"users\":[{\"loginMethods\":[{\"tenantId\":\"invalid\",\"recipeId\":\"passwordless\",\"email\":\"johndoe@gmail.com\"}]}]}") + "{\"users\":[{\"loginMethods\":[{\"tenantIds\":[\"invalid\"],\"recipeId\":\"passwordless\",\"email\":\"johndoe@gmail.com\"}]}]}") .getAsJsonObject(); HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); assertEquals(responseString, "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"Invalid tenantId: invalid for passwordless recipe.\"]}]}"); } + // CASE 3. Two more tenants do not share the same storage + try { + FeatureFlagTestContent.getInstance(process.getProcess()) + .setKeyValue(FeatureFlagTestContent.ENABLED_FEATURES, new EE_FEATURES[]{EE_FEATURES.MULTI_TENANCY}); + + createTenants(process.getProcess()); + + JsonObject request = new JsonParser().parse( + "{\"users\":[{\"loginMethods\":[{\"tenantIds\":[\"public\"],\"recipeId\":\"passwordless\",\"email\":\"johndoe@gmail.com\"}, {\"tenantIds\":[\"t2\"],\"recipeId\":\"thirdparty\", \"email\":\"johndoe@gmail.com\", \"thirdPartyId\":\"id\", \"thirdPartyUserId\":\"id\"}]}]}") + .getAsJsonObject(); + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/bulk-import/users", + request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); + } catch (io.supertokens.test.httpRequest.HttpResponseException e) { + String responseString = getResponseMessageFromError(e.getMessage()); + assertEquals(400, e.statusCode); + assertEquals(responseString, + "{\"error\":\"" + genericErrMsg + "\",\"users\":[{\"index\":0,\"errors\":[\"All tenants for a user must share the same storage.\"]}]}"); + } } // No two loginMethods can have isPrimary as true { @@ -361,6 +453,7 @@ public void shouldThrow400Error() throws Exception { HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -372,9 +465,10 @@ public void shouldThrow400Error() throws Exception { { try { JsonObject request = generateUsersJson(0); - HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/bulk-import/users", - request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/bulk-import/users", + request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -385,9 +479,10 @@ public void shouldThrow400Error() throws Exception { { try { JsonObject request = generateUsersJson(10001); - HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", - "http://localhost:3567/bulk-import/users", - request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + HttpRequestForTesting.sendJsonPOSTRequest(process.getProcess(), "", + "http://localhost:3567/bulk-import/users", + request, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { String responseString = getResponseMessageFromError(e.getMessage()); assertEquals(400, e.statusCode); @@ -495,15 +590,16 @@ public static JsonObject generateUsersJson(int numberOfUsers) { user.addProperty("externalUserId", UUID.randomUUID().toString()); user.add("userMetadata", parser.parse("{\"key1\":\"value1\",\"key2\":{\"key3\":\"value3\"}}")); - user.add("userRoles", parser.parse("[\"role1\", \"role2\"]")); + user.add("userRoles", parser.parse("[{\"role\":\"role1\", \"tenantIds\": [\"public\"]},{\"role\":\"role2\", \"tenantIds\": [\"public\"]}]")); user.add("totpDevices", parser.parse("[{\"secretKey\":\"secretKey\",\"deviceName\":\"deviceName\"}]")); + JsonArray tenanatIds = parser.parse("[\"public\"]").getAsJsonArray(); String email = " johndoe+" + i + "@gmail.com "; JsonArray loginMethodsArray = new JsonArray(); - loginMethodsArray.add(createEmailLoginMethod(email)); - loginMethodsArray.add(createThirdPartyLoginMethod(email)); - loginMethodsArray.add(createPasswordlessLoginMethod(email)); + loginMethodsArray.add(createEmailLoginMethod(email, tenanatIds)); + loginMethodsArray.add(createThirdPartyLoginMethod(email, tenanatIds)); + loginMethodsArray.add(createPasswordlessLoginMethod(email, tenanatIds)); user.add("loginMethods", loginMethodsArray); usersArray.add(user); @@ -513,9 +609,9 @@ public static JsonObject generateUsersJson(int numberOfUsers) { return userJsonObject; } - private static JsonObject createEmailLoginMethod(String email) { + private static JsonObject createEmailLoginMethod(String email, JsonArray tenantIds) { JsonObject loginMethod = new JsonObject(); - loginMethod.addProperty("tenantId", "public"); + loginMethod.add("tenantIds", tenantIds); loginMethod.addProperty("email", email); loginMethod.addProperty("recipeId", "emailpassword"); loginMethod.addProperty("passwordHash", "$argon2d$v=19$m=12,t=3,p=1$aGI4enNvMmd0Zm0wMDAwMA$r6p7qbr6HD+8CD7sBi4HVw"); @@ -526,9 +622,9 @@ private static JsonObject createEmailLoginMethod(String email) { return loginMethod; } - private static JsonObject createThirdPartyLoginMethod(String email) { + private static JsonObject createThirdPartyLoginMethod(String email, JsonArray tenantIds) { JsonObject loginMethod = new JsonObject(); - loginMethod.addProperty("tenantId", "public"); + loginMethod.add("tenantIds", tenantIds); loginMethod.addProperty("recipeId", "thirdparty"); loginMethod.addProperty("email", email); loginMethod.addProperty("thirdPartyId", "google"); @@ -539,9 +635,9 @@ private static JsonObject createThirdPartyLoginMethod(String email) { return loginMethod; } - private static JsonObject createPasswordlessLoginMethod(String email) { + private static JsonObject createPasswordlessLoginMethod(String email, JsonArray tenantIds) { JsonObject loginMethod = new JsonObject(); - loginMethod.addProperty("tenantId", "public"); + loginMethod.add("tenantIds", tenantIds); loginMethod.addProperty("email", email); loginMethod.addProperty("recipeId", "passwordless"); loginMethod.addProperty("phoneNumber", "+91-9999999999"); @@ -550,4 +646,47 @@ private static JsonObject createPasswordlessLoginMethod(String email) { loginMethod.addProperty("timeJoinedInMSSinceEpoch", 0); return loginMethod; } + + private void createTenants(Main main) + throws StorageQueryException, TenantOrAppNotFoundException, InvalidProviderConfigException, + FeatureNotEnabledException, IOException, InvalidConfigException, + CannotModifyBaseConfigException, BadPermissionException { + // User pool 1 - (null, null, null), (null, null, t1) + // User pool 2 - (null, null, t2) + + { // tenant 1 + TenantIdentifier tenantIdentifier = new TenantIdentifier(null, null, "t1"); + + Multitenancy.addNewOrUpdateAppOrTenant( + main, + new TenantIdentifier(null, null, null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + null, null, new JsonObject() + ) + ); + } + { // tenant 2 + JsonObject config = new JsonObject(); + TenantIdentifier tenantIdentifier = new TenantIdentifier(null, null, "t2"); + + StorageLayer.getStorage(new TenantIdentifier(null, null, null), main) + .modifyConfigToAddANewUserPoolForTesting(config, 1); + + Multitenancy.addNewOrUpdateAppOrTenant( + main, + new TenantIdentifier(null, null, null), + new TenantConfig( + tenantIdentifier, + new EmailPasswordConfig(true), + new ThirdPartyConfig(true, null), + new PasswordlessConfig(true), + null, null, config + ) + ); + } + } } diff --git a/src/test/java/io/supertokens/test/bulkimport/apis/GetBulkImportUsersTest.java b/src/test/java/io/supertokens/test/bulkimport/apis/GetBulkImportUsersTest.java index 8b40f96bf..181bcd336 100644 --- a/src/test/java/io/supertokens/test/bulkimport/apis/GetBulkImportUsersTest.java +++ b/src/test/java/io/supertokens/test/bulkimport/apis/GetBulkImportUsersTest.java @@ -18,6 +18,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.fail; import java.util.HashMap; import java.util.Map; @@ -72,7 +73,7 @@ public void shouldReturn400Error() throws Exception { HttpRequestForTesting.sendGETRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", params, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); - + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { assertEquals(400, e.statusCode); assertEquals( @@ -86,7 +87,7 @@ public void shouldReturn400Error() throws Exception { HttpRequestForTesting.sendGETRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", params, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); - + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { assertEquals(400, e.statusCode); assertEquals("Http error. Status Code: 400. Message: limit must a positive integer with min value 1", @@ -99,7 +100,7 @@ public void shouldReturn400Error() throws Exception { HttpRequestForTesting.sendGETRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", params, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); - + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { assertEquals(400, e.statusCode); assertEquals("Http error. Status Code: 400. Message: Max limit allowed is 500", e.getMessage()); @@ -111,7 +112,7 @@ public void shouldReturn400Error() throws Exception { HttpRequestForTesting.sendGETRequest(process.getProcess(), "", "http://localhost:3567/bulk-import/users", params, 1000, 1000, null, Utils.getCdiVersionStringLatestForTests(), null); - + fail("The API should have thrown an error"); } catch (io.supertokens.test.httpRequest.HttpResponseException e) { assertEquals(400, e.statusCode); assertEquals("Http error. Status Code: 400. Message: invalid pagination token", e.getMessage()); @@ -151,7 +152,7 @@ public void shouldReturn200Response() throws Exception { assertEquals(1, bulkImportUsers.size()); JsonObject bulkImportUserJson = bulkImportUsers.get(0).getAsJsonObject(); bulkImportUserJson.get("status").getAsString().equals("NEW"); - BulkImportUser.fromTesting_fromJson(bulkImportUserJson).toRawDataForDbStorage().equals(rawData); + BulkImportUser.forTesting_fromJson(bulkImportUserJson).toRawDataForDbStorage().equals(rawData); process.kill(); Assert.assertNotNull(process.checkOrWaitForEvent(ProcessState.PROCESS_STATE.STOPPED));