Skip to content
Merged
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
24 changes: 24 additions & 0 deletions backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,30 @@ describe('AuthService', () => {
passwordService = module.get<PasswordService>(PasswordService);
});

describe('email normalisation', () => {
it('registers with a lower-cased, trimmed email via UsersService', async () => {
const { RegisterDto } = await import('./dto/register.dto');
const { plainToInstance } = await import('class-transformer');
const dto = plainToInstance(RegisterDto, {
firstName: 'J',
lastName: 'D',
email: ' Jane@Corp.COM ',
password: 'Password123!',
});
expect(dto.email).toBe('jane@corp.com');
});

it('normalises the login email before validation', async () => {
const { LoginDto } = await import('./dto/login.dto');
const { plainToInstance } = await import('class-transformer');
const dto = plainToInstance(LoginDto, {
email: 'JANE@corp.com',
password: 'x',
});
expect(dto.email).toBe('jane@corp.com');
});
});

describe('register', () => {
it('should throw ConflictException when email already exists', async () => {
jest
Expand Down
3 changes: 3 additions & 0 deletions backend/src/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MaxLength } from 'class-validator';
import { normalizeEmailTransform } from '../../users/utils/normalize-email';

export class LoginDto {
@ApiProperty({ example: 'user@example.com' })
@Transform(normalizeEmailTransform)
@IsEmail()
@IsNotEmpty()
email: string;
Expand Down
3 changes: 3 additions & 0 deletions backend/src/auth/dto/register.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsMaxBytes } from '../../common/validators/is-max-bytes.validator';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { normalizeEmailTransform } from '../../users/utils/normalize-email';

export class RegisterDto {
@ApiProperty({ example: 'John' })
Expand All @@ -14,6 +16,7 @@ export class RegisterDto {
lastName: string;

@ApiProperty({ example: 'user@example.com' })
@Transform(normalizeEmailTransform)
@IsEmail()
@IsNotEmpty()
email: string;
Expand Down
40 changes: 40 additions & 0 deletions backend/src/migrations/1760000000000-NormaliseUserEmails.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Makes user emails case-insensitively unique (#1572).
*
* Existing rows are trimmed and lower-cased. If two accounts differ only by
* case the migration stops and lists them: merging accounts is a data decision
* that must not be made silently.
*/
export class NormaliseUserEmails1760000000000 implements MigrationInterface {
name = 'NormaliseUserEmails1760000000000';

public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasTable('users'))) return;

const clashes: { email: string; count: string }[] = await queryRunner.query(
`SELECT lower(btrim(email)) AS email, count(*) AS count
FROM users GROUP BY lower(btrim(email)) HAVING count(*) > 1`,
);
if (clashes.length) {
throw new Error(
`Cannot normalise user emails; resolve these duplicates first: ${clashes
.map((c) => `${c.email} (x${c.count})`)
.join(', ')}`,
);
}

await queryRunner.query(
`UPDATE users SET email = lower(btrim(email)) WHERE email <> lower(btrim(email))`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_users_email_lower" ON users (lower(email))`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
// Lower-casing is not reversible; only the new index is dropped.
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_users_email_lower"`);
}
}
29 changes: 29 additions & 0 deletions backend/src/users/users.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { UnauthorizedException } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController.getProfile', () => {
const user = { id: 'u-1', email: 'a@b.com' };
const service = {
findById: jest.fn().mockResolvedValue(user),
sanitize: jest.fn((u) => u),
} as unknown as UsersService;
const controller = new UsersController(service);

beforeEach(() => jest.clearAllMocks());

it('returns the sanitised profile for the authenticated user', async () => {
await expect(controller.getProfile('u-1')).resolves.toEqual(user);
expect(service.findById).toHaveBeenCalledWith('u-1');
});

it.each([undefined, ''])(
'throws 401 instead of inventing an identity when the user id is %p',
async (id) => {
await expect(controller.getProfile(id)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(service.findById).not.toHaveBeenCalled();
},
);
});
13 changes: 4 additions & 9 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Query,
Req,
UseGuards,
UnauthorizedException,
} from '@nestjs/common';
import {
ApiTags,
Expand All @@ -18,6 +19,7 @@ import { ApiEnvelope, ApiErrorEnvelope } from '../common/swagger/api-envelope.de
import { UsersService } from './users.service';
import { User, UserRole } from './entities/user.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { GetUser } from '../auth/decorators/get-user.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UpdateProfileDto } from './dto/update-profile.dto';
Expand Down Expand Up @@ -46,16 +48,9 @@ export class UsersController {
@ApiOperation({ summary: 'Get current user profile' })
@ApiEnvelope(User, { description: 'Current user profile' })
@ApiErrorEnvelope(401, 'Missing or invalid token')
async getProfile(@Req() req: any) {
const userId = req.user?.id || req.user?.sub;
async getProfile(@GetUser('id') userId?: string) {
if (!userId) {
return {
id: 'demo-user-id',
email: 'user@example.com',
role: UserRole.ADMIN,
firstName: 'Demo',
lastName: 'User',
};
throw new UnauthorizedException('Authentication required');
}
const user = await this.usersService.findById(userId);
return this.usersService.sanitize(user);
Expand Down
7 changes: 5 additions & 2 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { PasswordService } from '../common/security/password.service';
import { User, UserRole } from './entities/user.entity';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { AuditLogsService } from '../audit-logs/audit-logs.service';
import { normalizeEmail } from './utils/normalize-email';

@Injectable()
export class UsersService {
Expand Down Expand Up @@ -66,7 +67,9 @@ export class UsersService {
}

async findByEmail(email: string): Promise<User | null> {
return this.userRepository.findOne({ where: { email } });
return this.userRepository.findOne({
where: { email: normalizeEmail(email) },
});
}

async create(dto: {
Expand All @@ -81,7 +84,7 @@ export class UsersService {

const passwordHash = await this.passwords.hash(dto.password);
const user = this.userRepository.create({
email: dto.email,
email: normalizeEmail(dto.email),
passwordHash,
firstName: dto.firstName,
lastName: dto.lastName,
Expand Down
12 changes: 12 additions & 0 deletions backend/src/users/utils/normalize-email.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { normalizeEmail, normalizeEmailTransform } from './normalize-email';

describe('normalizeEmail', () => {
it('trims and lower-cases', () => {
expect(normalizeEmail(' Jane@Corp.COM ')).toBe('jane@corp.com');
});

it('leaves non-strings to the validator', () => {
expect(normalizeEmailTransform({ value: 42 })).toBe(42);
expect(normalizeEmailTransform({ value: undefined })).toBeUndefined();
});
});
12 changes: 12 additions & 0 deletions backend/src/users/utils/normalize-email.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Canonical form of an email address: trimmed and lower-cased. */
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}

/**
* `class-transformer` helper for DTO fields. Non-strings pass through untouched
* so `@IsEmail()` still reports them.
*/
export function normalizeEmailTransform({ value }: { value: unknown }): unknown {
return typeof value === 'string' ? normalizeEmail(value) : value;
}
Loading