diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/AuthUser.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/AuthUser.java
index 8f474da115..460665ed94 100644
--- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/AuthUser.java
+++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/model/AuthUser.java
@@ -4,6 +4,7 @@
import lombok.*;
import org.apache.commons.lang3.StringUtils;
import org.lowcoder.domain.authentication.context.AuthRequestContext;
+import org.lowcoder.sdk.util.EmailUtils;
import java.util.Map;
import java.util.Optional;
@@ -40,13 +41,35 @@ public String getSource() {
return getAuthContext().getAuthConfig().getSource();
}
+ /**
+ * The connection to persist for this authenticated identity.
+ *
+ *
Every path that creates a connection goes through here -- form register, SSO just-in-time
+ * provisioning and {@code /tp/link} all end at this method -- so it is where connection email values
+ * are put into canonical form. Normalizing here rather than at each call site is what keeps newly
+ * written connections consistent without having to find every writer. It is not the only path that
+ * creates a user : SCIM provisioning builds a {@code User} directly and deliberately attaches no
+ * connection, so it normalizes its own address
+ * (see {@code UserController#createSCIMUserAndAddToOrg}).
+ *
+ *
{@code email}, when present, is an address, and is normalized. It is frequently absent: several
+ * providers leave the email claim unset and carry the address in {@code username} instead, so null
+ * must survive as null rather than becoming {@code ""}.
+ *
+ *
{@code rawId} is only an address when the source is {@code EMAIL}; for every other source it is
+ * the opaque IdP subject and is stored byte-for-byte, because normalizing it would let the subject
+ * {@code AbC} authenticate as {@code abc}.
+ */
public Connection toAuthConnection() {
return Connection.builder()
.authId(getAuthContext().getAuthConfig().getId())
.source(getSource())
.name(getUsername())
- .email(getEmail())
- .rawId(getUid())
+ // Null-preserving: an SSO provider that returns no email claim must leave this null, not "".
+ // An empty string is a value, and it would later look like an address that nothing can
+ // resolve.
+ .email(getEmail() == null ? null : EmailUtils.normalize(getEmail()))
+ .rawId(EmailUtils.normalizeIfEmailSource(getSource(), getUid()))
.avatar(getAvatar())
.orgIds(StringUtils.isBlank(getOrgId()) ? Set.of() : Set.of(getOrgId()))
.authConnectionAuthToken(Optional.ofNullable(authToken).map(ConnectionAuthToken::of).orElse(null))
diff --git a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/UserServiceImpl.java b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/UserServiceImpl.java
index 1df76dfccf..e5837a943b 100644
--- a/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/UserServiceImpl.java
+++ b/server/api-service/lowcoder-domain/src/main/java/org/lowcoder/domain/user/service/UserServiceImpl.java
@@ -33,6 +33,7 @@
import org.lowcoder.sdk.constants.WorkspaceMode;
import org.lowcoder.sdk.exception.BizError;
import org.lowcoder.sdk.exception.BizException;
+import org.lowcoder.sdk.util.EmailUtils;
import org.lowcoder.sdk.util.HashUtils;
import org.lowcoder.sdk.util.LocaleUtils;
import org.springframework.dao.DuplicateKeyException;
@@ -45,6 +46,7 @@
import java.security.SecureRandom;
import java.time.Instant;
+import java.util.Comparator;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.function.Function;
@@ -105,19 +107,93 @@ public Mono> getByIds(Collection ids) {
);
}
+ /**
+ * Resolves a connection by source and subject, tolerating the case in which it was stored.
+ *
+ * Two byte-exact probes rather than one normalized probe. Both are served by the existing
+ * {@code (connections.source, connections.rawId)} index, and between them they cover the two shapes that
+ * coexist while data is being cleaned up: rows written before normalization, which hold whatever casing
+ * the user typed, and rows written after it, which hold the normalized form.
+ *
+ *
A single normalized probe would be simpler and is what a fully migrated database wants -- but on an
+ * un-migrated one it silently stops finding every account whose stored {@code rawId} contains an
+ * uppercase letter, i.e. it locks those users out. This form has no such failure mode and needs no
+ * migration.
+ *
+ *
Known limitation. The second probe only helps when the STORED value is normalized. An account
+ * stored as {@code JoHn@DoE.com} is still not found by the input {@code john@doe.com}, because finding it
+ * would need a case-insensitive query, which MongoDB cannot serve from this index. So this closes
+ * duplicate creation going forward, and does not retroactively unify addresses that already differ in
+ * case. Converging those requires the backfill migration.
+ */
@Override
public Mono findBySourceAndId(String source, String sourceUuid) {
- return repository.findByConnections_SourceAndConnections_RawId(source, sourceUuid);
+ // normalizeIfEmailSource, never normalize: for a non-EMAIL source this value is the opaque IdP
+ // subject and must be matched byte-for-byte.
+ String normalized = EmailUtils.normalizeIfEmailSource(source, sourceUuid);
+ Mono asStored = repository.findByConnections_SourceAndConnections_RawId(source, sourceUuid);
+ if (StringUtils.equals(normalized, sourceUuid)) {
+ return asStored;
+ }
+ return asStored.switchIfEmpty(
+ repository.findByConnections_SourceAndConnections_RawId(source, normalized));
}
public Mono findByName(String rawUuid) {
return repository.findByName(rawUuid);
}
+ /** Same two-probe reasoning as {@link #findBySourceAndId}, including the same known limitation. */
@Override
public Mono findByEmailDeep(String email) {
if(StringUtils.isEmpty(email)) return Mono.empty();
- return repository.findByEmailOrConnections_Email(email, email).next();
+ String normalized = EmailUtils.normalize(email);
+ Mono asStored = probeByEmail(email);
+ if (StringUtils.equals(normalized, email)) {
+ return asStored;
+ }
+ return asStored.switchIfEmpty(probeByEmail(normalized));
+ }
+
+ /**
+ * Whether password recovery may act on this account.
+ *
+ * Resolving by address rather than by {@code user.name} reaches strictly more rows: any casing, and
+ * anything matched through {@code connections[].email}. Soft-deleted accounts keep their {@code email}
+ * -- {@code markAsDeleted} only changes the state, the enabled flag and the connection sources -- so
+ * without this they become reachable targets for a reset. They cannot log in afterwards, so this is
+ * tidiness rather than a hole, but widening what an unauthenticated endpoint can mutate is not
+ * something to do by accident.
+ */
+ private static boolean canRecoverPassword(User user) {
+ return user.getState() != UserState.DELETED;
+ }
+
+ /**
+ * One probe of the email keyspace, resolved deterministically.
+ *
+ *
This used to be {@code .next()} on the repository's {@link Flux}, which returns whichever document
+ * storage happened to yield first. That was tolerable while only SSO linking and SCIM used this lookup,
+ * and is not now that password recovery does: the backfill deliberately leaves conflicting accounts in
+ * place, so two accounts matching one address is a state the system is designed to keep, and picking
+ * between them at random would make the reset target a coin flip.
+ */
+ private Mono probeByEmail(String value) {
+ return repository.findByEmailOrConnections_Email(value, value)
+ .collectList()
+ .flatMap(candidates -> Mono.justOrEmpty(pickDeterministically(candidates, value)));
+ }
+
+ /**
+ * An account whose own {@code email} matches wins over one reached only through a connection -- that is
+ * the account a human means by the address -- and identity breaks any remaining tie.
+ */
+ static User pickDeterministically(List candidates, String value) {
+ return candidates.stream()
+ .min(Comparator
+ .comparingInt((User user) -> StringUtils.equals(user.getEmail(), value) ? 0 : 1)
+ .thenComparing(user -> StringUtils.defaultString(user.getId())))
+ .orElse(null);
}
@Override
@@ -166,7 +242,10 @@ public Mono findByAuthUserRawId(AuthUser authUser) {
public Mono createNewUserByAuthUser(AuthUser authUser, boolean isSuperAdmin) {
User.UserBuilder userBuilder = User.builder()
.name(authUser.getUsername())
- .email(authUser.getEmail())
+ // Normalized on write, null-preserving: an SSO provider that returns no email claim must
+ // leave this null rather than "". user.name is left alone -- it is a display name, and for
+ // SSO users it is a handle rather than an address.
+ .email(authUser.getEmail() == null ? null : EmailUtils.normalize(authUser.getEmail()))
.state(UserState.ACTIVATED)
.superAdmin(isSuperAdmin)
.isEnabled(true)
@@ -198,21 +277,70 @@ public Mono getUserAvatar(ServerWebExchange exchange, String userId) {
.flatMap(user -> assetService.makeImageResponse(exchange, user.getAvatar()));
}
+ /**
+ * Attaches an email address to an account that authenticated some other way.
+ *
+ * Two guards, both of which the original had no equivalent for.
+ *
+ *
Shape. The endpoint takes a bare {@code @RequestParam} and validated nothing, so without
+ * this an empty string or a display name is stored as an address -- in {@code user.email} AND as a
+ * connection {@code rawId} the form login then resolves against. Registration validates shape; binding
+ * must too.
+ *
+ *
Ownership. The unique index on {@code (connections.source, connections.rawId)} used to be
+ * what stopped two accounts holding one address: a second bind of the same value collided and surfaced
+ * as {@link BizError#ALREADY_BIND}. Normalizing the value is exactly what stops that index from
+ * catching it -- binding {@code Legacy@Example.COM} now writes {@code legacy@example.com}, a different
+ * key, so an account already stored under the typed casing no longer collides. Without the probe
+ * below, adding normalization would therefore have removed a guarantee that existed before it.
+ * The probe restores it: fed the RAW value, the two-probe lookup reaches both the pre-normalization
+ * casing and the canonical one.
+ *
+ *
What it does not cover, for the same reason no lookup on this branch does: an address
+ * stored in mixed case is not reachable from lowercase input, so binding {@code legacy@example.com}
+ * while another account holds {@code Legacy@Example.COM} still writes a second row. That hole is
+ * unchanged from before this commit -- the byte-exact index never caught that pairing either. Changeset
+ * {@code 032} converges such rows, so what is left is the owner the backfill deliberately froze as part
+ * of a reported conflict, or an install where it has not run yet. It is pinned by
+ * {@code EmailSanitizationEndpointsTest#bindEmailStillMissesALegacyMixedCaseOwnerFromLowercaseInput}.
+ */
@Override
public Mono bindEmail(User user, String email) {
+ String normalized = EmailUtils.normalize(email);
+ if (!EmailUtils.looksLikeEmail(normalized)) {
+ return Mono.error(new BizException(BizError.INVALID_EMAIL_FORMAT, "INVALID_EMAIL_FORMAT"));
+ }
+ return findBySourceAndId(AuthSourceConstants.EMAIL, email)
+ .flatMap(owner -> {
+ if (StringUtils.equals(owner.getId(), user.getId())) {
+ // Already this user's address. Writing again would append a second EMAIL connection
+ // differing from the first only in case, which is the very state this change exists
+ // to prevent, so the bind is idempotent instead.
+ return Mono.just(true);
+ }
+ return Mono.error(
+ new BizException(BizError.ALREADY_BIND, "ALREADY_BIND", normalized, ""));
+ })
+ .switchIfEmpty(Mono.defer(() -> doBindEmail(user, normalized)));
+ }
+
+ /** The write half of {@link #bindEmail}, deferred so it does not run while the ownership probe is still pending. */
+ private Mono doBindEmail(User user, String normalized) {
Connection connection = Connection.builder()
.source(AuthSourceConstants.EMAIL)
- .name(email)
- .rawId(email)
- .email(email)
+ .name(normalized)
+ .rawId(normalized)
+ .email(normalized)
.build();
user.getConnections().add(connection);
- user.setEmail(email);
+ user.setEmail(normalized);
return repository.save(user)
.then(Mono.just(true))
+ // Still needed: the probe above is not atomic with the save, so a concurrent bind of the
+ // same address can still land between the two and be caught only by the index.
.onErrorResume(throwable -> {
if (throwable instanceof DuplicateKeyException) {
- return Mono.error(new BizException(BizError.ALREADY_BIND, "ALREADY_BIND", email, ""));
+ return Mono.error(new BizException(BizError.ALREADY_BIND, "ALREADY_BIND", normalized, ""));
}
return Mono.error(throwable);
});
@@ -287,7 +415,9 @@ public Mono resetPassword(String userId) {
@Override
public Mono lostPassword(String userEmail) {
- return findByName(userEmail)
+ return findByEmailDeep(userEmail)
+ .switchIfEmpty(Mono.defer(() -> findByName(userEmail)))
+ .filter(UserServiceImpl::canRecoverPassword)
.zipWhen(user -> orgMemberService.getCurrentOrgMember(user.getId())
.flatMap(orgMember -> organizationService.getById(orgMember.getOrgId()))
.map(organization -> organization.getCommonSettings().getOrDefault(PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT, PASSWORD_RESET_EMAIL_TEMPLATE_DEFAULT)))
@@ -308,9 +438,15 @@ public Mono lostPassword(String userEmail) {
@Override
public Mono resetLostPassword(String userEmail, String token, String newPassword) {
- return findByName(userEmail)
+ return findByEmailDeep(userEmail)
+ .switchIfEmpty(Mono.defer(() -> findByName(userEmail)))
+ .filter(UserServiceImpl::canRecoverPassword)
.flatMap(user -> {
- if (Instant.now().until(user.getPasswordResetTokenExpiry(), ChronoUnit.MINUTES) <= 0) {
+ // Null when this account never requested a reset. Previously an NPE -- and a 500 on an
+ // unauthenticated endpoint -- reachable for any account resolvable by name; resolving by
+ // address reaches strictly more accounts, so guard it rather than widen the hole.
+ if (user.getPasswordResetTokenExpiry() == null
+ || Instant.now().until(user.getPasswordResetTokenExpiry(), ChronoUnit.MINUTES) <= 0) {
return ofError(BizError.INVALID_PARAMETER, "TOKEN_EXPIRED");
}
diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java
index 6319237662..d7ecb6f1ff 100644
--- a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java
+++ b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/exception/BizError.java
@@ -104,6 +104,7 @@ public enum BizError {
ID_NOT_EXIST(500, 5620),
DUPLICATE_AUTH_CONFIG_ADDITION(400, 5621),
EMAIL_PROVIDER_DISABLED(403, 5622),
+ INVALID_EMAIL_FORMAT(400, 5623),
// asset related, code range 5700 - 5799
diff --git a/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/EmailUtils.java b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/EmailUtils.java
new file mode 100644
index 0000000000..8c907c7054
--- /dev/null
+++ b/server/api-service/lowcoder-sdk/src/main/java/org/lowcoder/sdk/util/EmailUtils.java
@@ -0,0 +1,84 @@
+package org.lowcoder.sdk.util;
+
+import java.util.Locale;
+
+import org.apache.commons.lang3.StringUtils;
+import org.lowcoder.sdk.constants.AuthSourceConstants;
+
+/**
+ * Canonical form for email addresses, and the shape check that decides whether a value is one.
+ *
+ * Addresses are case-insensitive in practice, so {@code john@doe.com} and {@code JoHn@DoE.com} are one
+ * identity. Nothing in this codebase enforced that: no normalization anywhere, and no MongoDB collation, so
+ * the two registered as two separate accounts.
+ */
+public final class EmailUtils {
+
+ private static final char AT = '@';
+
+ private EmailUtils() {
+ }
+
+ /**
+ * Trim, then lower-case. Null-safe: returns {@code ""} rather than null, so callers can compare without
+ * guarding.
+ *
+ *
{@link Locale#ROOT} is not optional. The no-argument {@code toLowerCase()} uses the default locale,
+ * and under a Turkish locale it maps {@code I} to the dotless {@code ı} -- so the same address would
+ * canonicalize differently depending on the JVM's locale, which is exactly the class of bug this method
+ * exists to remove.
+ */
+ public static String normalize(String value) {
+ if (value == null) {
+ return StringUtils.EMPTY;
+ }
+ return value.trim().toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Normalizes only when the value really is an email address.
+ *
+ *
A connection's {@code rawId} holds the address for {@code EMAIL} connections and the opaque IdP
+ * subject for every other source. Those subjects are case-sensitive: normalizing one would let the
+ * subject {@code AbC} authenticate as {@code abc}. So the source, not the field, decides.
+ *
+ *
Unlike {@link #normalize}, this is null-preserving : null in, null out, for every source. It
+ * is a field sanitizer -- it cleans a value that is being stored or matched, and must not invent a
+ * value where there was none, because {@code ""} is a value and would later read as an address that
+ * nothing can resolve. Use {@code normalize} instead when you need a non-null canonical form to
+ * compare against.
+ */
+ public static String normalizeIfEmailSource(String source, String value) {
+ if (value == null) {
+ return null;
+ }
+ return AuthSourceConstants.EMAIL.equals(source) ? normalize(value) : value;
+ }
+
+ /**
+ * Whether this value is shaped like an email address: exactly one {@code @}, a non-empty local part, a
+ * non-empty domain part, and no whitespace anywhere.
+ *
+ *
Deliberately no dot-in-domain requirement. {@code root@localhost} and {@code user@intranet}
+ * are real addresses on self-hosted instances, and rejecting them would lock those operators out.
+ *
+ *
This is a shape check, not a validity check -- it exists to keep values that are plainly not
+ * addresses (a GitHub handle, a display name, an empty string) out of fields the login paths treat as
+ * addresses. It is not an attempt to implement RFC 5322.
+ */
+ public static boolean looksLikeEmail(String value) {
+ if (StringUtils.isBlank(value)) {
+ return false;
+ }
+ int at = value.indexOf(AT);
+ // no '@', or an empty local part
+ if (at <= 0) {
+ return false;
+ }
+ // more than one '@', or an empty domain part
+ if (at != value.lastIndexOf(AT) || at == value.length() - 1) {
+ return false;
+ }
+ return !StringUtils.containsWhitespace(value);
+ }
+}
diff --git a/server/api-service/lowcoder-sdk/src/main/resources/locale_de.properties b/server/api-service/lowcoder-sdk/src/main/resources/locale_de.properties
index ed265a0807..931d8cd84b 100644
--- a/server/api-service/lowcoder-sdk/src/main/resources/locale_de.properties
+++ b/server/api-service/lowcoder-sdk/src/main/resources/locale_de.properties
@@ -100,6 +100,7 @@ CERTIFICATE_EMPTY=Zertifikat ist leer.
USER_NOT_EXIST=Benutzer existiert nicht.
DUPLICATE_AUTH_CONFIG_ADDITION=Dieser Anbieter wurde bereits zur Organisation hinzugefügt.
EMAIL_PROVIDER_DISABLED=E-Mail-Anbieter ist deaktiviert.
+INVALID_EMAIL_FORMAT=Bitte geben Sie eine gültige E-Mail-Adresse ein.
SLUG_DUPLICATE_ENTRY=Slug existiert bereits.
SLUG_INVALID=Slug-Format ist ungültig.
FLOW_ERROR=Flow-Fehlermeldung: {0}.
diff --git a/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties b/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties
index f72df80a67..69afb3d0cf 100644
--- a/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties
+++ b/server/api-service/lowcoder-sdk/src/main/resources/locale_en.properties
@@ -283,6 +283,7 @@ DISABLE_AUTH_CONFIG_FORBIDDEN=Can not disable current administrator''s last iden
USER_NOT_EXIST=User not exist.
DUPLICATE_AUTH_CONFIG_ADDITION=Provider auth type already added to organization
EMAIL_PROVIDER_DISABLED=Email provider is disabled.
+INVALID_EMAIL_FORMAT=Please enter a valid email address.
SLUG_DUPLICATE_ENTRY=Slug already exists
SLUG_INVALID=Slug format is invalid
FLOW_ERROR=Flow error message: {0}
diff --git a/server/api-service/lowcoder-sdk/src/main/resources/locale_zh.properties b/server/api-service/lowcoder-sdk/src/main/resources/locale_zh.properties
index fbb2598363..4a03fc8b6d 100644
--- a/server/api-service/lowcoder-sdk/src/main/resources/locale_zh.properties
+++ b/server/api-service/lowcoder-sdk/src/main/resources/locale_zh.properties
@@ -283,6 +283,7 @@ DISABLE_AUTH_CONFIG_FORBIDDEN=无法禁用当前管理员的最后一个身份
USER_NOT_EXIST=用户不存在。
DUPLICATE_AUTH_CONFIG_ADDITION=提供商身份验证类型已添加到组织中
EMAIL_PROVIDER_DISABLED=电子邮件提供商已禁用。
+INVALID_EMAIL_FORMAT=请输入有效的电子邮件地址。
SLUG_DUPLICATE_ENTRY=蛞蝓已经存在
SLUG_INVALID=Slug 格式无效
FLOW_ERROR=流程错误消息:{0}
\ No newline at end of file
diff --git a/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java
new file mode 100644
index 0000000000..f13679627a
--- /dev/null
+++ b/server/api-service/lowcoder-sdk/src/test/java/org/lowcoder/sdk/util/EmailUtilsTest.java
@@ -0,0 +1,227 @@
+package org.lowcoder.sdk.util;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Locale;
+
+import org.junit.After;
+import org.junit.Test;
+import org.lowcoder.sdk.constants.AuthSourceConstants;
+
+/**
+ * The form-login and SSO paths resolve an account by address through {@link EmailUtils}, so the exact
+ * behaviour of these three methods -- including their null and locale edges -- decides who can log in.
+ * (Password recovery is the exception: {@code UserServiceImpl#lostPassword} still resolves by
+ * {@code user.name} and does not go through this class.)
+ *
+ *
Each assertion prints what it checked, so a failure in CI shows the offending value rather than just a
+ * line number.
+ */
+public class EmailUtilsTest {
+
+ private static final String CANONICAL = "john@doe.com";
+ private static final String MIXED_CASE = "JoHn@DoE.CoM";
+ private static final String GITHUB_SUBJECT = "AbC123";
+
+ /** Restores whatever locale the JVM started with, so the Turkish test cannot leak into the others. */
+ private final Locale originalDefault = Locale.getDefault();
+
+ @After
+ public void restoreDefaultLocale() {
+ Locale.setDefault(originalDefault);
+ }
+
+ private void checkNormalize(String input, String expected) {
+ String actual = EmailUtils.normalize(input);
+ System.out.printf("normalize(%s) -> %s (expected %s)%n",
+ quote(input), quote(actual), quote(expected));
+ assertEquals("normalize(" + quote(input) + ")", expected, actual);
+ }
+
+ private void checkLooksLikeEmail(String input, boolean expected) {
+ boolean actual = EmailUtils.looksLikeEmail(input);
+ System.out.printf("looksLikeEmail(%s) -> %s (expected %s)%n", quote(input), actual, expected);
+ assertEquals("looksLikeEmail(" + quote(input) + ")", expected, actual);
+ }
+
+ private static String quote(String value) {
+ return value == null ? "null" : "\"" + value + "\"";
+ }
+
+ // ---------------------------------------------------------------- normalize
+
+ @Test
+ public void normalizeLowerCasesAndTrims() {
+ checkNormalize(MIXED_CASE, CANONICAL);
+ checkNormalize(" " + MIXED_CASE + " ", CANONICAL);
+ checkNormalize("\t" + CANONICAL + "\n", CANONICAL);
+ checkNormalize(CANONICAL, CANONICAL);
+ }
+
+ /**
+ * The whole point of normalizing is that it converges. Applying it twice must not move the value again,
+ * or a value normalized on write would stop matching the same value normalized on read.
+ */
+ @Test
+ public void normalizeIsIdempotent() {
+ String once = EmailUtils.normalize(" " + MIXED_CASE + " ");
+ String twice = EmailUtils.normalize(once);
+ System.out.printf("normalize twice: %s -> %s -> %s%n", quote(MIXED_CASE), quote(once), quote(twice));
+ assertEquals(once, twice);
+ }
+
+ /** Null-safe by contract: callers compare the result without guarding, so it must never be null. */
+ @Test
+ public void normalizeReturnsEmptyStringForNullAndBlank() {
+ checkNormalize(null, "");
+ checkNormalize("", "");
+ checkNormalize(" ", "");
+ }
+
+ /**
+ * {@code String.trim()} only strips characters at or below U+0020, so interior whitespace survives. That
+ * is deliberate -- {@link EmailUtils#looksLikeEmail} is what rejects such values; normalize does not
+ * silently repair them into something that looks valid.
+ */
+ @Test
+ public void normalizeDoesNotStripInteriorWhitespace() {
+ checkNormalize(" John Doe@Example.com ", "john doe@example.com");
+ assertFalse("a value with interior whitespace must still fail the shape check",
+ EmailUtils.looksLikeEmail(EmailUtils.normalize(" John Doe@Example.com ")));
+ }
+
+ /**
+ * The reason {@code toLowerCase(Locale.ROOT)} is spelled out in the implementation. Under a Turkish
+ * locale the no-argument {@code toLowerCase()} maps {@code I} to the dotless {@code ı} (U+0131), so
+ * {@code INFO@X.COM} would canonicalize differently depending on the JVM's locale -- two canonical forms
+ * for one address, which is exactly the bug this class exists to remove.
+ */
+ @Test
+ public void normalizeIsIndependentOfTheDefaultLocale() {
+ Locale.setDefault(new Locale("tr", "TR"));
+
+ String localeSensitive = "INFO@EXAMPLE.COM".toLowerCase();
+ String normalized = EmailUtils.normalize("INFO@EXAMPLE.COM");
+ System.out.printf("under tr-TR: toLowerCase() -> %s, EmailUtils.normalize() -> %s%n",
+ quote(localeSensitive), quote(normalized));
+
+ assertEquals("normalize must use Locale.ROOT", "info@example.com", normalized);
+ assertFalse("sanity: the default-locale lower-case really does differ under tr-TR, "
+ + "so this test is actually exercising the difference",
+ localeSensitive.equals(normalized));
+ }
+
+ // -------------------------------------------------- normalizeIfEmailSource
+
+ @Test
+ public void normalizeIfEmailSourceNormalizesOnlyEmailConnections() {
+ String normalized = EmailUtils.normalizeIfEmailSource(AuthSourceConstants.EMAIL, " " + MIXED_CASE + " ");
+ System.out.printf("normalizeIfEmailSource(EMAIL, %s) -> %s%n", quote(MIXED_CASE), quote(normalized));
+ assertEquals(CANONICAL, normalized);
+ }
+
+ /**
+ * For every non-EMAIL source the value is the IdP's opaque subject, not an address. Lower-casing one
+ * would let the subject {@code AbC123} authenticate as {@code abc123} -- a different account at the
+ * provider.
+ */
+ @Test
+ public void normalizeIfEmailSourceLeavesOpaqueSubjectsByteExact() {
+ for (String source : new String[] {
+ AuthSourceConstants.GOOGLE,
+ AuthSourceConstants.GITHUB,
+ AuthSourceConstants.ORY,
+ AuthSourceConstants.KEYCLOAK,
+ "SOME_FUTURE_PROVIDER",
+ null }) {
+ String actual = EmailUtils.normalizeIfEmailSource(source, GITHUB_SUBJECT);
+ System.out.printf("normalizeIfEmailSource(%s, %s) -> %s%n",
+ quote(source), quote(GITHUB_SUBJECT), quote(actual));
+ assertSame("source " + quote(source) + " must pass the subject through untouched",
+ GITHUB_SUBJECT, actual);
+ }
+ }
+
+ /**
+ * Null-preserving for every source, unlike {@link EmailUtils#normalize}. A connection whose {@code rawId}
+ * or {@code email} is absent must stay absent: {@code ""} is a value, and a stored {@code ""} address
+ * would read as an address that no lookup can ever resolve.
+ */
+ @Test
+ public void normalizeIfEmailSourcePreservesNull() {
+ for (String source : new String[] { AuthSourceConstants.EMAIL, AuthSourceConstants.GOOGLE, null }) {
+ String actual = EmailUtils.normalizeIfEmailSource(source, null);
+ System.out.printf("normalizeIfEmailSource(%s, null) -> %s%n", quote(source), quote(actual));
+ assertNull("null must survive for source " + quote(source), actual);
+ }
+ }
+
+ // ---------------------------------------------------------- looksLikeEmail
+
+ @Test
+ public void looksLikeEmailAcceptsRealAddresses() {
+ checkLooksLikeEmail(CANONICAL, true);
+ checkLooksLikeEmail("John.Doe+tag@sub.example.co.uk", true);
+ checkLooksLikeEmail("a@b", true);
+ }
+
+ /**
+ * No dot-in-domain requirement, on purpose. Self-hosted instances really do have accounts at
+ * {@code localhost} and at single-label intranet hosts, and rejecting those would lock those operators
+ * out of their own deployment.
+ */
+ @Test
+ public void looksLikeEmailAcceptsDotlessDomains() {
+ checkLooksLikeEmail("root@localhost", true);
+ checkLooksLikeEmail("user@intranet", true);
+ }
+
+ @Test
+ public void looksLikeEmailRejectsMissingOrEmptyParts() {
+ checkLooksLikeEmail(null, false);
+ checkLooksLikeEmail("", false);
+ checkLooksLikeEmail(" ", false);
+ checkLooksLikeEmail("notanemail", false);
+ checkLooksLikeEmail("@example.com", false);
+ checkLooksLikeEmail("user@", false);
+ checkLooksLikeEmail("@", false);
+ }
+
+ @Test
+ public void looksLikeEmailRejectsMultipleAtSigns() {
+ checkLooksLikeEmail("a@b@c.com", false);
+ checkLooksLikeEmail("user@@example.com", false);
+ }
+
+ /**
+ * Whitespace anywhere is a reject, including the leading and trailing kind. Callers must
+ * {@link EmailUtils#normalize} first -- that ordering is what the production call sites do, and this
+ * pins it so a future caller that forgets the trim gets a rejection rather than a stored
+ * {@code " john@doe.com "}.
+ */
+ @Test
+ public void looksLikeEmailRejectsAnyWhitespaceAndRequiresNormalizeFirst() {
+ checkLooksLikeEmail("John Doe@example.com", false);
+ checkLooksLikeEmail("user@exa mple.com", false);
+ checkLooksLikeEmail("user\t@example.com", false);
+ checkLooksLikeEmail(" " + CANONICAL + " ", false);
+
+ assertTrue("normalize() first is what makes a padded address acceptable",
+ EmailUtils.looksLikeEmail(EmailUtils.normalize(" " + CANONICAL + " ")));
+ }
+
+ /**
+ * The values that actually reached production as if they were addresses: a display name and an empty
+ * string, both written by endpoints that validated nothing.
+ */
+ @Test
+ public void looksLikeEmailRejectsTheValuesThatUsedToBeStoredAsAddresses() {
+ checkLooksLikeEmail("Iron Man", false);
+ checkLooksLikeEmail("octocat", false);
+ checkLooksLikeEmail("", false);
+ }
+}
diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/form/FormAuthRequest.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/form/FormAuthRequest.java
index 0aba244914..f9744202a4 100644
--- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/form/FormAuthRequest.java
+++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/request/form/FormAuthRequest.java
@@ -11,6 +11,7 @@
import org.lowcoder.sdk.constants.AuthSourceConstants;
import org.lowcoder.sdk.exception.BizError;
import org.lowcoder.sdk.exception.BizException;
+import org.lowcoder.sdk.util.EmailUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
@@ -37,6 +38,14 @@ public Mono auth(AuthRequestContext authRequestContext) {
if (AuthSourceConstants.EMAIL.equals(authConfig.getSource())
&& authConfig instanceof EmailAuthConfig emailAuthConfig
&& emailAuthConfig.isEnableRegister()) {
+ // Shape check before anything is created. A blank loginId would otherwise be
+ // stored as an address and then resolve on a subsequent login with loginId="".
+ if (!EmailUtils.looksLikeEmail(EmailUtils.normalize(context.getLoginId()))) {
+ return ofError(BizError.INVALID_EMAIL_FORMAT, "INVALID_EMAIL_FORMAT");
+ }
+ // Passed AS SUPPLIED on purpose: findBySourceAndId probes the value given and
+ // then its normalized form, so this now rejects JoHn@DoE.com when
+ // john@doe.com already exists -- the duplicate that used to get through.
return userService.findBySourceAndId(authConfig.getSource(), context.getLoginId())
.flatMap(user -> ofError(BizError.USER_LOGIN_ID_EXIST, "USER_LOGIN_ID_EXIST"));
}
diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java
index ff15c81879..e351f0b268 100644
--- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java
+++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/authentication/service/AuthenticationApiServiceImpl.java
@@ -41,6 +41,7 @@
import org.lowcoder.sdk.exception.BizException;
import org.lowcoder.sdk.models.HasIdAndAuditing;
import org.lowcoder.sdk.util.CookieHelper;
+import org.lowcoder.sdk.util.EmailUtils;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ServerWebExchange;
@@ -244,13 +245,38 @@ public void updateConnection(AuthUser authUser, User user) {
user.setActiveAuthId(oldConnection.getAuthId());
}
+ /**
+ * The connection this authenticated identity corresponds to.
+ *
+ * The subject comparison has to agree with how {@code findBySourceAndId} resolved the user, or the
+ * lookup finds an account and this then fails to find its connection. That is not hypothetical: the
+ * lookup now also matches a stored {@code john@doe.com} against the input {@code JoHn@DoE.com}, and a
+ * byte-exact comparison here would throw on exactly those logins.
+ *
+ *
{@code normalizeIfEmailSource} on both sides, so this is case-insensitive for EMAIL connections and
+ * byte-exact for every other source -- where {@code rawId} is an opaque IdP subject.
+ *
+ *
Exact match first, case-insensitive only as a fallback. {@code User.connections} is a
+ * {@code HashSet}, so its iteration order is not a contract; a single case-insensitive pass would pick
+ * an arbitrary one of two connections that differ only in case -- a state legacy data can be in. Trying
+ * the byte-exact match first makes the choice deterministic and gives the connection the caller
+ * actually authenticated with.
+ */
@SuppressWarnings("OptionalGetWithoutIsPresent")
protected Connection getAuthConnection(AuthUser authUser, User user) {
- return user.getConnections()
+ String subject = EmailUtils.normalizeIfEmailSource(authUser.getSource(), authUser.getUid());
+ List sameSource = user.getConnections()
.stream()
- .filter(connection -> authUser.getSource().equals(connection.getSource())
- && Objects.equals(connection.getRawId(), authUser.getUid()))
+ .filter(connection -> authUser.getSource().equals(connection.getSource()))
+ .toList();
+ return sameSource.stream()
+ .filter(connection -> Objects.equals(connection.getRawId(), authUser.getUid()))
.findFirst()
+ .or(() -> sameSource.stream()
+ .filter(connection -> Objects.equals(
+ EmailUtils.normalizeIfEmailSource(connection.getSource(), connection.getRawId()),
+ subject))
+ .findFirst())
.get();
}
diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/UserController.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/UserController.java
index f6bb280a0c..5e0d3656c0 100644
--- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/UserController.java
+++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/api/usermanagement/UserController.java
@@ -27,6 +27,7 @@
import org.lowcoder.sdk.constants.AuthSourceConstants;
import org.lowcoder.sdk.exception.BizError;
import org.lowcoder.sdk.exception.BizException;
+import org.lowcoder.sdk.util.EmailUtils;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.codec.multipart.Part;
@@ -75,12 +76,28 @@ public Mono> createUserAndAddToOrg(@PathVariable String orgId, C
@Override
public Mono> createSCIMUserAndAddToOrg(@PathVariable String orgId, CreateUserRequest request) {
+ // The third writer of an address, and the only one that validated nothing. A blank value creates a
+ // row whose email is "" -- which findByEmailDeep can never resolve, since it short-circuits on
+ // empty. The lookup below is fed the value AS SUPPLIED rather than the normalized one, so the
+ // two-probe lookup gets both chances: the typed casing byte-exact, then the canonical form. It
+ // does NOT find a row stored in some third casing -- e.g. stored "Stored@Example.COM" is not
+ // reachable from "stored@example.com". Changeset 032 converges such rows, so what is left is one
+ // the backfill deliberately froze as part of a reported conflict, or an install where 032 has not
+ // run yet. Only what gets written is normalized.
+ //
+ // Authorize first, validate second: a caller who is not an org admin must be turned away by
+ // checkVisitorAdminRole regardless of what they sent, and must not be able to tell a malformed
+ // payload from a well-formed one by the error they get back.
+ String normalizedEmail = EmailUtils.normalize(request.email());
return orgApiService.checkVisitorAdminRole(orgId)
.flatMap(__ -> {
+ if (!EmailUtils.looksLikeEmail(normalizedEmail)) {
+ return ofError(BizError.INVALID_EMAIL_FORMAT, "INVALID_EMAIL_FORMAT");
+ }
// For SCIM provisioning: Create a minimal user without auth connection
// The auth connection will be added on first SSO login via JIT provisioning
// This allows SCIM to pre-provision users while SSO handles authentication
-
+
// Check if user already exists by email
return userService.findByEmailDeep(request.email())
.flatMap(existingUser -> {
@@ -92,8 +109,8 @@ public Mono> createSCIMUserAndAddToOrg(@PathVariable String orgI
// Create new user without auth connection (placeholder for SSO)
Mono.defer(() -> {
User newUser = User.builder()
- .name(request.email())
- .email(request.email())
+ .name(normalizedEmail)
+ .email(normalizedEmail)
.isEnabled(true)
.build();
newUser.setConnections(new HashSet<>());
diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/DatabaseChangelog.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/DatabaseChangelog.java
index 02d3fb8712..2379b1b44f 100644
--- a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/DatabaseChangelog.java
+++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/DatabaseChangelog.java
@@ -35,6 +35,7 @@
import org.lowcoder.runner.migrations.job.AddPtmFieldsJob;
import org.lowcoder.runner.migrations.job.AddSuperAdminUser;
import org.lowcoder.runner.migrations.job.CompleteAuthType;
+import org.lowcoder.runner.migrations.job.EmailNormalizationBackfill;
import org.lowcoder.runner.migrations.job.MigrateAuthConfigJob;
import org.lowcoder.sdk.config.CommonConfig;
import org.springframework.context.annotation.Profile;
@@ -502,6 +503,16 @@ public void deleteOldSuperAdmin(MongockTemplate mongoTemplate, MongoDatabase mon
}
}
+ /**
+ * Backfills existing email addresses to the canonical form every write path has used since the
+ * sanitization change. Accounts that would collide are left as they are and reported; see
+ * {@link EmailNormalizationBackfill} and EMAIL-SANITIZATION.md.
+ */
+ @ChangeSet(order = "032", id = "normalize-email-backfill", author = "")
+ public void normalizeEmailBackfill(MongoDatabase mongoDatabase) {
+ EmailNormalizationBackfill.run(mongoDatabase);
+ }
+
private void addGidField(MongockTemplate mongoTemplate, String collectionName) {
// Create a query to match all documents
Query query = new Query();
diff --git a/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfill.java b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfill.java
new file mode 100644
index 0000000000..084e82cdbe
--- /dev/null
+++ b/server/api-service/lowcoder-server/src/main/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfill.java
@@ -0,0 +1,727 @@
+package org.lowcoder.runner.migrations.job;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+
+import org.bson.Document;
+import org.lowcoder.sdk.constants.AuthSourceConstants;
+import org.lowcoder.sdk.util.EmailUtils;
+import org.lowcoder.sdk.util.HashUtils;
+
+import com.mongodb.MongoBulkWriteException;
+import com.mongodb.MongoException;
+import com.mongodb.bulk.BulkWriteError;
+import com.mongodb.bulk.BulkWriteResult;
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoCursor;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.BulkWriteOptions;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.model.IndexOptions;
+import com.mongodb.client.model.Projections;
+import com.mongodb.client.model.ReplaceOneModel;
+import com.mongodb.client.model.ReplaceOptions;
+import com.mongodb.client.model.UpdateOneModel;
+import com.mongodb.client.model.WriteModel;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Backfills existing email addresses into the canonical form the application has written since the
+ * sanitization change: trimmed and lower-cased. See {@code EMAIL-SANITIZATION.md}.
+ *
+ * Policy: converge what is unambiguous, freeze and report what is not. Where the survey sees two
+ * accounts converging on one normalized identity, every member of that group is left byte-for-byte as it
+ * is and written to {@value #REPORT_COLLECTION} for an operator to resolve. Nothing is merged, deleted or
+ * disabled. The dual-probe lookups in {@code UserServiceImpl} keep frozen rows reachable by their exact
+ * stored casing, so freezing costs nobody their login.
+ *
+ *
A collision that only appears after the survey is handled differently and more weakly: the
+ * write is refused by the index, and the document that lost is reported under {@code space: "write"}. The
+ * account that took the key is not part of that report -- it was not a conflict when it was read, and
+ * finding it would need a third pass. Such a group is therefore reported by one member, not all of them.
+ *
+ *
Nothing in the data it finds may prevent the application from starting. Mongock runs as an
+ * {@code ApplicationRunner} at boot and records a throwing changeset as FAILED, which by default stops
+ * startup. Worse, {@code AddSuperAdminRunner} writes the super-admin user from a {@code @PostConstruct}
+ * that ends in a bare {@code subscribe()}, so it is not ordered against this migration and can land a
+ * write mid-run on every boot; multi-replica deployments add ordinary login traffic from the other pods.
+ * The survey therefore cannot be the safety mechanism -- it is the policy enforcer, the one that makes a
+ * conflict group atomic. Safety comes from tolerating duplicate keys at write time, which is the same
+ * policy applied a moment later: a write that turns out to collide is not applied, it is reported.
+ *
+ *
Only data faults are tolerated. Infrastructure faults -- sockets, auth, write concern, any bulk error
+ * that is not {@value #DUPLICATE_KEY_ERROR} -- still propagate, because those should be loud.
+ */
+@Slf4j
+public final class EmailNormalizationBackfill {
+
+ public static final String USER_COLLECTION = "user";
+ public static final String REPORT_COLLECTION = "emailNormalizationConflict";
+
+ static final String FIELD_ID = "_id";
+ static final String FIELD_EMAIL = "email";
+ static final String FIELD_CONNECTIONS = "connections";
+ static final String FIELD_SOURCE = "source";
+ static final String FIELD_RAW_ID = "rawId";
+
+ static final String REPORT_SPACE = "space";
+ static final String REPORT_VALUE = "normalizedValue";
+ static final String REPORT_USER_ID = "userId";
+ static final String REPORT_STORED = "storedValues";
+ static final String REPORT_PATH = "path";
+ static final String REPORT_VALUE_FIELD = "value";
+ static final String REPORT_RUN_ID = "runId";
+ static final String REPORT_DETECTED_AT = "detectedAt";
+ static final String SUMMARY_ID = "summary";
+
+ /** Two accounts converge on one EMAIL-connection {@code rawId} -- the key the unique index enforces. */
+ static final String SPACE_RAW_ID = "connections.rawId";
+ /** Two accounts converge in the {@code findByEmailDeep} keyspace -- {@code email} or a connection email. */
+ static final String SPACE_EMAIL = "email";
+ /** The write collided anyway: someone took the key between the survey and the write. */
+ static final String SPACE_WRITE = "write";
+
+ static final String MONGO_SET = "$set";
+ static final int BULK_BATCH_SIZE = 1000;
+ static final int CURSOR_BATCH_SIZE = 1000;
+ static final int PROGRESS_LOG_INTERVAL = 50_000;
+ static final int SAMPLE_LIMIT = 20;
+ static final int DUPLICATE_KEY_ERROR = 11000;
+
+ private EmailNormalizationBackfill() {
+ }
+
+ /** Changeset entry point. */
+ public static Summary run(MongoDatabase db) {
+ return run(db, USER_COLLECTION, REPORT_COLLECTION);
+ }
+
+ /**
+ * Collection names are parameters so tests can drive this against throwaway collections. The embedded
+ * mongod is shared across test classes, and the real {@code user} collection carries other suites'
+ * fixtures.
+ *
+ *
The absent-collection guard is load-bearing rather than defensive. {@code @ActiveProfiles} replaces
+ * the active profile set rather than adding to it, so every test class that names its own profile --
+ * {@code emailSanitization}, {@code testGeneric}, {@code testFolder} and the rest -- does NOT have
+ * {@code test} active, {@code @Profile("!test")} matches, and Mongock runs this changeset at context
+ * startup against a database those suites have not populated yet.
+ */
+ static Summary run(MongoDatabase db, String usersCollection, String reportCollection) {
+ if (!collectionExists(db, usersCollection)) {
+ log.info("032 email backfill: collection {} does not exist; nothing to do.", usersCollection);
+ return Summary.empty();
+ }
+ MongoCollection users = db.getCollection(usersCollection);
+ MongoCollection report = db.getCollection(reportCollection);
+
+ Survey survey = survey(users);
+ Summary summary = backfill(users, report, survey, UUID.randomUUID().toString(), Instant.now());
+ ensureLookupIndexes(users);
+ return summary;
+ }
+
+ private static boolean collectionExists(MongoDatabase db, String name) {
+ try (MongoCursor names = db.listCollectionNames().iterator()) {
+ while (names.hasNext()) {
+ if (name.equals(names.next())) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ // -------------------------------------------------------------------------------- pass 1: survey
+
+ /**
+ * Finds the normalized values that more than one account would end up owning.
+ *
+ * Grouped in Java with {@link EmailUtils#normalize} -- the very function the backfill applies -- and
+ * not with a server-side {@code $group} on {@code $toLower}, which neither trims nor handles non-ASCII.
+ * Under that weaker key {@code " john@doe.com"} forms its own group, passes the gate, and then converges
+ * during the backfill, surfacing as a bare duplicate-key error: exactly what the gate exists to prevent.
+ *
+ *
Every document is scanned, including already-canonical ones. Grouping only the documents that need
+ * a write would miss the common shape: {@code A="john@doe.com"} needs no write, so {@code B="JoHn@DoE.com"}
+ * would look like a lone owner and be written straight onto A's key.
+ *
+ *
Holds two maps of normalized value to owning id for the length of the pass. This is the only part
+ * that does not stream; at a million users it is a few hundred MB and well inside the container.
+ */
+ static Survey survey(MongoCollection users) {
+ Map rawIdOwners = new HashMap<>();
+ Map emailOwners = new HashMap<>();
+ Set conflictingRawIds = new HashSet<>();
+ Set conflictingEmails = new HashSet<>();
+ long scanned = 0;
+
+ try (MongoCursor cursor = users.find()
+ .projection(Projections.include(FIELD_ID, FIELD_EMAIL, FIELD_CONNECTIONS))
+ .batchSize(CURSOR_BATCH_SIZE)
+ .noCursorTimeout(true)
+ .iterator()) {
+ while (cursor.hasNext()) {
+ Document doc = cursor.next();
+ scanned++;
+ Object id = doc.get(FIELD_ID);
+
+ for (String key : emailConnectionRawIdKeys(doc)) {
+ claim(rawIdOwners, conflictingRawIds, key, id);
+ }
+ for (String key : lookupEmailKeys(doc)) {
+ claim(emailOwners, conflictingEmails, key, id);
+ }
+
+ if (scanned % PROGRESS_LOG_INTERVAL == 0) {
+ log.info("032 email backfill survey: scanned {}.", scanned);
+ }
+ }
+ }
+ log.info("032 email backfill survey: scanned {} documents, found {} conflicting rawId values and "
+ + "{} conflicting email values.", scanned, conflictingRawIds.size(), conflictingEmails.size());
+ return new Survey(conflictingRawIds, conflictingEmails, scanned);
+ }
+
+ /**
+ * Records this document as an owner of the key, marking the key conflicting when a different document
+ * already owns it. Ids are compared as stored objects: {@code ObjectId} has value equality, and
+ * {@code toString} would merge an {@code ObjectId} with a String of the same hex.
+ */
+ private static void claim(Map owners, Set conflicting, String key, Object id) {
+ Object previous = owners.putIfAbsent(key, id);
+ if (previous != null && !Objects.equals(previous, id)) {
+ conflicting.add(key);
+ }
+ }
+
+ /**
+ * The normalized EMAIL-connection {@code rawId}s this document would own, deduplicated.
+ *
+ * Keyed on {@code source} equal to {@code EMAIL} and nothing else, which is what the unique index is
+ * keyed on. Two consequences that both matter:
+ *
+ *
+ * An OIDC subject that happens to be an address -- many providers set {@code sub} to the user's
+ * email -- lives under a different {@code source}, so it is a different index key and must not be
+ * allowed to freeze the form account holding the same address. On an SSO-heavy install, grouping on the
+ * bare value would freeze most of the collection.
+ * {@code User.markAsDeleted} rewrites {@code source} to {@code "EMAIL(User deleted at )"}, so a
+ * tombstone is likewise a different key and cannot block a live account from converging.
+ *
+ *
+ * Values that normalize to empty are excluded here and never written, so they cannot converge onto a
+ * shared {@code ""} key.
+ */
+ static Set emailConnectionRawIdKeys(Document doc) {
+ Set keys = new LinkedHashSet<>();
+ for (Object element : rawConnections(doc)) {
+ if (!(element instanceof Document connection)
+ || !AuthSourceConstants.EMAIL.equals(stringOrNull(connection, FIELD_SOURCE))) {
+ continue;
+ }
+ String rawId = stringOrNull(connection, FIELD_RAW_ID);
+ if (rawId == null) {
+ continue;
+ }
+ String normalized = EmailUtils.normalize(rawId);
+ if (!normalized.isEmpty()) {
+ keys.add(normalized);
+ }
+ }
+ return keys;
+ }
+
+ /**
+ * The normalized values this document would own in the {@code findByEmailDeep} keyspace.
+ *
+ * That lookup is {@code {$or: [{email: v}, {"connections.email": v}]}}, so both fields share one
+ * keyspace and connection emails count for every source, not just EMAIL.
+ *
+ *
Gated on {@link EmailUtils#looksLikeEmail}. Changeset {@code 023} ran
+ * {@code update.set("email", document.getString("name"))} for every user, so on an upgraded install
+ * {@code user.email} is full of display names, handles, empty strings and nulls. Those are not
+ * addresses: normalizing {@code "Iron Man"} to {@code "iron man"} is a visible regression in the UI for
+ * no benefit, and counting duplicates among them would freeze their owners' perfectly unambiguous
+ * {@code rawId} for reasons that have nothing to do with email case.
+ */
+ static Set lookupEmailKeys(Document doc) {
+ Set keys = new LinkedHashSet<>();
+ addEmailKey(keys, stringOrNull(doc, FIELD_EMAIL));
+ for (Object element : rawConnections(doc)) {
+ if (element instanceof Document connection) {
+ addEmailKey(keys, stringOrNull(connection, FIELD_EMAIL));
+ }
+ }
+ return keys;
+ }
+
+ private static void addEmailKey(Set keys, String value) {
+ if (value == null) {
+ return;
+ }
+ String normalized = EmailUtils.normalize(value);
+ if (EmailUtils.looksLikeEmail(normalized)) {
+ keys.add(normalized);
+ }
+ }
+
+ // ---------------------------------------------------------------------- pass 2: backfill + report
+
+ static Summary backfill(MongoCollection users, MongoCollection report, Survey survey,
+ String runId, Instant runAt) {
+ List ops = new ArrayList<>();
+ List reportRows = new ArrayList<>();
+ List samples = new ArrayList<>();
+ long scanned = 0;
+ long modified = 0;
+ long rawIdFrozen = 0;
+ long emailFrozen = 0;
+ long collidedAtWrite = 0;
+ long notConverged = 0;
+
+ try (MongoCursor cursor = users.find()
+ .projection(Projections.include(FIELD_ID, FIELD_EMAIL, FIELD_CONNECTIONS))
+ .batchSize(CURSOR_BATCH_SIZE)
+ .noCursorTimeout(true)
+ .iterator()) {
+ while (cursor.hasNext()) {
+ Document doc = cursor.next();
+ scanned++;
+ Object id = doc.get(FIELD_ID);
+
+ boolean freezeRawId = intersects(emailConnectionRawIdKeys(doc), survey.conflictingRawIds());
+ boolean freezeEmail = intersects(lookupEmailKeys(doc), survey.conflictingEmails());
+ if (freezeRawId) {
+ rawIdFrozen++;
+ collectConflictRows(reportRows, samples, doc, id, SPACE_RAW_ID, survey, runId, runAt);
+ }
+ if (freezeEmail) {
+ emailFrozen++;
+ collectConflictRows(reportRows, samples, doc, id, SPACE_EMAIL, survey, runId, runAt);
+ }
+
+ UpdateOneModel operation = buildBackfillOperation(doc, freezeRawId, freezeEmail);
+ if (operation != null) {
+ ops.add(new Op(id, targetValueOf(doc, freezeRawId), operation));
+ modified++;
+ }
+ if (ops.size() >= BULK_BATCH_SIZE) {
+ BulkOutcome outcome = flush(users, ops, reportRows, runId, runAt);
+ collidedAtWrite += outcome.collided().size();
+ notConverged += outcome.notConverged();
+ ops.clear();
+ }
+ if (scanned % PROGRESS_LOG_INTERVAL == 0) {
+ log.info("032 email backfill: scanned {}, modified {}.", scanned, modified);
+ }
+ }
+ }
+ BulkOutcome last = flush(users, ops, reportRows, runId, runAt);
+ collidedAtWrite += last.collided().size();
+ notConverged += last.notConverged();
+ // Counted at queue time, so back out everything that did not actually land: a document whose key
+ // was taken, and one the compare-and-swap filter rejected. An operator reading "modified" after a
+ // production identity migration has to be able to trust it.
+ modified -= collidedAtWrite + notConverged;
+
+ Summary summary = new Summary(scanned, modified, rawIdFrozen, emailFrozen,
+ survey.conflictingRawIds().size() + survey.conflictingEmails().size(), collidedAtWrite,
+ notConverged);
+ // The user data is the valuable part and it is already written. Losing the report is bad, but
+ // failing the changeset over it is worse: Mongock would record 032 FAILED and stop the application
+ // from starting, having already normalized the collection.
+ try {
+ upsertConflictRows(report, reportRows);
+ writeSummaryAndSweepStale(report, summary, runId, runAt);
+ } catch (RuntimeException e) {
+ log.error("032 email backfill: the data was migrated but the conflict report could not be "
+ + "written to {}. Re-run the changeset to regenerate it.",
+ report.getNamespace().getCollectionName(), e);
+ }
+ logOutcome(summary, samples, report.getNamespace().getCollectionName());
+ return summary;
+ }
+
+ private static BulkOutcome flush(MongoCollection users, List ops,
+ List reportRows, String runId, Instant runAt) {
+ BulkOutcome outcome = executeBulk(users, ops, "032 email backfill");
+ for (Op op : outcome.collided()) {
+ reportRows.add(conflictRow(SPACE_WRITE, op.targetValue(), op.userId(), List.of(), runId, runAt));
+ }
+ return outcome;
+ }
+
+ /**
+ * The canonical address this operation is trying to claim, for the report row a late collision
+ * produces. Empty when the document is only having non-indexed email fields normalized, which cannot
+ * collide.
+ */
+ private static String targetValueOf(Document doc, boolean freezeRawId) {
+ if (freezeRawId) {
+ return "";
+ }
+ for (String key : emailConnectionRawIdKeys(doc)) {
+ return key;
+ }
+ return "";
+ }
+
+ private static boolean intersects(Set keys, Set conflicting) {
+ for (String key : keys) {
+ if (conflicting.contains(key)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * The single update this document needs, or null when nothing changes.
+ *
+ * One operation per document, never split per field. Whole-document atomicity is what makes "frozen
+ * means untouched" true and keeps the report unambiguous; a half-applied document would be neither
+ * converged nor pristine, and an operator resolving the conflict could not tell which.
+ *
+ *
The filter is a compare-and-swap: every path written is pinned to the value it was read with, and
+ * a {@code rawId} write additionally pins its {@code source}. This subsumes pinning the array length --
+ * if the array shrank, a dotted path matches nothing and the update is simply skipped, so MongoDB never
+ * pads the array to reach a stale index -- and unlike a length check it also covers reordering and a
+ * concurrent {@code markAsDeleted} turning {@code EMAIL} into a tombstone source mid-run.
+ */
+ static UpdateOneModel buildBackfillOperation(Document doc, boolean freezeRawId,
+ boolean freezeEmail) {
+ Document set = new Document();
+ List guards = new ArrayList<>();
+ guards.add(Filters.eq(FIELD_ID, doc.get(FIELD_ID)));
+
+ if (!freezeEmail) {
+ String email = stringOrNull(doc, FIELD_EMAIL);
+ if (email != null && changesToAnAddress(email)) {
+ set.append(FIELD_EMAIL, EmailUtils.normalize(email));
+ guards.add(Filters.eq(FIELD_EMAIL, email));
+ }
+ }
+
+ List> connections = rawConnections(doc);
+ for (int i = 0; i < connections.size(); i++) {
+ // Anything that is not a document -- nulls, scalars, nested arrays -- is skipped rather than
+ // cast. The index is the STORED index because the whole array was projected; projecting
+ // connections.source/connections.rawId instead would drop non-document elements and shift every
+ // later position, so a dotted write would land on a foreign IdP subject.
+ if (!(connections.get(i) instanceof Document connection)) {
+ continue;
+ }
+ String prefix = FIELD_CONNECTIONS + "." + i + ".";
+ String source = stringOrNull(connection, FIELD_SOURCE);
+
+ if (!freezeRawId && AuthSourceConstants.EMAIL.equals(source)) {
+ String rawId = stringOrNull(connection, FIELD_RAW_ID);
+ // No shape gate here, unlike the email fields: rawId under an EMAIL source IS the login
+ // identifier by definition, so normalizing it always improves reachability. Empty results
+ // are suppressed so whitespace-only values cannot converge onto a shared "" key.
+ if (rawId != null && !EmailUtils.normalize(rawId).isEmpty()
+ && !EmailUtils.normalize(rawId).equals(rawId)) {
+ set.append(prefix + FIELD_RAW_ID, EmailUtils.normalize(rawId));
+ guards.add(Filters.eq(prefix + FIELD_RAW_ID, rawId));
+ guards.add(Filters.eq(prefix + FIELD_SOURCE, source));
+ }
+ }
+
+ if (!freezeEmail) {
+ String email = stringOrNull(connection, FIELD_EMAIL);
+ if (email != null && changesToAnAddress(email)) {
+ set.append(prefix + FIELD_EMAIL, EmailUtils.normalize(email));
+ guards.add(Filters.eq(prefix + FIELD_EMAIL, email));
+ }
+ }
+ }
+
+ if (set.isEmpty()) {
+ return null;
+ }
+ return new UpdateOneModel<>(Filters.and(guards), new Document(MONGO_SET, set));
+ }
+
+ /** Whether this value is an address AND is not already canonical -- the condition for writing it. */
+ private static boolean changesToAnAddress(String value) {
+ String normalized = EmailUtils.normalize(value);
+ return EmailUtils.looksLikeEmail(normalized) && !normalized.equals(value);
+ }
+
+ // ------------------------------------------------------------------------------------- writing
+
+ /**
+ * Applies the batch and returns the ids that could not be written because their key was already taken.
+ *
+ * Unordered, so one rejected operation does not abandon the rest of the batch. A duplicate key here
+ * means the survey's picture went stale -- someone registered or bound the address while the migration
+ * ran -- which is the same condition the survey freezes for, discovered later. So it is reported and
+ * the run continues. Anything else is an infrastructure fault and propagates.
+ */
+ static BulkOutcome executeBulk(MongoCollection collection, List ops, String what) {
+ if (ops.isEmpty()) {
+ return BulkOutcome.empty();
+ }
+ List> models = new ArrayList<>(ops.size());
+ for (Op op : ops) {
+ models.add(op.model());
+ }
+ try {
+ BulkWriteResult result = collection.bulkWrite(models, new BulkWriteOptions().ordered(false));
+ // With w=0 the counts are not zero, getMatchedCount throws outright, so check before reading.
+ if (!result.wasAcknowledged()) {
+ log.warn("{}: the connection is configured for unacknowledged writes (w=0), so this batch "
+ + "cannot be verified. Run the migration against an acknowledged connection.", what);
+ return BulkOutcome.empty();
+ }
+ long shortfall = ops.size() - result.getMatchedCount();
+ if (shortfall > 0) {
+ // The compare-and-swap filter rejected these: the documents changed under the migration.
+ // Not fatal and not a conflict -- they are simply not converged, and another run picks
+ // them up. Failing the changeset here would fail application startup instead. Returned
+ // rather than only logged, so the summary does not count them as normalized.
+ log.warn("{}: {} of {} operations matched nothing because the document changed during the "
+ + "run. Those documents are not normalized; re-run the migration.", what, shortfall,
+ ops.size());
+ }
+ return new BulkOutcome(List.of(), shortfall);
+ } catch (MongoBulkWriteException e) {
+ List collided = new ArrayList<>();
+ boolean onlyDuplicates = e.getWriteConcernError() == null;
+ for (BulkWriteError error : e.getWriteErrors()) {
+ if (error.getCode() == DUPLICATE_KEY_ERROR) {
+ collided.add(ops.get(error.getIndex()));
+ } else {
+ onlyDuplicates = false;
+ log.error("{} failed for _id {}: code {} -- {}", what,
+ ops.get(error.getIndex()).userId(), error.getCode(), error.getMessage());
+ }
+ }
+ if (!onlyDuplicates) {
+ if (e.getWriteConcernError() != null) {
+ log.error("{} write concern error: code {} -- {}", what,
+ e.getWriteConcernError().getCode(), e.getWriteConcernError().getMessage());
+ }
+ throw e;
+ }
+ log.warn("{}: {} documents could not be normalized because another account already holds the "
+ + "address. They are unchanged and reported.", what, collided.size());
+ long applied = e.getWriteResult().wasAcknowledged()
+ ? e.getWriteResult().getMatchedCount()
+ : ops.size() - collided.size();
+ return new BulkOutcome(collided, Math.max(0, ops.size() - collided.size() - applied));
+ } catch (MongoException e) {
+ // Socket, command and write-concern failures carry no per-operation detail, so name the pass
+ // rather than letting a bare driver exception surface with no indication of what was running.
+ log.error("{} failed: {}", what, e.getMessage());
+ throw e;
+ }
+ }
+
+ // -------------------------------------------------------------------------------------- report
+
+ private static void collectConflictRows(List rows, List samples, Document doc,
+ Object id, String space, Survey survey, String runId, Instant runAt) {
+ Set conflicting = SPACE_RAW_ID.equals(space)
+ ? survey.conflictingRawIds()
+ : survey.conflictingEmails();
+ Set keys = SPACE_RAW_ID.equals(space)
+ ? emailConnectionRawIdKeys(doc)
+ : lookupEmailKeys(doc);
+
+ for (String key : keys) {
+ if (!conflicting.contains(key)) {
+ continue;
+ }
+ rows.add(conflictRow(space, key, id, storedValuesFor(doc, space, key), runId, runAt));
+ if (samples.size() < SAMPLE_LIMIT) {
+ samples.add(String.format("%s=%s held by _id %s", space, key, id));
+ }
+ }
+ }
+
+ /** The actual stored strings behind a conflicting key, with their dotted paths, for the report. */
+ private static List storedValuesFor(Document doc, String space, String key) {
+ List stored = new ArrayList<>();
+ boolean emailSpace = SPACE_EMAIL.equals(space);
+
+ if (emailSpace) {
+ String email = stringOrNull(doc, FIELD_EMAIL);
+ if (email != null && key.equals(EmailUtils.normalize(email))) {
+ stored.add(new Document(REPORT_PATH, FIELD_EMAIL).append(REPORT_VALUE_FIELD, email));
+ }
+ }
+ List> connections = rawConnections(doc);
+ for (int i = 0; i < connections.size(); i++) {
+ if (!(connections.get(i) instanceof Document connection)) {
+ continue;
+ }
+ String prefix = FIELD_CONNECTIONS + "." + i + ".";
+ String field = emailSpace ? FIELD_EMAIL : FIELD_RAW_ID;
+ if (!emailSpace && !AuthSourceConstants.EMAIL.equals(stringOrNull(connection, FIELD_SOURCE))) {
+ continue;
+ }
+ String value = stringOrNull(connection, field);
+ if (value != null && key.equals(EmailUtils.normalize(value))) {
+ stored.add(new Document(REPORT_PATH, prefix + field).append(REPORT_VALUE_FIELD, value));
+ }
+ }
+ return stored;
+ }
+
+ /**
+ * One report row. The {@code _id} is a hash rather than the concatenation because an {@code _id} index
+ * key is capped at 1024 bytes and an address is not bounded; hashing also makes the row deterministic,
+ * so a re-run replaces it in place instead of accumulating duplicates.
+ */
+ private static Document conflictRow(String space, String normalizedValue, Object userId,
+ List storedValues, String runId, Instant runAt) {
+ String key = space + " " + normalizedValue + " " + userId;
+ return new Document(FIELD_ID, HashUtils.hash(key.getBytes(StandardCharsets.UTF_8)))
+ .append(REPORT_SPACE, space)
+ .append(REPORT_VALUE, normalizedValue)
+ .append(REPORT_USER_ID, userId)
+ .append(REPORT_STORED, storedValues)
+ .append(REPORT_RUN_ID, runId)
+ .append(REPORT_DETECTED_AT, Date.from(runAt));
+ }
+
+ static void upsertConflictRows(MongoCollection report, List rows) {
+ if (rows.isEmpty()) {
+ return;
+ }
+ List> models = new ArrayList<>(rows.size());
+ for (Document row : rows) {
+ models.add(new ReplaceOneModel<>(Filters.eq(FIELD_ID, row.get(FIELD_ID)), row,
+ new ReplaceOptions().upsert(true)));
+ if (models.size() >= BULK_BATCH_SIZE) {
+ report.bulkWrite(models, new BulkWriteOptions().ordered(false));
+ models.clear();
+ }
+ }
+ if (!models.isEmpty()) {
+ report.bulkWrite(models, new BulkWriteOptions().ordered(false));
+ }
+ }
+
+ /**
+ * Writes the one row an operator actually reads, then drops rows from earlier runs.
+ *
+ * The sweep is what stops a resolved conflict haunting the collection forever. It runs only after a
+ * completed pass, so an aborted run can never delete rows that are still valid.
+ */
+ static void writeSummaryAndSweepStale(MongoCollection report, Summary summary, String runId,
+ Instant runAt) {
+ Document doc = new Document(FIELD_ID, SUMMARY_ID)
+ .append(REPORT_RUN_ID, runId)
+ .append(REPORT_DETECTED_AT, Date.from(runAt))
+ .append("scanned", summary.scanned())
+ .append("modified", summary.modified())
+ .append("rawIdFrozen", summary.rawIdFrozen())
+ .append("emailFrozen", summary.emailFrozen())
+ .append("conflictGroups", summary.conflictGroups())
+ .append("collidedAtWrite", summary.collidedAtWrite())
+ .append("notConverged", summary.notConverged());
+ report.replaceOne(Filters.eq(FIELD_ID, SUMMARY_ID), doc, new ReplaceOptions().upsert(true));
+ report.deleteMany(Filters.ne(REPORT_RUN_ID, runId));
+ }
+
+ private static void logOutcome(Summary summary, List samples, String reportCollection) {
+ log.info("032 email backfill complete: scanned {}, normalized {}.", summary.scanned(),
+ summary.modified());
+ if (summary.notConverged() > 0) {
+ log.warn("032 email backfill: {} documents were changed by something else while the migration "
+ + "ran and were left un-normalized. Re-run the changeset to pick them up.",
+ summary.notConverged());
+ }
+ if (summary.rawIdFrozen() == 0 && summary.emailFrozen() == 0 && summary.collidedAtWrite() == 0) {
+ return;
+ }
+ log.warn("032 email backfill left {} documents unchanged because their address is shared with "
+ + "another account ({} on the login identifier, {} in the email lookup, {} discovered "
+ + "at write time). They still work, by their stored casing. See collection {} -- "
+ + "its \"{}\" document has the counts. First {} of {}: {}",
+ summary.rawIdFrozen() + summary.emailFrozen() + summary.collidedAtWrite(),
+ summary.rawIdFrozen(), summary.emailFrozen(), summary.collidedAtWrite(), reportCollection,
+ SUMMARY_ID, samples.size(), summary.conflictGroups(), samples);
+ }
+
+ // ------------------------------------------------------------------------------------- indexes
+
+ /**
+ * Indexes the {@code findByEmailDeep} keyspace. Both fields, because that lookup is an {@code $or} and
+ * MongoDB can only serve an {@code $or} from indexes when every branch has one -- indexing {@code email}
+ * alone would still leave the query a collection scan.
+ *
+ * It was already a scan on the SSO login, invite and SCIM paths; password recovery is about to start
+ * using it too, and that endpoint is unauthenticated, which makes an unindexed full scan there an
+ * amplification vector.
+ *
+ *
Plain, non-unique, non-sparse. Non-unique is the point: a unique index would hard-fail on exactly
+ * the conflict groups this migration has deliberately left in place.
+ */
+ static void ensureLookupIndexes(MongoCollection users) {
+ // Same reasoning as the report: these are a performance improvement, not the migration's purpose.
+ // An install that already carries an index on the same keys under a different name makes
+ // createIndex throw, and that must not turn into a failed changeset and a refused startup.
+ try {
+ users.createIndex(new Document(FIELD_EMAIL, 1), new IndexOptions().name("email_1"));
+ users.createIndex(new Document(FIELD_CONNECTIONS + "." + FIELD_EMAIL, 1),
+ new IndexOptions().name("connections.email_1"));
+ } catch (RuntimeException e) {
+ log.warn("032 email backfill: could not create the email lookup indexes. findByEmailDeep will "
+ + "still work, as a collection scan. Create them by hand: {}", e.getMessage());
+ }
+ }
+
+ // ------------------------------------------------------------------------------------- helpers
+
+ private static List> rawConnections(Document doc) {
+ return doc.get(FIELD_CONNECTIONS) instanceof List> list ? list : List.of();
+ }
+
+ /** The value at this key when it is a String, else null. Never throws on a wrongly-typed field. */
+ private static String stringOrNull(Document doc, String field) {
+ return doc.get(field) instanceof String value ? value : null;
+ }
+
+ /** The conflicting normalized values, in each of the two keyspaces that can produce them. */
+ record Survey(Set conflictingRawIds, Set conflictingEmails, long scanned) {
+ }
+
+ /**
+ * A pending update, the id it targets and the canonical value it is trying to claim, so a bulk error
+ * can name both the document and the address it lost.
+ */
+ record Op(Object userId, String targetValue, UpdateOneModel model) {
+ }
+
+ /** What a batch actually did: which operations lost a key, and how many the CAS filter rejected. */
+ record BulkOutcome(List collided, long notConverged) {
+
+ static BulkOutcome empty() {
+ return new BulkOutcome(List.of(), 0);
+ }
+ }
+
+ /** The outcome of a run. Public because it is what the public entry point returns. */
+ public record Summary(long scanned, long modified, long rawIdFrozen, long emailFrozen,
+ long conflictGroups, long collidedAtWrite, long notConverged) {
+
+ static Summary empty() {
+ return new Summary(0, 0, 0, 0, 0, 0, 0);
+ }
+ }
+}
diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/EmailBackfillEndToEndTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/EmailBackfillEndToEndTest.java
new file mode 100644
index 0000000000..68ebad965f
--- /dev/null
+++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/EmailBackfillEndToEndTest.java
@@ -0,0 +1,319 @@
+package org.lowcoder.api.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.lowcoder.sdk.exception.BizError.ALREADY_BIND;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.lowcoder.domain.encryption.EncryptionService;
+import org.lowcoder.domain.user.model.Connection;
+import org.lowcoder.domain.user.model.User;
+import org.lowcoder.domain.user.model.UserState;
+import org.lowcoder.domain.user.repository.UserRepository;
+import org.lowcoder.domain.user.service.UserService;
+import org.lowcoder.runner.migrations.job.EmailNormalizationBackfill;
+import org.lowcoder.sdk.constants.AuthSourceConstants;
+import org.lowcoder.sdk.exception.BizException;
+import org.lowcoder.sdk.util.HashUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Proves the backfill actually closes the holes the sanitization branch documented, exercised through the
+ * real services rather than against the migration's own collections.
+ *
+ * Its own profile, so it gets its own application context and its own embedded mongod. It must not use
+ * the {@code test} profile: that shares one {@code user} collection with every other {@code test}-profile
+ * class, and running a real backfill across it would rewrite their fixtures.
+ *
+ *
A side effect of naming a profile is that {@code test} is not active, so {@code DatabaseChangelog}
+ * (which is {@code @Profile("!test")}) runs for real at context startup — including changeset 032 against
+ * an empty collection, and the changesets that build the unique sparse index this test then relies on.
+ */
+@SpringBootTest
+@ActiveProfiles("emailBackfill")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@Slf4j
+public class EmailBackfillEndToEndTest {
+
+ private static final String SOURCE = AuthSourceConstants.EMAIL;
+ private static final String PASSWORD = "lowcoder";
+
+ @Autowired
+ private UserService userService;
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private EncryptionService encryptionService;
+ @Autowired
+ private MongoTemplate blockingMongoTemplate;
+
+ /** Writes a row in the shape a pre-normalization install holds it: whatever casing the user typed. */
+ private User seedLegacy(String storedAddress) {
+ Set connections = new HashSet<>();
+ connections.add(Connection.builder()
+ .source(SOURCE)
+ .name(storedAddress)
+ .rawId(storedAddress)
+ .email(storedAddress)
+ .build());
+ User user = userRepository.save(User.builder()
+ .name(storedAddress)
+ .email(storedAddress)
+ .password(encryptionService.encryptPassword(PASSWORD))
+ .state(UserState.ACTIVATED)
+ .isEnabled(true)
+ .connections(connections)
+ .build())
+ .block();
+ assertNotNull(user);
+ return user;
+ }
+
+ private void runBackfill() {
+ log.info("backfill: {}", EmailNormalizationBackfill.run(blockingMongoTemplate.getDb()));
+ }
+
+ /**
+ * The headline fix. Before the backfill this lookup misses — it is the limitation
+ * {@code EmailNormalizationTest.legacyMixedCaseAccountIsNotReachableByItsLowercaseForm} pins.
+ */
+ @Test
+ public void aLegacyMixedCaseAccountBecomesReachableByItsLowercaseForm() {
+ String stored = "Reachable.Legacy@Example.COM";
+ String typed = "reachable.legacy@example.com";
+ User seeded = seedLegacy(stored);
+
+ assertNull(userService.findBySourceAndId(SOURCE, typed).block(),
+ "precondition: the lowercase form does not reach it before the backfill");
+
+ runBackfill();
+
+ User found = userService.findBySourceAndId(SOURCE, typed).block();
+ assertNotNull(found, "after the backfill the canonical form must resolve the account");
+ assertEquals(seeded.getId(), found.getId(), "and it must be the same account, not a new one");
+ assertEquals(typed, found.getEmail(), "its stored address is now canonical");
+ assertTrue(encryptionService.matchPassword(PASSWORD, found.getPassword()),
+ "the credential must survive the migration untouched");
+ }
+
+ /** The other half: the duplicate that the un-migrated row used to let through is now caught. */
+ @Test
+ public void bindEmailFindsALegacyMixedCaseOwnerAfterTheBackfill() {
+ String stored = "Bindable.Legacy@Example.COM";
+ String typed = "bindable.legacy@example.com";
+ seedLegacy(stored);
+ runBackfill();
+
+ User binder = userRepository.save(User.builder()
+ .name("sso-only-binder-e2e")
+ .isEnabled(true)
+ .connections(new HashSet<>())
+ .build())
+ .block();
+ assertNotNull(binder);
+
+ BizException exception = null;
+ try {
+ userService.bindEmail(binder, typed).block();
+ } catch (BizException e) {
+ exception = e;
+ }
+ assertNotNull(exception, "binding an address another account holds must now be rejected");
+ log.info("bind rejected with {} / {}", exception.getError(), exception.getMessageKey());
+ assertEquals(ALREADY_BIND, exception.getError());
+ }
+
+ /**
+ * Password recovery, which this change moved off {@code findByName} and onto the email lookup. Driven
+ * through {@code resetLostPassword} with the token seeded directly, so the test needs no SMTP.
+ */
+ @Test
+ public void passwordRecoveryResolvesAnAccountWhateverCaseIsTyped() {
+ String stored = "Recover.Legacy@Example.COM";
+ User seeded = seedLegacy(stored);
+
+ String token = "reset-token-e2e";
+ seeded.setPasswordResetToken(HashUtils.hash(token.getBytes()));
+ seeded.setPasswordResetTokenExpiry(Instant.now().plus(1, ChronoUnit.HOURS));
+ userRepository.save(seeded).block();
+
+ runBackfill();
+
+ Boolean reset = userService.resetLostPassword("RECOVER.LEGACY@EXAMPLE.COM", token, "new-password")
+ .block();
+ assertEquals(Boolean.TRUE, reset, "recovery must resolve the account regardless of typed case");
+
+ User after = userRepository.findById(seeded.getId()).block();
+ assertNotNull(after);
+ assertTrue(encryptionService.matchPassword("new-password", after.getPassword()),
+ "and must actually have changed the password");
+ }
+
+ /** The pre-existing NPE this change would otherwise have made far more reachable. */
+ @Test
+ public void passwordResetOnAnAccountThatNeverRequestedOneFailsCleanly() {
+ seedLegacy("NoToken.Legacy@Example.COM");
+ runBackfill();
+
+ BizException exception = null;
+ try {
+ userService.resetLostPassword("notoken.legacy@example.com", "whatever", "new-password").block();
+ } catch (BizException e) {
+ exception = e;
+ }
+ assertNotNull(exception, "an account with no reset token must produce a clean error, not an NPE");
+ log.info("no-token reset rejected with {} / {}", exception.getError(), exception.getMessageKey());
+ assertEquals("TOKEN_EXPIRED", exception.getMessageKey());
+ }
+
+ /**
+ * Resolving recovery by address rather than by {@code user.name} reaches strictly more rows, including
+ * soft-deleted accounts, which keep their {@code email}. They must not be resettable: a reset cannot
+ * log anyone in afterwards, but widening what an unauthenticated endpoint can mutate is not something
+ * to do by accident.
+ */
+ @Test
+ public void passwordRecoveryIgnoresASoftDeletedAccount() {
+ String address = "Deleted.Legacy@Example.COM";
+ User seeded = seedLegacy(address);
+ String token = "reset-token-deleted";
+ seeded.setPasswordResetToken(HashUtils.hash(token.getBytes()));
+ seeded.setPasswordResetTokenExpiry(Instant.now().plus(1, ChronoUnit.HOURS));
+ seeded.markAsDeleted();
+ userRepository.save(seeded).block();
+
+ runBackfill();
+
+ Boolean reset = userService.resetLostPassword(address, token, "new-password").block();
+ log.info("reset against a soft-deleted account returned {}", reset);
+ assertNull(reset, "a deleted account must not be a password-reset target");
+ }
+
+ /**
+ * An account that belongs to no workspace. It is a reachable state: {@code loginOrRegister} only
+ * creates a default org when {@code authProperties.getWorkspaceCreation()} is on, so with workspace
+ * creation disabled every new form registration lands here, as does anyone whose last org was
+ * deleted or who was removed from it.
+ *
+ * The backfill itself does not care — it never reads org membership. This pins that, and pins what
+ * {@code lostPassword} does for such an account.
+ */
+ @Test
+ public void anAccountInNoOrgIsStillConvergedByTheBackfill() {
+ String stored = "Orgless.Legacy@Example.COM";
+ String typed = "orgless.legacy@example.com";
+ User seeded = seedLegacy(stored);
+
+ runBackfill();
+
+ User found = userService.findBySourceAndId(SOURCE, typed).block();
+ assertNotNull(found, "org membership is irrelevant to the backfill");
+ assertEquals(seeded.getId(), found.getId());
+ assertEquals(typed, found.getEmail());
+
+ // And recovery still resolves it, regardless of case: resetLostPassword does not consult orgs.
+ User reloaded = userRepository.findById(seeded.getId()).block();
+ assertNotNull(reloaded);
+ String token = "orgless-token";
+ reloaded.setPasswordResetToken(HashUtils.hash(token.getBytes()));
+ reloaded.setPasswordResetTokenExpiry(Instant.now().plus(1, ChronoUnit.HOURS));
+ userRepository.save(reloaded).block();
+
+ assertEquals(Boolean.TRUE, userService.resetLostPassword(typed, token, "new-password").block(),
+ "resetting must work for an account that belongs to no workspace");
+ }
+
+ /**
+ * ...but requesting the token does not, and fails silently.
+ *
+ *
{@code lostPassword} zips the user with their current organization to pick an email template, and
+ * {@code getCurrentOrgMember} completes empty for an account in no workspace. {@code zipWhen} with an
+ * empty inner publisher yields empty, so the method stores no token, sends no mail, and reports
+ * nothing — the caller cannot distinguish it from success.
+ *
+ *
Pre-existing and not caused by the email work, but it means password recovery is unusable for
+ * this class of account regardless of casing, so it is pinned here rather than left as folklore.
+ */
+ @Test
+ public void requestingAPasswordResetSilentlyDoesNothingForAnAccountInNoOrg() {
+ String address = "orgless.request@example.com";
+ User seeded = seedLegacy(address);
+
+ Boolean requested = userService.lostPassword(address).block();
+ log.info("lostPassword for an org-less account returned {}", requested);
+
+ User after = userRepository.findById(seeded.getId()).block();
+ assertNotNull(after);
+ assertNull(after.getPasswordResetToken(),
+ "no token is stored, and the caller is told nothing -- this is the gap");
+ }
+
+ /**
+ * The honest counterpart: a genuine conflict is left in place by design, so the lowercase form still
+ * does not reach it. This is what the collision policy costs, pinned so it stays visible.
+ */
+ @Test
+ public void aConflictingPairIsLeftAloneAndStaysUnreachableByItsLowercaseForm() {
+ String lower = "conflict.pair@example.com";
+ String upper = "Conflict.Pair@Example.COM";
+ User canonical = seedLegacy(lower);
+ User mixed = seedLegacy(upper);
+
+ runBackfill();
+
+ User reloadedMixed = userRepository.findById(mixed.getId()).block();
+ assertNotNull(reloadedMixed);
+ assertEquals(upper, reloadedMixed.getEmail(),
+ "a conflicting account must be left byte-for-byte as it was");
+
+ User byLower = userService.findBySourceAndId(SOURCE, lower).block();
+ assertNotNull(byLower);
+ assertEquals(canonical.getId(), byLower.getId(),
+ "the lowercase form still resolves the account that already held it");
+
+ User byUpper = userService.findBySourceAndId(SOURCE, upper).block();
+ assertNotNull(byUpper, "and the frozen account is still reachable by its own stored casing, "
+ + "which is what makes freezing safe rather than a lockout");
+ assertEquals(mixed.getId(), byUpper.getId());
+ }
+
+ /** The conflict report is the whole remediation story, so it has to actually land in the database. */
+ @Test
+ public void aConflictIsRecordedInTheReportCollection() {
+ seedLegacy("reported.pair@example.com");
+ seedLegacy("Reported.Pair@Example.COM");
+
+ runBackfill();
+
+ long rows = blockingMongoTemplate.getDb()
+ .getCollection(EmailNormalizationBackfill.REPORT_COLLECTION)
+ .countDocuments(com.mongodb.client.model.Filters.eq("normalizedValue",
+ "reported.pair@example.com"));
+ log.info("report rows for the conflicting pair: {}", rows);
+ assertTrue(rows >= 2, "both members of the group must be reported for an operator to resolve");
+ }
+
+ /** Re-running the changeset against already-converged data must be a no-op, not a second rewrite. */
+ @Test
+ public void reRunningTheBackfillChangesNothing() {
+ seedLegacy("Rerun.Legacy@Example.COM");
+ runBackfill();
+ EmailNormalizationBackfill.Summary second =
+ EmailNormalizationBackfill.run(blockingMongoTemplate.getDb());
+ log.info("second run: {}", second);
+ assertEquals(0, second.modified(), "a converged database must produce no writes on a re-run");
+ }
+}
diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/EmailNormalizationTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/EmailNormalizationTest.java
new file mode 100644
index 0000000000..3844cedbb8
--- /dev/null
+++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/authentication/EmailNormalizationTest.java
@@ -0,0 +1,324 @@
+package org.lowcoder.api.authentication;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.lowcoder.sdk.exception.BizError.INVALID_EMAIL_FORMAT;
+import static org.lowcoder.sdk.exception.BizError.USER_LOGIN_ID_EXIST;
+
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.lowcoder.api.authentication.AuthenticationEndpoints.FormLoginRequest;
+import org.lowcoder.domain.authentication.AuthenticationService;
+import org.lowcoder.domain.authentication.FindAuthConfig;
+import org.lowcoder.domain.encryption.EncryptionService;
+import org.lowcoder.domain.user.model.Connection;
+import org.lowcoder.domain.user.model.User;
+import org.lowcoder.domain.user.model.UserState;
+import org.lowcoder.domain.user.repository.UserRepository;
+import org.lowcoder.sdk.auth.AbstractAuthConfig;
+import org.lowcoder.sdk.constants.AuthSourceConstants;
+import org.lowcoder.sdk.exception.BizException;
+import org.lowcoder.sdk.util.EmailUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
+import org.springframework.mock.web.server.MockServerWebExchange;
+import org.springframework.test.context.ActiveProfiles;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * End-to-end cover for email sanitization, driven through {@link AuthenticationController#formLogin} -- the
+ * same entry point the browser hits -- so it exercises the whole chain: the shape check in
+ * {@code FormAuthRequest}, the two-probe lookups in {@code UserServiceImpl}, the normalization in
+ * {@code AuthUser#toAuthConnection} and {@code createNewUserByAuthUser}, and the case-insensitive connection
+ * match in {@code AuthenticationApiServiceImpl#getAuthConnection}.
+ *
+ *
The bug being closed: {@code john@doe.com} and {@code JoHn@DoE.com} registered as two separate
+ * accounts, because nothing normalized and MongoDB has no collation on these fields.
+ *
+ *
The constraint being respected: an account stored with mixed case must keep working exactly as before,
+ * whether or not the backfill (changeset {@code 032}) has reached it -- and it never does here, because
+ * {@code DatabaseChangelog} is {@code @Profile("!test")}. {@link #legacyMixedCaseAccountStillLogsIn} is the
+ * regression guard, and {@link #legacyMixedCaseAccountIsNotReachableByItsLowercaseForm} pins the lookup
+ * limitation that remains for any row the backfill leaves in place.
+ *
+ *
Every test uses its own address: this class shares an application context, and therefore one embedded
+ * MongoDB, with the other {@code @ActiveProfiles("test")} test classes.
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Slf4j
+public class EmailNormalizationTest {
+
+ private static final String SOURCE = AuthSourceConstants.EMAIL;
+ private static final String PASSWORD = "lowcoder";
+
+ @Autowired
+ private AuthenticationController authenticationController;
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private EncryptionService encryptionService;
+ @Autowired
+ private AuthenticationService authenticationService;
+
+ private String getEmailAuthConfigId() {
+ return authenticationService.findAuthConfigBySource(null, AuthSourceConstants.EMAIL)
+ .map(FindAuthConfig::authConfig)
+ .map(AbstractAuthConfig::getId)
+ .block();
+ }
+
+ /** Drives the real endpoint. {@code register} true is sign-up, false is sign-in. */
+ private void formLogin(String loginId, boolean register) {
+ FormLoginRequest request = new FormLoginRequest(loginId, PASSWORD, register, SOURCE, getEmailAuthConfigId());
+ MockServerWebExchange exchange =
+ MockServerWebExchange.builder(MockServerHttpRequest.post("").build()).build();
+ authenticationController.formLogin(request, null, null, exchange).block();
+ }
+
+ private BizException formLoginExpectingFailure(String loginId, boolean register) {
+ try {
+ formLogin(loginId, register);
+ } catch (BizException e) {
+ return e;
+ }
+ throw new AssertionError("expected formLogin(\"" + loginId + "\", register=" + register
+ + ") to fail, but it succeeded");
+ }
+
+ private User findByRawId(String rawId) {
+ return userRepository.findByConnections_SourceAndConnections_RawId(SOURCE, rawId).block();
+ }
+
+ private Connection emailConnection(User user) {
+ return user.getConnections().stream()
+ .filter(connection -> SOURCE.equals(connection.getSource()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("user " + user.getId() + " has no EMAIL connection"));
+ }
+
+ private void logUser(String label, User user) {
+ if (user == null) {
+ log.info("{}: ", label);
+ return;
+ }
+ log.info("{}: id={} name={} email={} connections={}", label, user.getId(), user.getName(),
+ user.getEmail(),
+ user.getConnections().stream()
+ .map(c -> c.getSource() + ":" + c.getRawId() + " (email=" + c.getEmail() + ")")
+ .toList());
+ }
+
+ // ------------------------------------------------------------------ writes
+
+ /**
+ * Registration is the main writer of an address. Whatever case the user typed, one canonical form has to
+ * land in {@code user.email} and in the connection, or the next login is a coin flip.
+ */
+ @Test
+ public void registrationStoresTheAddressInCanonicalForm() {
+ String typed = "MiXeD.Case@Example.COM";
+ String canonical = "mixed.case@example.com";
+
+ formLogin(typed, true);
+
+ User user = findByRawId(canonical);
+ logUser("registered as " + typed, user);
+ assertNotNull(user, "the account must be reachable by the canonical rawId " + canonical);
+
+ assertEquals(canonical, user.getEmail(), "user.email must be stored canonical");
+ Connection connection = emailConnection(user);
+ assertEquals(canonical, connection.getRawId(), "connection.rawId must be stored canonical");
+ assertEquals(canonical, connection.getEmail(), "connection.email must be stored canonical");
+
+ assertNull(findByRawId(typed),
+ "nothing may remain stored under the typed casing " + typed);
+
+ // user.name keeps what the user typed. Pinned so a future change to name handling is a deliberate
+ // one. It is no longer a lookup key: UserServiceImpl#lostPassword and #resetLostPassword now
+ // resolve by address via findByEmailDeep, falling back to findByName only when that finds
+ // nothing, so password recovery is case-insensitive like login.
+ assertEquals(typed, user.getName(), "user.name is left as typed");
+ }
+
+ /** Surrounding whitespace is stripped by the same normalization, not stored into the identifier. */
+ @Test
+ public void registrationTrimsSurroundingWhitespace() {
+ String typed = " padded@example.com\t";
+ String canonical = "padded@example.com";
+
+ formLogin(typed, true);
+
+ User user = findByRawId(canonical);
+ logUser("registered as " + EmailUtils.normalize(typed) + " (typed with padding)", user);
+ assertNotNull(user, "the account must be reachable by the trimmed rawId " + canonical);
+ assertEquals(canonical, user.getEmail());
+ assertEquals(canonical, emailConnection(user).getRawId());
+ }
+
+ // ------------------------------------------------------------------- the bug
+
+ /**
+ * The bug this branch exists to close. Before the fix, the second registration found nothing -- the
+ * lookup was a byte-exact match on the typed casing -- and happily created a second account for the same
+ * address.
+ */
+ @Test
+ public void registrationRejectsADuplicateThatDiffersOnlyInCase() {
+ formLogin("dup@example.com", true);
+
+ BizException exception = formLoginExpectingFailure("DuP@Example.COM", true);
+ log.info("second registration rejected with {} / {}", exception.getError(), exception.getMessageKey());
+
+ assertEquals(USER_LOGIN_ID_EXIST, exception.getError());
+ assertEquals("USER_LOGIN_ID_EXIST", exception.getMessageKey());
+
+ assertNull(findByRawId("DuP@Example.COM"), "no second account may have been created");
+ }
+
+ /**
+ * The other half of the same bug: having registered once, the user must be able to sign in however they
+ * type it. This is also what proves {@code getAuthConnection} agrees with the lookup -- it runs on the
+ * login path after the user is resolved, and a byte-exact comparison there would throw on exactly this
+ * login rather than returning a connection.
+ */
+ @Test
+ public void loginSucceedsWhenTheTypedCaseDiffersFromTheStoredAddress() {
+ String canonical = "caselogin@example.com";
+ formLogin(canonical, true);
+ User afterRegister = findByRawId(canonical);
+ assertNotNull(afterRegister);
+ String userId = afterRegister.getId();
+
+ formLogin("CaseLogin@Example.COM", false);
+
+ User afterLogin = findByRawId(canonical);
+ logUser("after mixed-case login", afterLogin);
+ assertNotNull(afterLogin, "the mixed-case login must resolve to the existing account");
+ assertEquals(userId, afterLogin.getId(), "it must be the SAME account, not a new one");
+ assertEquals(UserState.ACTIVATED, afterLogin.getState());
+ assertTrue(encryptionService.matchPassword(PASSWORD, afterLogin.getPassword()));
+
+ assertEquals(1, afterLogin.getConnections().size(),
+ "the login must reuse the existing connection, not append a second one");
+ assertEquals(canonical, emailConnection(afterLogin).getRawId(),
+ "the stored rawId must stay canonical -- the login must not rewrite it to the typed casing");
+ assertNull(findByRawId("CaseLogin@Example.COM"), "no second account may have been created");
+ }
+
+ // ----------------------------------------------------------- no regression
+
+ /**
+ * The no-migration guarantee, as a test. An account written before normalization holds whatever case the
+ * user typed back then; signing in with that same value must still work. A single normalized probe would
+ * fail here -- it would look up {@code legacy@example.com}, find nothing, and lock the account out.
+ */
+ @Test
+ public void legacyMixedCaseAccountStillLogsIn() {
+ String legacy = "Legacy@Example.COM";
+ User seeded = userRepository.save(User.builder()
+ .name(legacy)
+ .email(legacy)
+ .password(encryptionService.encryptPassword(PASSWORD))
+ .state(UserState.ACTIVATED)
+ .isEnabled(true)
+ .connections(Set.of(Connection.builder()
+ .source(SOURCE)
+ .name(legacy)
+ .rawId(legacy)
+ .email(legacy)
+ .build()))
+ .build())
+ .block();
+ assertNotNull(seeded);
+ logUser("seeded pre-normalization account", seeded);
+
+ formLogin(legacy, false);
+
+ User afterLogin = findByRawId(legacy);
+ logUser("after legacy login", afterLogin);
+ assertNotNull(afterLogin, "a pre-normalization account must still be found by its stored value");
+ assertEquals(seeded.getId(), afterLogin.getId(), "it must be the SAME account, not a new one");
+ assertEquals(legacy, emailConnection(afterLogin).getRawId(),
+ "logging in must not rewrite the stored value; only changeset 032 converges it");
+ }
+
+ /**
+ * The known limitation of two byte-exact probes, pinned deliberately. Reaching a stored
+ * {@code Stored@Example.COM} from the input {@code stored@example.com} needs a case-insensitive query,
+ * which MongoDB cannot serve from the existing {@code (connections.source, connections.rawId)} index.
+ *
+ * This describes the lookup , and it stays true even though the backfill migration now ships.
+ * The migration converges stored data; it does not give the query case-insensitivity. So this remains
+ * the behaviour for every row the backfill does not rewrite -- a member of a reported conflict group,
+ * a row written by something outside the normalizing paths, or an install where changeset {@code 032}
+ * has not run yet.
+ *
+ *
It therefore keeps its assertions rather than being inverted. The converged case is covered
+ * separately, end to end, by
+ * {@code EmailBackfillEndToEndTest#aLegacyMixedCaseAccountBecomesReachableByItsLowercaseForm}; this
+ * test seeds its own row under the {@code test} profile, where {@code DatabaseChangelog} is disabled,
+ * so no migration ever touches it.
+ */
+ @Test
+ public void legacyMixedCaseAccountIsNotReachableByItsLowercaseForm() {
+ String legacy = "Stored@Example.COM";
+ userRepository.save(User.builder()
+ .name(legacy)
+ .email(legacy)
+ .password(encryptionService.encryptPassword(PASSWORD))
+ .state(UserState.ACTIVATED)
+ .isEnabled(true)
+ .connections(Set.of(Connection.builder()
+ .source(SOURCE)
+ .name(legacy)
+ .rawId(legacy)
+ .email(legacy)
+ .build()))
+ .build())
+ .block();
+
+ User byLowercase = findByRawId("stored@example.com");
+ log.info("lookup of the lowercase form of a mixed-case stored account: {}",
+ byLowercase == null ? "" : byLowercase.getId());
+ assertNull(byLowercase, "documents the limitation: converging these needs the backfill migration");
+ }
+
+ // --------------------------------------------------------------- shape check
+
+ /**
+ * Registration used to accept anything, including the empty string, and store it as an address. An
+ * account whose address is {@code ""} is then resolvable by a subsequent login that also sends
+ * {@code ""} -- which is why the shape check runs before anything is created.
+ */
+ @ParameterizedTest(name = "registration rejects [{0}]")
+ @ValueSource(strings = {
+ "",
+ " ",
+ "notanemail",
+ "@example.com",
+ "user@",
+ "a@b@c.com",
+ "John Doe@example.com",
+ "Iron Man",
+ })
+ public void registrationRejectsValuesThatAreNotAddresses(String notAnAddress) {
+ BizException exception = formLoginExpectingFailure(notAnAddress, true);
+ log.info("registration of [{}] rejected with {} / {}", notAnAddress, exception.getError(),
+ exception.getMessageKey());
+
+ assertEquals(INVALID_EMAIL_FORMAT, exception.getError());
+ assertEquals("INVALID_EMAIL_FORMAT", exception.getMessageKey());
+
+ assertNull(findByRawId(notAnAddress), "nothing may have been stored for [" + notAnAddress + "]");
+ assertNull(findByRawId(EmailUtils.normalize(notAnAddress)),
+ "nothing may have been stored for the normalized form of [" + notAnAddress + "]");
+ }
+}
diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/EmailSanitizationEndpointsTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/EmailSanitizationEndpointsTest.java
new file mode 100644
index 0000000000..4d62401f94
--- /dev/null
+++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/api/usermanagement/EmailSanitizationEndpointsTest.java
@@ -0,0 +1,365 @@
+package org.lowcoder.api.usermanagement;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.lowcoder.sdk.exception.BizError.ALREADY_BIND;
+import static org.lowcoder.sdk.exception.BizError.INVALID_EMAIL_FORMAT;
+import static org.lowcoder.sdk.exception.BizError.NOT_AUTHORIZED;
+
+import java.util.HashSet;
+
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+import org.lowcoder.api.common.InitData;
+import org.lowcoder.api.common.mockuser.WithMockUser;
+import org.lowcoder.api.usermanagement.UserEndpoints.CreateUserRequest;
+import org.lowcoder.domain.user.model.Connection;
+import org.lowcoder.domain.user.model.User;
+import org.lowcoder.domain.user.repository.UserRepository;
+import org.lowcoder.domain.user.service.UserService;
+import org.lowcoder.sdk.constants.AuthSourceConstants;
+import org.lowcoder.sdk.exception.BizException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import reactor.core.publisher.Mono;
+import reactor.test.StepVerifier;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Cover for the two address writers that {@link org.lowcoder.api.authentication.EmailNormalizationTest} does
+ * not reach: SCIM provisioning ({@link UserController#createSCIMUserAndAddToOrg}) and
+ * {@link UserService#bindEmail}, which attaches an address to an account that signed in via SSO.
+ *
+ * Both wrote whatever they were handed. {@code bindEmail} takes a bare {@code @RequestParam} and wrote it
+ * to {@code user.email} and to a connection {@code rawId} that the form login then resolves against --
+ * so an unvalidated value there is not just untidy data, it is a login identifier.
+ *
+ *
Fixtures come from {@code InitData}: {@code user01} is an ADMIN of {@code org01} and {@code user02} is
+ * an ordinary member, which is what the authorization-ordering test needs.
+ */
+@SpringBootTest
+@ActiveProfiles("emailSanitization")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@Slf4j
+public class EmailSanitizationEndpointsTest {
+
+ private static final String ORG_ID = WithMockUser.DEFAULT_CURRENT_ORG_ID;
+ private static final String NON_ADMIN_USER_ID = "user02";
+ private static final String PASSWORD = "lowcoder";
+
+ @Autowired
+ private UserController userController;
+ @Autowired
+ private UserService userService;
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private InitData initData;
+
+ @BeforeAll
+ public void beforeAll() {
+ initData.init();
+ }
+
+ private BizException expectFailure(Mono> mono, String what) {
+ try {
+ mono.block();
+ } catch (BizException e) {
+ return e;
+ }
+ throw new AssertionError("expected " + what + " to fail, but it succeeded");
+ }
+
+ private User findByEmail(String email) {
+ return userRepository.findByEmailOrConnections_Email(email, email).next().block();
+ }
+
+ // ------------------------------------------------------- SCIM provisioning
+
+ /**
+ * A SCIM client that sends a mixed-case address must produce one canonical row, or the next SSO login --
+ * which resolves by email -- provisions a second account for the same person.
+ */
+ @Test
+ @WithMockUser
+ public void scimProvisioningStoresTheAddressInCanonicalForm() {
+ String typed = "SCIM.User@Example.COM";
+ String canonical = "scim.user@example.com";
+
+ userController.createSCIMUserAndAddToOrg(ORG_ID, new CreateUserRequest(typed, PASSWORD)).block();
+
+ User user = findByEmail(canonical);
+ log.info("SCIM-provisioned [{}] -> id={} name={} email={}", typed,
+ user == null ? null : user.getId(),
+ user == null ? null : user.getName(),
+ user == null ? null : user.getEmail());
+
+ assertNotNull(user, "the provisioned account must be reachable by the canonical address");
+ assertEquals(canonical, user.getEmail(), "user.email must be stored canonical");
+ // This endpoint has no display name to work with, so it uses the address for both fields. Both must
+ // therefore be canonical -- unlike form registration, where name is what the user typed.
+ assertEquals(canonical, user.getName(), "SCIM uses the address as the name, so it too is canonical");
+
+ assertNull(findByEmail(typed), "nothing may remain stored under the typed casing");
+ }
+
+ /**
+ * An empty address used to create a row whose {@code email} is {@code ""}. Nothing can ever resolve it:
+ * {@code findByEmailDeep} short-circuits on an empty value, so the next SCIM call for the same person
+ * creates yet another row.
+ */
+ @ParameterizedTest(name = "SCIM rejects [{0}]")
+ @ValueSource(strings = { "", " ", "notanemail", "@example.com", "user@", "a@b@c.com", "Iron Man" })
+ @WithMockUser
+ public void scimProvisioningRejectsValuesThatAreNotAddresses(String notAnAddress) {
+ BizException exception = expectFailure(
+ userController.createSCIMUserAndAddToOrg(ORG_ID, new CreateUserRequest(notAnAddress, PASSWORD)),
+ "SCIM provisioning of [" + notAnAddress + "]");
+ log.info("SCIM provisioning of [{}] rejected with {} / {}", notAnAddress, exception.getError(),
+ exception.getMessageKey());
+
+ assertEquals(INVALID_EMAIL_FORMAT, exception.getError());
+ assertEquals("INVALID_EMAIL_FORMAT", exception.getMessageKey());
+ assertNull(findByEmail(notAnAddress), "nothing may have been stored for [" + notAnAddress + "]");
+ }
+
+ /**
+ * Authorization is checked before the payload is. A non-admin must get {@code NOT_AUTHORIZED} whatever
+ * they send, so that the error they see cannot be used to distinguish a malformed payload from a
+ * well-formed one.
+ */
+ @Test
+ @WithMockUser(id = NON_ADMIN_USER_ID)
+ public void scimProvisioningRejectsANonAdminBeforeLookingAtThePayload() {
+ BizException exception = expectFailure(
+ userController.createSCIMUserAndAddToOrg(ORG_ID, new CreateUserRequest("", PASSWORD)),
+ "SCIM provisioning by a non-admin");
+ log.info("SCIM provisioning by non-admin {} rejected with {} / {}", NON_ADMIN_USER_ID,
+ exception.getError(), exception.getMessageKey());
+
+ assertEquals(NOT_AUTHORIZED, exception.getError(),
+ "a non-admin must be turned away by the role check, not by the shape check");
+ }
+
+ // -------------------------------------------------------------- bindEmail
+
+ /**
+ * Binding an address to an existing account writes it in three places at once -- {@code user.email}, the
+ * new connection's {@code rawId} and its {@code email}. All three must be canonical, because
+ * {@code rawId} is what the form login matches on.
+ */
+ @Test
+ public void bindEmailStoresTheAddressInCanonicalForm() {
+ String typed = " Bound.User@Example.COM ";
+ String canonical = "bound.user@example.com";
+
+ User user = userRepository.save(User.builder()
+ .name("sso-only-user")
+ .isEnabled(true)
+ .connections(new HashSet<>())
+ .build())
+ .block();
+ assertNotNull(user);
+
+ StepVerifier.create(userService.bindEmail(user, typed))
+ .expectNext(true)
+ .verifyComplete();
+
+ User bound = userRepository.findById(user.getId()).block();
+ assertNotNull(bound);
+ log.info("bindEmail([{}]) -> email={} connections={}", typed, bound.getEmail(),
+ bound.getConnections().stream()
+ .map(c -> c.getSource() + ":" + c.getRawId() + " (email=" + c.getEmail() + ")")
+ .toList());
+
+ assertEquals(canonical, bound.getEmail(), "user.email must be stored canonical");
+
+ Connection connection = bound.getConnections().stream()
+ .filter(c -> AuthSourceConstants.EMAIL.equals(c.getSource()))
+ .findFirst()
+ .orElseThrow(() -> new AssertionError("bindEmail did not add an EMAIL connection"));
+ assertEquals(canonical, connection.getRawId(),
+ "connection.rawId is a login identifier and must be stored canonical");
+ assertEquals(canonical, connection.getEmail(), "connection.email must be stored canonical");
+ assertEquals(canonical, connection.getName(), "connection.name must be stored canonical");
+
+ assertTrue(userService.findBySourceAndId(AuthSourceConstants.EMAIL, "BOUND.USER@example.com")
+ .hasElement().block(),
+ "the bound address must now resolve however it is typed");
+ }
+
+ /**
+ * The regression that normalizing on its own would introduce. The unique index on
+ * {@code (connections.source, connections.rawId)} is what used to stop two accounts holding one
+ * address -- but it is byte-exact, so once the bind writes {@code legacy@example.com} where the
+ * existing account holds {@code Legacy@Example.COM}, the keys differ and the index waves it through.
+ * Both accounts would then be reachable by the same address in different casings.
+ */
+ @Test
+ public void bindEmailRejectsAnAddressAnotherAccountAlreadyHoldsInADifferentCase() {
+ String legacy = "Taken.Legacy@Example.COM";
+
+ User owner = userRepository.save(User.builder()
+ .name(legacy)
+ .email(legacy)
+ .isEnabled(true)
+ .connections(newConnectionSet(legacy))
+ .build())
+ .block();
+ assertNotNull(owner);
+
+ User binder = userRepository.save(User.builder()
+ .name("sso-only-binder")
+ .isEnabled(true)
+ .connections(new HashSet<>())
+ .build())
+ .block();
+ assertNotNull(binder);
+
+ BizException exception = expectFailure(userService.bindEmail(binder, legacy),
+ "binding an address another account already holds");
+ log.info("bindEmail([{}]) by {} rejected with {} / {} (already held by {})", legacy, binder.getId(),
+ exception.getError(), exception.getMessageKey(), owner.getId());
+
+ assertEquals(ALREADY_BIND, exception.getError());
+
+ User unchanged = userRepository.findById(binder.getId()).block();
+ assertNotNull(unchanged);
+ assertNull(unchanged.getEmail(), "the rejected bind must not have written an address");
+ assertTrue(unchanged.getConnections().isEmpty(), "the rejected bind must not have added a connection");
+ }
+
+ /**
+ * Re-binding an address the account already holds must not append a second EMAIL connection differing
+ * from the first only in case -- that is precisely the state this change exists to prevent.
+ */
+ @Test
+ public void bindEmailIsIdempotentForAnAddressThisAccountAlreadyHolds() {
+ String legacy = "Own.Legacy@Example.COM";
+
+ User user = userRepository.save(User.builder()
+ .name(legacy)
+ .email(legacy)
+ .isEnabled(true)
+ .connections(newConnectionSet(legacy))
+ .build())
+ .block();
+ assertNotNull(user);
+
+ StepVerifier.create(userService.bindEmail(user, legacy))
+ .expectNext(true)
+ .verifyComplete();
+
+ User after = userRepository.findById(user.getId()).block();
+ assertNotNull(after);
+ log.info("re-bind of [{}] -> email={} connections={}", legacy, after.getEmail(),
+ after.getConnections().stream().map(c -> c.getSource() + ":" + c.getRawId()).toList());
+
+ assertEquals(1, after.getConnections().size(),
+ "re-binding must not append a second EMAIL connection");
+ assertEquals(legacy, after.getConnections().iterator().next().getRawId(),
+ "re-binding must leave the stored value alone; only changeset 032 converges it");
+ }
+
+ /**
+ * The residual hole, pinned deliberately so it is not mistaken for a guarantee the probe gives.
+ *
+ *
The ownership probe reaches an owner stored under the typed casing and an owner stored
+ * canonically. It cannot reach one stored in a third casing, because that needs a
+ * case-insensitive query and MongoDB will not serve one from the byte-exact
+ * {@code (connections.source, connections.rawId)} index.
+ *
+ *
This is unchanged from before normalization was added : the index is byte-exact, so
+ * {@code legacy@example.com} and {@code Legacy@Example.COM} were always two distinct keys and this
+ * bind always succeeded.
+ *
+ *
The backfill migration now ships, and this still holds -- it describes the probe , not the
+ * stored data. Once changeset {@code 032} has converged the owner, the bind is rejected; that is
+ * covered by {@code EmailBackfillEndToEndTest#bindEmailFindsALegacyMixedCaseOwnerAfterTheBackfill}.
+ * What remains is the row the backfill deliberately leaves alone -- a member of a reported conflict
+ * group -- which this test keeps pinned. It seeds its own row under a profile whose migration has
+ * already run against an empty collection, so nothing converges it.
+ */
+ @Test
+ public void bindEmailStillMissesALegacyMixedCaseOwnerFromLowercaseInput() {
+ String storedByOwner = "Missed.Legacy@Example.COM";
+ String typedByBinder = "missed.legacy@example.com";
+
+ User owner = userRepository.save(User.builder()
+ .name(storedByOwner)
+ .email(storedByOwner)
+ .isEnabled(true)
+ .connections(newConnectionSet(storedByOwner))
+ .build())
+ .block();
+ assertNotNull(owner);
+
+ User binder = userRepository.save(User.builder()
+ .name("sso-only-binder-lowercase")
+ .isEnabled(true)
+ .connections(new HashSet<>())
+ .build())
+ .block();
+ assertNotNull(binder);
+
+ StepVerifier.create(userService.bindEmail(binder, typedByBinder))
+ .expectNext(true)
+ .verifyComplete();
+
+ User bound = userRepository.findById(binder.getId()).block();
+ assertNotNull(bound);
+ log.info("documented gap: [{}] bound to {} even though {} holds [{}]", typedByBinder,
+ bound.getId(), owner.getId(), storedByOwner);
+
+ assertEquals(typedByBinder, bound.getEmail(),
+ "documents the gap: the probe cannot see a third casing, so the bind goes through");
+ }
+
+ private static HashSet newConnectionSet(String address) {
+ HashSet connections = new HashSet<>();
+ connections.add(Connection.builder()
+ .source(AuthSourceConstants.EMAIL)
+ .name(address)
+ .rawId(address)
+ .email(address)
+ .build());
+ return connections;
+ }
+
+ /**
+ * The endpoint behind this takes a bare {@code @RequestParam} and validated nothing, so these values
+ * really could be written as a login identifier.
+ */
+ @ParameterizedTest(name = "bindEmail rejects [{0}]")
+ @ValueSource(strings = { "", " ", "notanemail", "@example.com", "user@", "a@b@c.com", "Iron Man" })
+ public void bindEmailRejectsValuesThatAreNotAddresses(String notAnAddress) {
+ User user = userRepository.save(User.builder()
+ .name("bind-reject-" + notAnAddress.hashCode())
+ .isEnabled(true)
+ .connections(new HashSet<>())
+ .build())
+ .block();
+ assertNotNull(user);
+
+ BizException exception = expectFailure(userService.bindEmail(user, notAnAddress),
+ "bindEmail([" + notAnAddress + "])");
+ log.info("bindEmail([{}]) rejected with {} / {}", notAnAddress, exception.getError(),
+ exception.getMessageKey());
+
+ assertEquals(INVALID_EMAIL_FORMAT, exception.getError());
+ assertEquals("INVALID_EMAIL_FORMAT", exception.getMessageKey());
+
+ User unchanged = userRepository.findById(user.getId()).block();
+ assertNotNull(unchanged);
+ assertNull(unchanged.getEmail(), "a rejected bind must not have written an address");
+ assertTrue(unchanged.getConnections().isEmpty(),
+ "a rejected bind must not have added a connection");
+ }
+}
diff --git a/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java
new file mode 100644
index 0000000000..abc80818e6
--- /dev/null
+++ b/server/api-service/lowcoder-server/src/test/java/org/lowcoder/runner/migrations/job/EmailNormalizationBackfillTest.java
@@ -0,0 +1,562 @@
+package org.lowcoder.runner.migrations.job;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.UUID;
+
+import org.bson.Document;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.lowcoder.api.ServerApplication;
+import org.lowcoder.runner.migrations.job.EmailNormalizationBackfill.Summary;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.mongodb.core.MongoTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import com.mongodb.client.MongoCollection;
+import com.mongodb.client.MongoDatabase;
+import com.mongodb.client.model.Filters;
+import com.mongodb.client.model.IndexOptions;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * Drives {@link EmailNormalizationBackfill} directly against the embedded mongod.
+ *
+ * The changeset itself is never driven through Mongock here. Under {@code @ActiveProfiles("test")},
+ * {@code DatabaseChangelog} is {@code @Profile("!test")} and Mongock skips it — which also means the real
+ * {@code (connections.source, connections.rawId)} unique sparse index does not exist in this context, so
+ * each test builds it on its own throwaway collection. That is a gift rather than a nuisance: it makes the
+ * duplicate-key path testable in isolation, which it would not be against the shared {@code user}
+ * collection.
+ *
+ *
Throwaway collections per test, because one embedded mongod is shared across every {@code test}-profile
+ * class and the real {@code user} collection holds other suites' fixtures.
+ *
+ *
{@code classes = ServerApplication.class} is required: {@code @SpringBootTest} only searches upwards
+ * from the test's own package for a {@code @SpringBootConfiguration}, and {@code org.lowcoder.api} is not an
+ * ancestor of {@code org.lowcoder.runner.migrations.job}.
+ */
+@SpringBootTest(classes = ServerApplication.class)
+@ActiveProfiles("test")
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@Slf4j
+public class EmailNormalizationBackfillTest {
+
+ private static final String EMAIL = "EMAIL";
+ private static final String GOOGLE = "GOOGLE";
+ private static final String GENERIC = "GENERIC";
+ /** What {@code User.markAsDeleted} leaves behind in {@code connections[].source}. */
+ private static final String DELETED_EMAIL_SOURCE = "EMAIL(User deleted at 1700000000)";
+
+ @Autowired
+ private MongoTemplate blockingMongoTemplate;
+
+ private MongoDatabase db;
+ private String usersName;
+ private String reportName;
+ private MongoCollection users;
+ private MongoCollection report;
+
+ @BeforeEach
+ public void beforeEach() {
+ db = blockingMongoTemplate.getDb();
+ String suffix = UUID.randomUUID().toString().replace("-", "");
+ usersName = "testUser_" + suffix;
+ reportName = "testConflict_" + suffix;
+ db.createCollection(usersName);
+ users = db.getCollection(usersName);
+ report = db.getCollection(reportName);
+ // The real index from changeset 016, so collisions behave here exactly as they do in production.
+ users.createIndex(new Document("connections.source", 1).append("connections.rawId", 1),
+ new IndexOptions().unique(true).sparse(true));
+ }
+
+ @AfterEach
+ public void afterEach() {
+ users.drop();
+ report.drop();
+ }
+
+ // ------------------------------------------------------------------------------------- helpers
+
+ private static Document connection(String source, String rawId, String email, String name) {
+ return new Document("source", source).append("rawId", rawId).append("email", email)
+ .append("name", name);
+ }
+
+ private void insert(String id, String email, Object... connections) {
+ users.insertOne(new Document("_id", id).append("email", email)
+ .append("connections", Arrays.asList(connections)));
+ }
+
+ private Summary run() {
+ return EmailNormalizationBackfill.run(db, usersName, reportName);
+ }
+
+ private Document reload(String id) {
+ Document doc = users.find(Filters.eq("_id", id)).first();
+ assertNotNull(doc, "user " + id + " vanished");
+ return doc;
+ }
+
+ private Document conn(String id, int index) {
+ return (Document) ((List>) reload(id).get("connections")).get(index);
+ }
+
+ private long reportRows(String id) {
+ return report.countDocuments(Filters.eq("userId", id));
+ }
+
+ private void logDoc(String label, String id) {
+ log.info("{}: {}", label, reload(id).toJson());
+ }
+
+ // -------------------------------------------------------------------------------- happy path
+
+ @Test
+ public void convergesAMixedCaseAccount() {
+ insert("u1", " JoHn@DoE.COM ",
+ connection(EMAIL, "JoHn@DoE.COM", "JoHn@DoE.COM", "JoHn@DoE.COM"),
+ connection(GOOGLE, "SuB-XyZ-123", "JoHn@DoE.COM", "John Doe"));
+
+ Summary summary = run();
+ logDoc("converged", "u1");
+
+ assertEquals("john@doe.com", reload("u1").getString("email"), "user.email must be canonical");
+ assertEquals("john@doe.com", conn("u1", 0).getString("rawId"), "EMAIL rawId must be canonical");
+ assertEquals("john@doe.com", conn("u1", 0).getString("email"), "connection email must be canonical");
+ assertEquals("john@doe.com", conn("u1", 1).getString("email"),
+ "a non-EMAIL connection's email is still an address and is normalized");
+
+ // The two identity fields the migration must never touch.
+ assertEquals("SuB-XyZ-123", conn("u1", 1).getString("rawId"),
+ "an opaque IdP subject must survive byte-for-byte -- lowercasing it would let subject "
+ + "AbC authenticate as abc");
+ assertEquals("JoHn@DoE.COM", conn("u1", 0).getString("name"),
+ "connection.name is a display field the write path does not normalize either");
+ assertEquals(1, summary.modified());
+ }
+
+ /**
+ * {@code connections[].name} is deliberately excluded. {@code AuthUser.toAuthConnection} writes it as
+ * {@code .name(getUsername())} un-normalized, so every account created since the sanitization change
+ * already stores a raw-cased name; normalizing it here would fight the application and the divergence
+ * would reappear on the very next login.
+ */
+ @Test
+ public void neverTouchesConnectionName() {
+ insert("u1", "john@doe.com", connection(EMAIL, "john@doe.com", "john@doe.com", "JoHn@DoE.COM"));
+ run();
+ assertEquals("JoHn@DoE.COM", conn("u1", 0).getString("name"));
+ }
+
+ @Test
+ public void isIdempotentAndWritesNothingOnASecondRun() {
+ insert("u1", "JoHn@DoE.COM", connection(EMAIL, "JoHn@DoE.COM", "JoHn@DoE.COM", "n"));
+
+ assertEquals(1, run().modified(), "first run converges the document");
+ Summary second = run();
+ log.info("second run: {}", second);
+ assertEquals(0, second.modified(), "a converged document must produce no write at all");
+ assertEquals("john@doe.com", reload("u1").getString("email"));
+ }
+
+ @Test
+ public void createsNoEmailKeyForADocumentThatHasNone() {
+ users.insertOne(new Document("_id", "u1")
+ .append("connections", List.of(connection(EMAIL, "JoHn@DoE.COM", null, "n"))));
+ run();
+ assertTrue(!reload("u1").containsKey("email"),
+ "a document with no email must not gain one -- normalize() is null-safe and an "
+ + "unconditional rule would write an explicit null");
+ assertEquals("john@doe.com", conn("u1", 0).getString("rawId"));
+ }
+
+ /**
+ * A connection carries more than the three fields the migration rewrites — {@code orgIds},
+ * {@code authId}, {@code rawUserInfo}, {@code tokens}. Dotted {@code $set} paths are used precisely so
+ * those survive; a {@code ReplaceOneModel} built from the projected document would silently drop every
+ * one of them, and an emptied {@code orgIds} would detach the connection from its workspaces.
+ */
+ @Test
+ public void preservesEveryConnectionFieldItDoesNotRewrite() {
+ Document connection = connection(EMAIL, "JoHn@DoE.COM", "JoHn@DoE.COM", "John Doe")
+ .append("authId", "auth-1")
+ .append("orgIds", List.of("org01", "org02"))
+ .append("rawUserInfo", new Document("email", "JoHn@DoE.COM").append("sub", "XyZ"))
+ .append("tokens", List.of("tok-1"));
+ users.insertOne(new Document("_id", "u1").append("email", "JoHn@DoE.COM")
+ .append("connections", List.of(connection)));
+
+ run();
+ logDoc("after rewrite", "u1");
+
+ Document after = conn("u1", 0);
+ assertEquals("john@doe.com", after.getString("rawId"), "precondition: it really was rewritten");
+ assertEquals("auth-1", after.getString("authId"));
+ assertEquals(List.of("org01", "org02"), after.get("orgIds"),
+ "orgIds must survive -- losing it would detach the connection from its workspaces");
+ assertEquals(List.of("tok-1"), after.get("tokens"));
+ assertEquals("JoHn@DoE.COM", ((Document) after.get("rawUserInfo")).getString("email"),
+ "rawUserInfo is the verbatim IdP payload and is never rewritten");
+ assertEquals("John Doe", after.getString("name"));
+ }
+
+ /** A connection with no orgIds at all, which is what an account outside every workspace looks like. */
+ @Test
+ public void convergesAnAccountThatBelongsToNoOrg() {
+ users.insertOne(new Document("_id", "u1").append("email", "Orgless@Example.COM")
+ .append("connections", List.of(
+ connection(EMAIL, "Orgless@Example.COM", "Orgless@Example.COM", "n")
+ .append("orgIds", List.of()))));
+
+ run();
+
+ assertEquals("orgless@example.com", reload("u1").getString("email"));
+ assertEquals("orgless@example.com", conn("u1", 0).getString("rawId"),
+ "org membership is irrelevant to normalization -- nothing in the migration reads it");
+ assertEquals(List.of(), conn("u1", 0).get("orgIds"));
+ }
+
+ /** And a document with no {@code connections} key whatsoever. */
+ @Test
+ public void handlesADocumentWithNoConnectionsAtAll() {
+ users.insertOne(new Document("_id", "u1").append("email", "Bare@Example.COM"));
+
+ Summary summary = run();
+
+ assertEquals("bare@example.com", reload("u1").getString("email"));
+ assertEquals(1, summary.modified());
+ assertTrue(!reload("u1").containsKey("connections"),
+ "the migration must not invent a connections array");
+ }
+
+ // ----------------------------------------------------------------------------------- freezing
+
+ @Test
+ public void leavesBothMembersOfARawIdCollisionByteExact() {
+ insert("u1", "john@doe.com", connection(EMAIL, "john@doe.com", "john@doe.com", "n"));
+ insert("u2", "JoHn@DoE.COM", connection(EMAIL, "JoHn@DoE.COM", "JoHn@DoE.COM", "n"));
+
+ Summary summary = run();
+ logDoc("collision member 1", "u1");
+ logDoc("collision member 2", "u2");
+
+ assertEquals("john@doe.com", conn("u1", 0).getString("rawId"), "already canonical, unchanged");
+ assertEquals("JoHn@DoE.COM", conn("u2", 0).getString("rawId"),
+ "the conflicting account must be left exactly as it was");
+ assertEquals("JoHn@DoE.COM", reload("u2").getString("email"),
+ "frozen means the whole document is untouched, not partially normalized");
+ assertTrue(summary.rawIdFrozen() >= 2, "both members counted as frozen");
+ assertTrue(reportRows("u1") > 0 && reportRows("u2") > 0, "both members must be reported");
+ }
+
+ /**
+ * Proves the policy is "every member of a conflicting group", not "first owner wins". A
+ * {@code putIfAbsent}-style implementation normalizes the first owner straight onto the others' key.
+ */
+ @Test
+ public void leavesAllThreeMembersOfAGroupUntouched() {
+ insert("u1", null, connection(EMAIL, "John@Doe.com", null, "n"));
+ insert("u2", null, connection(EMAIL, "JOHN@DOE.COM", null, "n"));
+ insert("u3", null, connection(EMAIL, "jOhN@dOe.CoM", null, "n"));
+
+ run();
+
+ assertEquals("John@Doe.com", conn("u1", 0).getString("rawId"));
+ assertEquals("JOHN@DOE.COM", conn("u2", 0).getString("rawId"));
+ assertEquals("jOhN@dOe.CoM", conn("u3", 0).getString("rawId"));
+ }
+
+ /**
+ * A tombstone must not block a live account. {@code User.markAsDeleted} mangles the connection source,
+ * so the deleted row sits under a different index key and is a different group by construction.
+ */
+ @Test
+ public void doesNotFoldADeletedTombstoneIntoALiveGroup() {
+ insert("live", null, connection(EMAIL, "JoHn@DoE.COM", null, "n"));
+ insert("dead", null, connection(DELETED_EMAIL_SOURCE, "john@doe.com", null, "n"));
+
+ run();
+
+ assertEquals("john@doe.com", conn("live", 0).getString("rawId"),
+ "the live account must still converge");
+ assertEquals("john@doe.com", conn("dead", 0).getString("rawId"),
+ "the tombstone's rawId is not under an EMAIL source, so it is never rewritten");
+ assertEquals(DELETED_EMAIL_SOURCE, conn("dead", 0).getString("source"));
+ }
+
+ /**
+ * Many OIDC providers set {@code sub} to the user's address. That subject lives under a different
+ * {@code source}, so it is a different index key and must not freeze the form account. Grouping on the
+ * bare value instead of the source-qualified key would freeze most of an SSO-heavy install.
+ */
+ @Test
+ public void doesNotFoldAnOidcSubjectIntoAnEmailGroup() {
+ insert("form", null, connection(EMAIL, "JoHn@DoE.COM", null, "n"));
+ insert("sso", null, connection(GENERIC, "john@doe.com", null, "n"));
+
+ run();
+
+ assertEquals("john@doe.com", conn("form", 0).getString("rawId"),
+ "the form account must converge despite an OIDC subject holding the same text");
+ assertEquals("john@doe.com", conn("sso", 0).getString("rawId"), "the subject is untouched");
+ }
+
+ /**
+ * The two keyspaces are frozen independently. A shared flag would let changeset 023's pollution of
+ * {@code user.email} freeze a perfectly unambiguous login identifier.
+ */
+ @Test
+ public void freezesOnlyTheEmailSideWhenOnlyEmailsCollide() {
+ insert("u1", "alice@x.com", connection(EMAIL, "alice-one@x.com", "alice@x.com", "n"));
+ insert("u2", null, connection(EMAIL, "Alice-Two@X.com", "Alice@X.com", "n"));
+
+ run();
+ logDoc("email-frozen but rawId-converged", "u2");
+
+ assertEquals("alice-two@x.com", conn("u2", 0).getString("rawId"),
+ "the login identifier does not collide, so it must still converge");
+ assertEquals("Alice@X.com", conn("u2", 0).getString("email"),
+ "the connection email does collide, so it must be left alone");
+ }
+
+ // ----------------------------------------------------------------------- hostile document shapes
+
+ @Test
+ public void leavesBlankNormalizingRawIdsAlone() {
+ insert("u1", null, connection(EMAIL, " ", null, "n"));
+ insert("u2", null, connection(EMAIL, "\t", null, "n"));
+
+ run();
+
+ assertEquals(" ", conn("u1", 0).getString("rawId"),
+ "whitespace-only rawIds must not converge onto a shared empty key");
+ assertEquals("\t", conn("u2", 0).getString("rawId"));
+ }
+
+ @Test
+ public void leavesNonEmailShapedUserEmailAlone() {
+ insert("u1", "Iron Man", connection(EMAIL, "ironman@avengers.com", null, "n"));
+ users.insertOne(new Document("_id", "u2").append("email", 42));
+ users.insertOne(new Document("_id", "u3").append("email", List.of("a@b.com")));
+
+ run();
+
+ assertEquals("Iron Man", reload("u1").getString("email"),
+ "changeset 023 wrote display names into email; lowercasing one is a visible UI regression");
+ assertEquals(42, reload("u2").get("email"), "a non-string email must not throw or be coerced");
+ assertEquals(List.of("a@b.com"), reload("u3").get("email"));
+ }
+
+ /**
+ * The highest-severity bug class in this migration: if positions read do not match positions written, a
+ * dotted update lands on a foreign IdP subject. The whole array is projected precisely so the loop index
+ * is the stored index.
+ */
+ @Test
+ public void isSafeOnRaggedConnectionArrays() {
+ List ragged = new ArrayList<>();
+ ragged.add(connection(GOOGLE, "SuB-1", null, "n"));
+ ragged.add(null);
+ ragged.add("a bare string");
+ ragged.add(42);
+ ragged.add(new Document("authId", "EMAIL"));
+ ragged.add(List.of("nested", "array"));
+ ragged.add(new Document());
+ ragged.add(connection(EMAIL, "JoHn@DoE.COM", null, "n"));
+ users.insertOne(new Document("_id", "u1").append("connections", ragged));
+
+ run();
+ logDoc("ragged", "u1");
+
+ List> after = (List>) reload("u1").get("connections");
+ assertEquals(8, after.size(), "the array must keep its shape");
+ assertEquals("SuB-1", ((Document) after.get(0)).getString("rawId"), "foreign subject intact");
+ assertNull(after.get(1));
+ assertEquals("a bare string", after.get(2));
+ assertEquals(42, after.get(3));
+ assertEquals("john@doe.com", ((Document) after.get(7)).getString("rawId"),
+ "only the genuine EMAIL connection at the stored index is rewritten");
+ }
+
+ /**
+ * One document holding two EMAIL connections differing only in case — a state the old {@code bindEmail}
+ * could produce. Both converge onto one key; multikey index keys dedupe within a document, so this is
+ * not a duplicate-key error. Asserted against the real index rather than assumed.
+ */
+ @Test
+ public void normalizesTwoCaseVariantConnectionsInsideOneDocument() {
+ insert("u1", null,
+ connection(EMAIL, "John@Doe.com", null, "n"),
+ connection(EMAIL, "JOHN@DOE.COM", null, "n"));
+
+ run();
+ logDoc("two case-variant connections", "u1");
+
+ assertEquals("john@doe.com", conn("u1", 0).getString("rawId"));
+ assertEquals("john@doe.com", conn("u1", 1).getString("rawId"));
+ assertEquals(2, ((List>) reload("u1").get("connections")).size(),
+ "per-document dedupe means this is one group with one owner, not a conflict");
+ assertEquals(0, reportRows("u1"), "a document cannot conflict with itself");
+ }
+
+ // -------------------------------------------------------------- the race the survey cannot close
+
+ /**
+ * The survey is not atomic with the backfill, and on this codebase that window is not theoretical:
+ * {@code AddSuperAdminRunner} writes the super-admin from a {@code @PostConstruct} ending in a bare
+ * {@code subscribe()}, so it is unordered against Mongock on every boot, and other replicas keep
+ * serving registrations throughout.
+ *
+ * Simulated by surveying, then letting someone take the target key, then backfilling. The run must
+ * absorb the duplicate key and report it — if it threw, Mongock would record the changeset FAILED and
+ * the application would not start, which on a replicated deployment is a boot loop on exactly the
+ * installs that most need the migration.
+ */
+ @Test
+ public void toleratesACollisionThatAppearsAfterTheSurveyAndReportsIt() {
+ insert("u2", null, connection(EMAIL, "JoHn@DoE.COM", null, "n"));
+ insert("u3", null, connection(EMAIL, "Other@Example.COM", null, "n"));
+
+ EmailNormalizationBackfill.Survey survey = EmailNormalizationBackfill.survey(users);
+ assertTrue(survey.conflictingRawIds().isEmpty(), "nothing conflicts at survey time");
+
+ // Someone registers the canonical address in the window between the two passes.
+ insert("late", null, connection(EMAIL, "john@doe.com", null, "n"));
+
+ Summary summary = EmailNormalizationBackfill.backfill(users, report, survey,
+ "run-" + UUID.randomUUID(), java.time.Instant.now());
+ log.info("after a late collision: {}", summary);
+
+ assertEquals("JoHn@DoE.COM", conn("u2", 0).getString("rawId"),
+ "the losing document must be left exactly as it was");
+ assertEquals("other@example.com", conn("u3", 0).getString("rawId"),
+ "an unordered bulk write must still apply every operation that does not collide");
+ assertEquals(1, summary.collidedAtWrite());
+ assertEquals(1, summary.modified(), "the collided document must not be counted as normalized");
+ assertTrue(reportRows("u2") > 0, "the late collision must reach the report");
+ }
+
+ /**
+ * A document the compare-and-swap filter rejects is not normalized, so it must not be counted as
+ * normalized either. {@code modified} is incremented when the operation is queued, which is before
+ * anyone knows whether it will land; an operator reading the summary of a production identity
+ * migration has to be able to trust that number.
+ */
+ @Test
+ public void doesNotCountADocumentTheCompareAndSwapRejectedAsModified() {
+ insert("u1", null, connection(EMAIL, "JoHn@DoE.COM", null, "n"));
+ Document asRead = users.find(Filters.eq("_id", "u1")).first();
+ assertNotNull(asRead);
+ var operation = EmailNormalizationBackfill.buildBackfillOperation(asRead, false, false);
+ assertNotNull(operation, "this document does need a write");
+
+ // Someone edits the document between the read and the write.
+ users.updateOne(Filters.eq("_id", "u1"),
+ new Document("$set", new Document("connections.0.rawId", "Changed@Example.COM")));
+
+ EmailNormalizationBackfill.BulkOutcome outcome = EmailNormalizationBackfill.executeBulk(users,
+ List.of(new EmailNormalizationBackfill.Op("u1", "john@doe.com", operation)), "test");
+ log.info("stale write outcome: {}", outcome);
+
+ assertEquals(1, outcome.notConverged(), "the rejected operation must be reported as not converged");
+ assertTrue(outcome.collided().isEmpty(), "it is not a collision, just a stale read");
+ assertEquals("Changed@Example.COM", conn("u1", 0).getString("rawId"),
+ "and the concurrent edit must survive -- the migration must not clobber it");
+ }
+
+ // ------------------------------------------------------------------------------------- report
+
+ @Test
+ public void writesASummaryRow() {
+ insert("u1", null, connection(EMAIL, "John@Doe.com", null, "n"));
+ insert("u2", null, connection(EMAIL, "JOHN@DOE.COM", null, "n"));
+
+ run();
+
+ Document summary = report.find(Filters.eq("_id", "summary")).first();
+ assertNotNull(summary, "an operator needs one row that states the outcome");
+ log.info("summary row: {}", summary.toJson());
+ assertEquals(2L, summary.getLong("scanned"));
+ assertEquals(2L, summary.getLong("rawIdFrozen"));
+ assertTrue(summary.getLong("conflictGroups") >= 1);
+ }
+
+ @Test
+ public void reportIsIdempotentAcrossReruns() {
+ insert("u1", null, connection(EMAIL, "John@Doe.com", null, "n"));
+ insert("u2", null, connection(EMAIL, "JOHN@DOE.COM", null, "n"));
+
+ run();
+ long afterFirst = report.countDocuments();
+ run();
+
+ assertEquals(afterFirst, report.countDocuments(),
+ "a deterministic _id must make re-runs replace rows, not accumulate them");
+ }
+
+ @Test
+ public void reportDropsRowsForResolvedConflicts() {
+ insert("u1", null, connection(EMAIL, "John@Doe.com", null, "n"));
+ insert("u2", null, connection(EMAIL, "JOHN@DOE.COM", null, "n"));
+ run();
+ assertTrue(reportRows("u2") > 0);
+
+ // The operator resolves it.
+ users.deleteOne(Filters.eq("_id", "u2"));
+ run();
+
+ assertEquals(0, reportRows("u2"),
+ "a resolved conflict must not haunt the report collection forever");
+ assertEquals("john@doe.com", conn("u1", 0).getString("rawId"),
+ "and the survivor converges on the next run");
+ }
+
+ // ------------------------------------------------------------------------- indexes and guards
+
+ @Test
+ public void ensureLookupIndexesBuildsBothAndIsRerunnable() {
+ run();
+ run();
+
+ List names = new ArrayList<>();
+ users.listIndexes().forEach(index -> names.add(index.getString("name")));
+ log.info("indexes: {}", names);
+ assertTrue(names.contains("email_1"), "findByEmailDeep's email branch must be indexed");
+ assertTrue(names.contains("connections.email_1"),
+ "and its connections.email branch too -- an $or degrades to a collection scan unless "
+ + "every branch has an index");
+ }
+
+ /**
+ * {@code @ActiveProfiles} replaces the active profiles rather than adding to them, so the eight test
+ * classes that name their own profile do not have {@code test} active, {@code @Profile("!test")}
+ * matches, and Mongock really does run this changeset at their context startup — against a database
+ * their fixtures have not populated yet.
+ */
+ @Test
+ public void runIsANoOpWhenTheUserCollectionIsAbsent() {
+ Summary summary = EmailNormalizationBackfill.run(db, "absent_" + UUID.randomUUID(), reportName);
+ log.info("absent-collection run: {}", summary);
+ assertEquals(0, summary.scanned());
+ assertEquals(0, summary.modified());
+ }
+
+ @Test
+ public void runIsANoOpOnAnEmptyCollection() {
+ Summary summary = run();
+ assertEquals(0, summary.scanned());
+ assertEquals(0, summary.modified());
+ }
+}