Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions migration/1781015951873-NormalizeUserDataMailLowercase.js
Original file line number Diff line number Diff line change
@@ -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"`);
}
};
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string> };
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'];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -196,7 +196,7 @@ export class UserDataService {
): Promise<UserData[]> {
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,
Expand Down