diff --git a/migration/1781015951873-NormalizeUserDataMailLowercase.js b/migration/1781015951873-NormalizeUserDataMailLowercase.js new file mode 100644 index 0000000000..91cfd25d7f --- /dev/null +++ b/migration/1781015951873-NormalizeUserDataMailLowercase.js @@ -0,0 +1,38 @@ +// Normalize all stored e-mail addresses to lowercase and add a functional index on LOWER(mail). +// +// Background: input lowercase-normalization (Util.toLowerCaseTrim) only arrived with PR #2695 +// (2025-12-23), so accounts created earlier hold mixed-case mails. After the MSSQL->PostgreSQL +// cutover (PR #3620, 2026-05-22) the duplicate-detection lookup `getUsersByMail` (exact `mail = ?`) +// became case-sensitive and stopped matching those mixed-case rows, allowing duplicate accounts +// for the same address (e.g. `Samuel.kullmann@...` vs `samuel.kullmann@...`). +// +// This migration lowercases the legacy data so it is consistent with the now case-insensitive +// lookup. The index is intentionally NON-unique here: case-collision duplicates still exist and +// must be resolved via the merge campaign before the UNIQUE variant can be created (separate PR). +// +// Note: lowercasing is lossy (original casing is not recoverable), so `down` is a no-op for the +// data updates and only drops the index. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +module.exports = class NormalizeUserDataMailLowercase1781015951873 { + name = 'NormalizeUserDataMailLowercase1781015951873'; + + async up(queryRunner) { + await queryRunner.query( + `UPDATE "user_data" SET "mail" = LOWER("mail") WHERE "mail" IS NOT NULL AND "mail" <> LOWER("mail")`, + ); + await queryRunner.query( + `UPDATE "recommendation" SET "recommendedMail" = LOWER("recommendedMail") WHERE "recommendedMail" IS NOT NULL AND "recommendedMail" <> LOWER("recommendedMail")`, + ); + await queryRunner.query(`CREATE INDEX "IDX_user_data_mail_lower" ON "user_data" (LOWER("mail"))`); + } + + async down(queryRunner) { + // Data lowercasing is irreversible; only the index is dropped. + await queryRunner.query(`DROP INDEX "public"."IDX_user_data_mail_lower"`); + } +}; diff --git a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts index ad7edb0767..1618d3b153 100644 --- a/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts +++ b/src/subdomains/generic/user/models/user-data/__tests__/user-data.service.spec.ts @@ -1,5 +1,7 @@ import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; +import { ConflictException } from '@nestjs/common'; +import { FindOperator } from 'typeorm'; import { RepositoryFactory } from 'src/shared/repositories/repository.factory'; import { CountryService } from 'src/shared/models/country/country.service'; import { FiatService } from 'src/shared/models/fiat/fiat.service'; @@ -65,6 +67,44 @@ describe('UserDataService', () => { service = module.get(UserDataService); }); + describe('getUsersByMail', () => { + it('matches case-insensitively via LOWER(mail) and passes a lowercased parameter', async () => { + userDataRepo.find.mockResolvedValue([]); + + await service.getUsersByMail('Samuel.Kullmann@Startmail.com'); + + const where = userDataRepo.find.mock.calls[0][0].where as { mail: FindOperator }; + expect(where.mail).toBeInstanceOf(FindOperator); + expect(where.mail.type).toBe('raw'); + // `getSql` is a getter returning the SQL generator; invoke it with the column alias + expect(where.mail.getSql?.('"UserData"."mail"')).toBe('LOWER("UserData"."mail") = :mail'); + expect(where.mail.objectLiteralParameters).toEqual({ mail: 'samuel.kullmann@startmail.com' }); + }); + + it('omits the status filter when onlyValidUser is false', async () => { + userDataRepo.find.mockResolvedValue([]); + + await service.getUsersByMail('a@b.com', false); + + const where = userDataRepo.find.mock.calls[0][0].where as { status?: unknown }; + expect(where.status).toBeUndefined(); + }); + }); + + describe('checkMail', () => { + it('throws a conflict when another account already uses the same mail (case-insensitive)', async () => { + const userData = Object.assign(new UserData(), { id: 1, kycType: 'DFX' }); + const conflictUser = Object.assign(new UserData(), { id: 2, kycType: 'DFX' }); + + // getUsersByMail() resolves to the case-variant conflicting account + userDataRepo.find.mockResolvedValue([conflictUser]); + + await expect(service.checkMail(userData, 'samuel.kullmann@startmail.com')).rejects.toBeInstanceOf( + ConflictException, + ); + }); + }); + describe('updateUserData', () => { it('does not pass kycSteps or users to save() to prevent stale-collection FK clobber', async () => { const fakeKycSteps = [{ id: 10 }] as UserData['kycSteps']; diff --git a/src/subdomains/generic/user/models/user-data/user-data.service.ts b/src/subdomains/generic/user/models/user-data/user-data.service.ts index 8035b831ee..8b6d2c3716 100644 --- a/src/subdomains/generic/user/models/user-data/user-data.service.ts +++ b/src/subdomains/generic/user/models/user-data/user-data.service.ts @@ -47,7 +47,7 @@ import { TfaLevel, TfaService } from 'src/subdomains/generic/kyc/services/tfa.se import { MailContext } from 'src/subdomains/supporting/notification/enums'; import { SpecialExternalAccountService } from 'src/subdomains/supporting/payment/services/special-external-account.service'; import { TransactionService } from 'src/subdomains/supporting/payment/services/transaction.service'; -import { Equal, FindOptionsRelations, In, IsNull, MoreThan, Not } from 'typeorm'; +import { Equal, FindOptionsRelations, In, IsNull, MoreThan, Not, Raw } from 'typeorm'; import { WebhookService } from '../../services/webhook/webhook.service'; import { MergeReason } from '../account-merge/account-merge.entity'; import { AccountMergeService } from '../account-merge/account-merge.service'; @@ -196,7 +196,7 @@ export class UserDataService { ): Promise { return this.userDataRepo.find({ where: { - mail, + mail: Raw((alias) => `LOWER(${alias}) = :mail`, { mail: mail?.toLowerCase() }), status: onlyValidUser ? In([UserDataStatus.ACTIVE, UserDataStatus.NA, UserDataStatus.KYC_ONLY, UserDataStatus.DEACTIVATED]) : undefined,