Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
250 changes: 250 additions & 0 deletions src/modules/user/user.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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 = 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);
});
});
});
});
60 changes: 60 additions & 0 deletions test/helpers/register.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { v4 } from 'uuid';
import { generateMnemonic } from 'bip39';

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;
}

export function generateValidRegistrationData(
overrides: Partial<RegisterUserDto> = {},
): RegisterUserDto {
const timestamp = Date.now();
const randomSuffix = v4().substring(0, 8);
const uniqueId = `${timestamp}-${randomSuffix}`;

return {
name: 'Test',
lastname: 'User',
email: overrides.email || `test-${uniqueId}@test.com`,
password: generateHashedPassword(),
mnemonic: generateMnemonic(256),
salt: generateSalt(),
keys: {
ecc: {
publicKey: `ecc-public-key-test-${uniqueId}`,
privateKey: `ecc-private-key-test-${uniqueId}`,
revocationKey: `ecc-revocation-key-test-${uniqueId}`,
},
kyber: {
publicKey: `kyber-public-key-test-${uniqueId}`,
privateKey: `kyber-private-key-test-${uniqueId}`,
},
},
...overrides,
};
Comment on lines 100 to 105
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generate one key using the libraries if possible. Just to prevent any issue if we add a validation in the DTOs for the keys.

Just if possible, of course. I think we have the libraries to create keys already in the repository check the preCreated code to confirm. However, I feel we can merge this like this, you would need to create a tech debt ticket to tackle it later

Up to you @jzunigax2

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I decided to implement it now using the existing libraries (openpgp for ECC and @dashlane/pqc-kem-kyber512-node for Kyber) to avoid creating tech debt and get it right from the start.

}

export function generateHashedPassword(): string {
return '$2a$08$' + v4().replace(/-/g, '').substring(0, 53);
}

export function generateSalt(): string {
return v4().replace(/-/g, '').substring(0, 32);
}
60 changes: 59 additions & 1 deletion test/helpers/test-app.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<NestExpressApplication>();
if (enableAuthGuard) {
const reflector = app.get(Reflector);
Expand Down
Loading