diff --git a/.eslintrc.js b/.eslintrc.js index 6a416bb4..97e9c75f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -175,6 +175,7 @@ module.exports = { 'testHelpers.tsx', '*.test-module.ts', 'test-utils.ts', + 'rest-api-test-utils.ts', '*Handlers.ts', '**/mocks/*', 'jest-setup.ts', diff --git a/packages/api/rest-api-docs.yaml b/packages/api/rest-api-docs.yaml index e671fbf1..6ffa5daa 100644 --- a/packages/api/rest-api-docs.yaml +++ b/packages/api/rest-api-docs.yaml @@ -87,6 +87,25 @@ paths: description: 'Email confirmation was successfully requested. Email was sent.' tags: - Email Confirmation + /email-confirmation/approval: + post: + summary: Confirm email for given user. + operationId: ConfirmUserRegistrationPost + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApproveEmailConfirmationBody' + responses: + 204: + description: 'Mail for user successfully confirmed' + 400: + $ref: '#/components/responses/BadRequest' + 401: + $ref: '#/components/responses/Unauthorized' + tags: + - Email Confirmation /users: get: summary: All users registered in the platform @@ -138,7 +157,7 @@ paths: get: summary: How many course activites was completed by user. operationId: CourseProgress_getCourseProgress - parameters: [ ] + parameters: [] responses: 200: description: '' @@ -259,6 +278,15 @@ components: example: user-registration required: - confirmationFor + ApproveEmailConfirmationBody: + type: object + properties: + confirmationToken: + description: Given confirmation token created meanwhile registration + type: string + example: someExample.Token + required: + - confirmationFor CourseProgressGetResponseBody: description: Current course progress of logged user type: object @@ -398,7 +426,7 @@ components: message: type: string description: Error message - example: "Learning materials url was already generated" + example: 'Learning materials url was already generated' required: - message NestErrorResponseBody: diff --git a/packages/api/src/module/write/email-confirmation/domain/approve-email-confirmation.ts b/packages/api/src/module/write/email-confirmation/domain/approve-email-confirmation.ts index dfae1fc3..55323de5 100644 --- a/packages/api/src/module/write/email-confirmation/domain/approve-email-confirmation.ts +++ b/packages/api/src/module/write/email-confirmation/domain/approve-email-confirmation.ts @@ -1,5 +1,6 @@ import { ApproveEmailConfirmation } from '@/module/commands/approve-email-confirmation'; import { emailConfirmationWasApprovedEvent } from '@/module/events/email-confirmation-was-approved.domain.event'; +import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception'; import { EmailConfirmationDomainEvent } from './events'; @@ -8,13 +9,14 @@ export const approveEmailConfirmation = (pastEvents: EmailConfirmationDomainEvent[]): EmailConfirmationDomainEvent[] => { const lastPublishedEmailConfirmation = pastEvents[pastEvents.length - 1]; - if (!lastPublishedEmailConfirmation) throw new Error("Couldn't find request which could be approved"); + if (!lastPublishedEmailConfirmation) + throw new DomainRuleViolationException("Couldn't find request which could be approved"); if (lastPublishedEmailConfirmation.type === 'EmailConfirmationWasApproved') - throw new Error('Email confirmation has been already approved'); + throw new DomainRuleViolationException('Email confirmation has been already approved'); if (lastPublishedEmailConfirmation.data.confirmationToken !== command.data.confirmationToken) - throw new Error('An attempt was made on obsolete confirmation token'); + throw new DomainRuleViolationException('An attempt was made on obsolete confirmation token'); return [emailConfirmationWasApprovedEvent(command.data)]; }; diff --git a/packages/api/src/module/write/email-confirmation/presentation/rest/email-confirmation.rest-controller.spec.ts b/packages/api/src/module/write/email-confirmation/presentation/rest/email-confirmation.rest-controller.spec.ts new file mode 100644 index 00000000..b3259478 --- /dev/null +++ b/packages/api/src/module/write/email-confirmation/presentation/rest/email-confirmation.rest-controller.spec.ts @@ -0,0 +1,77 @@ +import { HttpStatus } from '@nestjs/common'; +import { AsyncReturnType } from 'type-fest'; + +import { APPROVE_ENDPOINT } from '@coderscamp/shared/models/email-confirmation/approve-email-confirmation'; + +import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception'; +import { initTestModuleRestApi } from '@/shared/rest-api-test-utils'; + +import { initOpenApiExpect } from '../../../../../../jest-setup'; +import { EmailConfirmationRestController } from './email-confirmation.rest-controller'; + +initOpenApiExpect(); + +describe('email confirmation | REST API', () => { + let restUnderTest: AsyncReturnType; + + beforeAll(async () => { + restUnderTest = await initTestModuleRestApi(EmailConfirmationRestController); + restUnderTest.commandBusExecute.mockClear(); + }); + + afterAll(async () => { + await restUnderTest.close(); + }); + + describe(`POST /email-confirmation${APPROVE_ENDPOINT}`, () => { + it('Failed, user is not authenticated', async () => { + // Given + restUnderTest.commandBusExecute.mockImplementation(() => Promise.resolve()); + + // When + const response = await restUnderTest.http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({ + confirmationToken: 'exampleToken', + }); + + // Then + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + expect(response.body.message).toEqual('Unauthorized'); + expect(response).toSatisfyApiSpec(); + }); + + it('Failed, trying to approve email confirmation without earlier request', async () => { + // Given + restUnderTest.commandBusExecute.mockRejectedValue( + new DomainRuleViolationException("Couldn't find request which could be approved"), + ); + + // When + const response = await restUnderTest.asLoggedUser((http) => + http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({ + confirmationToken: 'exampleToken', + }), + ); + + // Then + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + expect(response.body.message).toBe("Couldn't find request which could be approved"); + expect(response).toSatisfyApiSpec(); + }); + + it('Success, email confirmation has been approved', async () => { + // Given + restUnderTest.commandBusExecute.mockImplementation(() => Promise.resolve()); + + // When + const response = await restUnderTest.asLoggedUser((http) => + http.post(`/api/email-confirmation${APPROVE_ENDPOINT}`).send({ + confirmationToken: 'exampleToken', + }), + ); + + // Then + expect(response.status).toBe(HttpStatus.NO_CONTENT); + expect(response).toSatisfyApiSpec(); + }); + }); +}); diff --git a/packages/api/src/module/write/user-registration/domain/CompleteUserRegistration.ts b/packages/api/src/module/write/user-registration/domain/CompleteUserRegistration.ts index b139cb93..a5b466a2 100644 --- a/packages/api/src/module/write/user-registration/domain/CompleteUserRegistration.ts +++ b/packages/api/src/module/write/user-registration/domain/CompleteUserRegistration.ts @@ -1,5 +1,6 @@ import { CompleteUserRegistration } from '@/commands/complete-user-registration'; import { userRegistrationWasCompletedEvent } from '@/events/user-registration-was-completed.domain-event'; +import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception'; import { UserRegistrationDomainEvent } from '@/write/user-registration/domain/events'; export const completeUserRegistration = @@ -8,10 +9,13 @@ export const completeUserRegistration = const { data } = command; const lastUserRegistrationEvent = pastEvents[pastEvents.length - 1]; - if (!lastUserRegistrationEvent) throw new Error('Impossible to complete registration while it is not started'); + if (!lastUserRegistrationEvent) + throw new DomainRuleViolationException('Impossible to complete registration while it is not started'); if (lastUserRegistrationEvent.type === 'UserRegistrationWasCompleted') - throw new Error(`Registration for user ${lastUserRegistrationEvent.data.fullName} was already completed`); + throw new DomainRuleViolationException( + `Registration for user ${lastUserRegistrationEvent.data.fullName} was already completed`, + ); const { fullName, emailAddress, hashedPassword } = lastUserRegistrationEvent.data; const userRegistrationWasCompleted = userRegistrationWasCompletedEvent({ diff --git a/packages/api/src/module/write/user-registration/presentation/rest/user-registration.rest-controller.spec.ts b/packages/api/src/module/write/user-registration/presentation/rest/user-registration.rest-controller.spec.ts index 35345be0..420d6a16 100644 --- a/packages/api/src/module/write/user-registration/presentation/rest/user-registration.rest-controller.spec.ts +++ b/packages/api/src/module/write/user-registration/presentation/rest/user-registration.rest-controller.spec.ts @@ -4,7 +4,7 @@ import { AsyncReturnType } from 'type-fest'; import { registerError } from '@coderscamp/shared/models/auth/register'; import { DomainRuleViolationException } from '@/shared/errors/domain-rule-violation.exception'; -import { initTestModuleRestApi } from '@/shared/test-utils'; +import { initTestModuleRestApi } from '@/shared/rest-api-test-utils'; import { UserRegistrationRestController } from '@/write/user-registration/presentation/rest/user-registration.rest-controller'; import { initOpenApiExpect } from '../../../../../../jest-setup'; diff --git a/packages/api/src/shared/rest-api-test-utils.ts b/packages/api/src/shared/rest-api-test-utils.ts new file mode 100644 index 00000000..f9386769 --- /dev/null +++ b/packages/api/src/shared/rest-api-test-utils.ts @@ -0,0 +1,115 @@ +import { INestApplication } from '@nestjs/common'; +import { Type } from '@nestjs/common/interfaces/type.interface'; +import { CommandBus } from '@nestjs/cqrs'; +import { Test, TestingModuleBuilder } from '@nestjs/testing'; +import supertest from 'supertest'; +import { v4 as uuid } from 'uuid'; + +import { AuthUser } from '@coderscamp/shared/models/auth'; + +import { AuthModule } from '@/crud/auth/auth.module'; +import { PrismaService } from '@/prisma/prisma.service'; +import { cleanupDatabase } from '@/shared/test-utils'; +import { ApplicationCommandFactory } from '@/write/shared/application/application-command.factory'; +import { UuidGenerator } from '@/write/shared/infrastructure/id-generator/uuid-generator'; +import { hashPassword } from '@/write/shared/infrastructure/password-encoder/crypto-password-encoder'; +import { SystemTimeProvider } from '@/write/shared/infrastructure/time-provider/system-time-provider'; + +import { setupMiddlewares } from '../app.middlewares'; +import { eventEmitterRootModule } from '../event-emitter.root-module'; + +const DEFAULT_TEST_PASSWORD = 'stronk'; + +export async function initTestModuleRestApi( + controller: Type, + config?: (module: TestingModuleBuilder) => TestingModuleBuilder, +) { + const commandBusExecute = jest.fn(); + const moduleBuilder = await Test.createTestingModule({ + providers: [ + { + provide: CommandBus, + useValue: { execute: commandBusExecute, register: jest.fn() }, + }, + { + provide: ApplicationCommandFactory, + useValue: new ApplicationCommandFactory(new UuidGenerator(), new SystemTimeProvider()), + }, + ], + controllers: [controller], + imports: [eventEmitterRootModule, AuthModule], + }); + const moduleRef = await (config ? config(moduleBuilder) : moduleBuilder).compile(); + + const app: INestApplication = moduleRef.createNestApplication(); + + const prismaService = app.get(PrismaService); + + await cleanupDatabase(prismaService); + + setupMiddlewares(app); + + await app.init(); + + const http = supertest(app.getHttpServer()); + + const randomUser = () => { + const id = uuid(); + + return { + id, + email: `${id}@email.com`, + password: DEFAULT_TEST_PASSWORD, + }; + }; + + const registerUser = async (userToCreate: Partial = randomUser()) => { + const id = userToCreate.id ?? uuid(); + const authUser = { + id, + email: `${id}@email.com`, + password: DEFAULT_TEST_PASSWORD, + ...userToCreate, + }; + const hashedPassword = await hashPassword(authUser.password); + + return prismaService.authUser.create({ + data: { + ...authUser, + password: hashedPassword, + }, + }); + }; + + const loginUser = async (request: { email: string } = randomUser()) => { + await registerUser({ email: request.email, password: DEFAULT_TEST_PASSWORD }); + + const response = await http.post('/api/auth/login').send(request); + + if (response.status !== 204) { + throw new Error('Example user login failed'); + } + + return response.get('set-cookie'); + }; + + const logoutUser = async () => { + const response = await http.post('/api/auth/logout').send(); + + if (response.status !== 201) { + throw new Error('Logout user failed'); + } + }; + + const asLoggedUser = async (request: (http: supertest.SuperTest) => supertest.Test) => { + const cookie = await loginUser(); + + return request(http).set('Cookie', cookie); + }; + + async function close() { + await app.close(); + } + + return { http, close, commandBusExecute, loginUser, logoutUser, asLoggedUser }; +} diff --git a/packages/api/src/shared/test-utils.ts b/packages/api/src/shared/test-utils.ts index 2d583f70..d5ee93da 100644 --- a/packages/api/src/shared/test-utils.ts +++ b/packages/api/src/shared/test-utils.ts @@ -1,11 +1,9 @@ -import { INestApplication } from '@nestjs/common'; import { Abstract } from '@nestjs/common/interfaces'; import { ModuleMetadata } from '@nestjs/common/interfaces/modules/module-metadata.interface'; import { Type } from '@nestjs/common/interfaces/type.interface'; import { CommandBus, ICommand } from '@nestjs/cqrs'; import { Test, TestingModule, TestingModuleBuilder } from '@nestjs/testing'; import _ from 'lodash'; -import supertest from 'supertest'; import { v4 as uuid } from 'uuid'; import waitForExpect from 'wait-for-expect'; @@ -21,12 +19,9 @@ import { EventStreamName } from '@/write/shared/application/event-stream-name.va import { SubscriptionId } from '@/write/shared/application/events-subscription/events-subscription'; import { ID_GENERATOR, IdGenerator } from '@/write/shared/application/id-generator'; import { TIME_PROVIDER } from '@/write/shared/application/time-provider.port'; -import { UuidGenerator } from '@/write/shared/infrastructure/id-generator/uuid-generator'; import { FixedTimeProvider } from '@/write/shared/infrastructure/time-provider/fixed-time-provider'; -import { SystemTimeProvider } from '@/write/shared/infrastructure/time-provider/system-time-provider'; import { SharedModule } from '@/write/shared/shared.module'; -import { setupMiddlewares } from '../app.middlewares'; import { AppModule } from '../app.module'; import { eventEmitterRootModule } from '../event-emitter.root-module'; @@ -373,38 +368,3 @@ export const commandBusNoFailWithoutHandler: Partial = { register: jest.fn(), execute: jest.fn(), }; - -export async function initTestModuleRestApi( - controller: Type, - config?: (module: TestingModuleBuilder) => TestingModuleBuilder, -) { - const commandBusExecute = jest.fn(); - const moduleBuilder = await Test.createTestingModule({ - providers: [ - { - provide: CommandBus, - useValue: { execute: commandBusExecute, register: jest.fn() }, - }, - { - provide: ApplicationCommandFactory, - useValue: new ApplicationCommandFactory(new UuidGenerator(), new SystemTimeProvider()), - }, - ], - controllers: [controller], - }); - const moduleRef = await (config ? config(moduleBuilder) : moduleBuilder).compile(); - - const app: INestApplication = moduleRef.createNestApplication(); - - setupMiddlewares(app); - - await app.init(); - - const http = supertest(app.getHttpServer()); - - async function close() { - await app.close(); - } - - return { http, close, commandBusExecute }; -}