diff --git a/src/modules/user/user.e2e-spec.ts b/src/modules/user/user.e2e-spec.ts new file mode 100644 index 000000000..77ef5f42b --- /dev/null +++ b/src/modules/user/user.e2e-spec.ts @@ -0,0 +1,250 @@ +import { HttpStatus } from '@nestjs/common'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { getModelToken } from '@nestjs/sequelize'; +import request from 'supertest'; + +import { createTestApp } from '../../../test/helpers/test-app.helper'; +import { + generateValidRegistrationData, + RegisterUserDto, +} from '../../../test/helpers/register.helper'; +import { UserModel } from './user.model'; +import { FolderModel } from '../folder/folder.model'; +import { FileModel } from '../file/file.model'; +import { KeyServerModel } from '../keyserver/key-server.model'; +import { CryptoService } from '../../externals/crypto/crypto.service'; + +describe('User Registration E2E', () => { + let app: NestExpressApplication; + let userModel: typeof UserModel; + let folderModel: typeof FolderModel; + let fileModel: typeof FileModel; + let keyServerModel: typeof KeyServerModel; + let cryptoService: CryptoService; + let createdUserIds: number[] = []; + + const encryptRegistrationData = (data: RegisterUserDto): RegisterUserDto => { + return { + ...data, + password: cryptoService.encryptText(data.password), + salt: cryptoService.encryptText(data.salt), + }; + }; + + beforeAll(async () => { + app = await createTestApp(); + userModel = app.get(getModelToken(UserModel)); + folderModel = app.get(getModelToken(FolderModel)); + fileModel = app.get(getModelToken(FileModel)); + keyServerModel = app.get(getModelToken(KeyServerModel)); + cryptoService = app.get(CryptoService); + }); + + afterEach(async () => { + for (const userId of createdUserIds) { + await userModel.update({ rootFolderId: null }, { where: { id: userId } }); + await keyServerModel.destroy({ where: { userId } }); + await fileModel.destroy({ where: { userId } }); + await folderModel.destroy({ where: { userId } }); + await userModel.destroy({ where: { id: userId } }); + } + createdUserIds = []; + }); + + afterAll(async () => { + await app.close(); + }); + + const registerUser = (data: RegisterUserDto) => { + return request(app.getHttpServer()).post('/users').send(data); + }; + + describe('POST /users - User Registration', () => { + describe('Users can register successfully with valid data', () => { + it('When valid registration data is provided, then user is created successfully', async () => { + const registrationData = await generateValidRegistrationData(); + const encryptedData = encryptRegistrationData(registrationData); + + const response = await registerUser(encryptedData).expect( + HttpStatus.CREATED, + ); + + expect(response.body).toHaveProperty('user'); + expect(response.body).toHaveProperty('token'); + expect(response.body).toHaveProperty('uuid'); + expect(response.body.user.email).toBe(registrationData.email); + expect(response.body.user.name).toBe(registrationData.name); + expect(response.body.user.lastname).toBe(registrationData.lastname); + + const createdUser = await userModel.findOne({ + where: { email: registrationData.email }, + }); + expect(createdUser).not.toBeNull(); + createdUserIds.push(createdUser.id); + }); + + it('When registration is successful, then tokens are returned', async () => { + const registrationData = await generateValidRegistrationData(); + const encryptedData = encryptRegistrationData(registrationData); + + const response = await registerUser(encryptedData).expect( + HttpStatus.CREATED, + ); + + expect(response.body.token).toBeDefined(); + expect(response.body.newToken).toBeDefined(); + expect(typeof response.body.token).toBe('string'); + expect(typeof response.body.newToken).toBe('string'); + + const createdUser = await userModel.findOne({ + where: { email: registrationData.email }, + }); + createdUserIds.push(createdUser.id); + }); + }); + + describe('The system properly rejects invalid registration attempts', () => { + it('When email is missing, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData(); + delete (registrationData as any).email; + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + + it('When name is missing, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData(); + delete (registrationData as any).name; + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + + it('When password is missing, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData(); + delete (registrationData as any).password; + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + + it('When mnemonic is missing, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData(); + delete (registrationData as any).mnemonic; + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + + it('When email is invalid format, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData({ + email: 'invalid-email', + }); + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + + it('When email is already registered, then registration fails with 409', async () => { + const registrationData = await generateValidRegistrationData(); + const encryptedData = encryptRegistrationData(registrationData); + + await registerUser(encryptedData).expect(HttpStatus.CREATED); + + const createdUser = await userModel.findOne({ + where: { email: registrationData.email }, + }); + createdUserIds.push(createdUser.id); + + const duplicateData = await generateValidRegistrationData({ + email: registrationData.email, + }); + const encryptedDuplicate = encryptRegistrationData(duplicateData); + + await registerUser(encryptedDuplicate).expect(HttpStatus.CONFLICT); + }); + + it('When name exceeds 100 characters, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData({ + name: 'a'.repeat(101), + }); + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + + it('When lastname exceeds 100 characters, then registration fails with 400', async () => { + const registrationData = await generateValidRegistrationData({ + lastname: 'a'.repeat(101), + }); + + await registerUser(registrationData).expect(HttpStatus.BAD_REQUEST); + }); + }); + + describe('All user resources are created correctly after registration', () => { + it('When registration is successful, then root folder is created', async () => { + const registrationData = await generateValidRegistrationData(); + const encryptedData = encryptRegistrationData(registrationData); + + const response = await registerUser(encryptedData).expect( + HttpStatus.CREATED, + ); + + expect(response.body.user.root_folder_id).toBeDefined(); + + const createdUser = await userModel.findOne({ + where: { email: registrationData.email }, + }); + createdUserIds.push(createdUser.id); + + const rootFolder = await folderModel.findOne({ + where: { userId: createdUser.id, parentId: null }, + }); + expect(rootFolder).not.toBeNull(); + // Note: plainName is not set by the registration endpoint, only name (encrypted) + expect(rootFolder.name).toBeDefined(); + }); + + it('When registration includes ECC keys, then keys are stored', async () => { + const registrationData = await generateValidRegistrationData(); + const encryptedData = encryptRegistrationData(registrationData); + + const response = await registerUser(encryptedData).expect( + HttpStatus.CREATED, + ); + + expect(response.body.user.keys).toBeDefined(); + expect(response.body.user.keys.ecc).toBeDefined(); + expect(response.body.user.keys.ecc.publicKey).toBeDefined(); + expect(response.body.user.keys.ecc.privateKey).toBeDefined(); + + const createdUser = await userModel.findOne({ + where: { email: registrationData.email }, + }); + createdUserIds.push(createdUser.id); + + const storedKeys = await keyServerModel.findOne({ + where: { userId: createdUser.id }, + }); + expect(storedKeys).not.toBeNull(); + expect(storedKeys.publicKey).toBeDefined(); + expect(storedKeys.privateKey).toBeDefined(); + }); + + it('When registration is successful, then user UUID is generated', async () => { + const registrationData = await generateValidRegistrationData(); + const encryptedData = encryptRegistrationData(registrationData); + + const response = await registerUser(encryptedData).expect( + HttpStatus.CREATED, + ); + + expect(response.body.uuid).toBeDefined(); + expect(response.body.uuid).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + + const createdUser = await userModel.findOne({ + where: { email: registrationData.email }, + }); + createdUserIds.push(createdUser.id); + expect(createdUser.uuid).toBe(response.body.uuid); + }); + }); + }); +}); diff --git a/test/helpers/register.helper.ts b/test/helpers/register.helper.ts new file mode 100644 index 000000000..92328db45 --- /dev/null +++ b/test/helpers/register.helper.ts @@ -0,0 +1,114 @@ +import { v4 } from 'uuid'; +import { generateMnemonic } from 'bip39'; +import { generateNewKeys } from '../../src/externals/asymmetric-encryption/openpgp'; +import { importEsmPackage } from '../../src/lib/import-esm-package'; + +export interface RegisterUserDto { + name: string; + lastname: string; + email: string; + password: string; + mnemonic: string; + salt: string; + keys?: { + ecc?: { + publicKey: string; + privateKey: string; + revocationKey?: string; + }; + kyber?: { + publicKey: string; + privateKey: string; + }; + }; + referrer?: string; +} + +interface EccKeys { + publicKey: string; + privateKey: string; + revocationKey: string; +} + +interface KyberKeys { + publicKey: string; + privateKey: string; +} + +interface Keys { + ecc: EccKeys; + kyber: KyberKeys; +} + +let cachedKeys: Keys | null = null; + +async function generateEccKeys(): Promise { + const keys = await generateNewKeys(); + return { + publicKey: keys.publicKeyArmored, + privateKey: keys.privateKeyArmored, + revocationKey: keys.revocationCertificate, + }; +} + +async function generateKyberKeys(): Promise { + const kemBuilder = await importEsmPackage< + typeof import('@dashlane/pqc-kem-kyber512-node').default + >('@dashlane/pqc-kem-kyber512-node'); + const kem = await kemBuilder(); + const keys = await kem.keypair(); + return { + publicKey: Buffer.from(keys.publicKey).toString('base64'), + privateKey: Buffer.from(keys.privateKey).toString('base64'), + }; +} + +export async function initializeTestKeys(): Promise { + if (cachedKeys) { + return cachedKeys; + } + + const [eccKeys, kyberKeys] = await Promise.all([ + generateEccKeys(), + generateKyberKeys(), + ]); + + cachedKeys = { + ecc: eccKeys, + kyber: kyberKeys, + }; + + return cachedKeys; +} + +export async function generateValidRegistrationData( + overrides: Partial = {}, +): Promise { + const timestamp = Date.now(); + const randomSuffix = v4().substring(0, 8); + const uniqueId = `${timestamp}-${randomSuffix}`; + + const keys = await initializeTestKeys(); + + return { + name: 'Test', + lastname: 'User', + email: overrides.email || `test-${uniqueId}@test.com`, + password: generateHashedPassword(), + mnemonic: generateMnemonic(256), + salt: generateSalt(), + keys: { + ecc: keys.ecc, + kyber: keys.kyber, + }, + ...overrides, + }; +} + +export function generateHashedPassword(): string { + return '$2a$08$' + v4().replace(/-/g, '').substring(0, 53); +} + +export function generateSalt(): string { + return v4().replace(/-/g, '').substring(0, 32); +} diff --git a/test/helpers/test-app.helper.ts b/test/helpers/test-app.helper.ts index d31e75a6e..1fe30f54b 100644 --- a/test/helpers/test-app.helper.ts +++ b/test/helpers/test-app.helper.ts @@ -2,9 +2,56 @@ import { Test, TestingModule } from '@nestjs/testing'; import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; +import { v4 } from 'uuid'; import { AppModule } from '../../src/app.module'; import { TransformInterceptor } from '../../src/lib/transform.interceptor'; import { AuthGuard } from '../../src/modules/auth/auth.guard'; +import { ThrottlerGuard } from '../../src/guards/throttler.guard'; +import { BridgeService } from '../../src/externals/bridge/bridge.service'; +import { MailerService } from '../../src/externals/mailer/mailer.service'; +import { NewsletterService } from '../../src/externals/newsletter'; +import { NotificationListener } from '../../src/externals/notifications/listeners/notification.listener'; + +class MockThrottlerGuard { + canActivate() { + return true; + } +} + +class MockBridgeService { + async createUser() { + return { + userId: `$2a$08$${v4().replace(/-/g, '').substring(0, 53)}`, + uuid: v4(), + }; + } + + async createBucket() { + return { id: v4().substring(0, 24), name: 'test-bucket' }; + } + + async deleteFile() { + return; + } +} + +class MockMailerService { + async send() { + return; + } +} + +class MockNewsletterService { + async subscribe() { + return; + } +} + +class MockNotificationListener { + async handleNotificationEvent() { + return; + } +} export interface CreateTestAppOptions { enableAuthGuard?: boolean; @@ -24,7 +71,18 @@ export async function createTestApp( } = options; const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideGuard(ThrottlerGuard) + .useClass(MockThrottlerGuard) + .overrideProvider(BridgeService) + .useClass(MockBridgeService) + .overrideProvider(MailerService) + .useClass(MockMailerService) + .overrideProvider(NewsletterService) + .useClass(MockNewsletterService) + .overrideProvider(NotificationListener) + .useClass(MockNotificationListener) + .compile(); const app = moduleFixture.createNestApplication(); if (enableAuthGuard) { const reflector = app.get(Reflector);