-
Notifications
You must be signed in to change notification settings - Fork 4
[PB-5590] test(user): add E2E tests for user registration flow #840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
douglas-xt
wants to merge
3
commits into
master
Choose a base branch
from
test/e2e-user-registration
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+423
−1
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
| } | ||
|
|
||
| export function generateHashedPassword(): string { | ||
| return '$2a$08$' + v4().replace(/-/g, '').substring(0, 53); | ||
| } | ||
|
|
||
| export function generateSalt(): string { | ||
| return v4().replace(/-/g, '').substring(0, 32); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.