diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3e42b8cc..790bb6ff 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -132,15 +132,15 @@ jobs: DB_USERNAME=${{ secrets.DB_USERNAME }} DB_PASSWORD=${{ secrets.DB_PASSWORD }} ACCESS_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }} - ACCESS_TOKEN_EXPIRES_IN=${{ vars.ACCESS_TOKEN_EXPIRES_IN }} - RESET_PASSWORD_TOKEN_SECRET=${{ secrets.ACCESS_TOKEN_SECRET }} + ACCESS_TOKEN_EXPIRES_IN=${{ secrets.ACCESS_TOKEN_EXPIRES_IN }} + RESET_PASSWORD_TOKEN_SECRET=${{ secrets.RESET_PASSWORD_TOKEN_SECRET }} RESET_PASSWORD_TOKEN_EXPIRES_IN=${{ secrets.RESET_PASSWORD_TOKEN_EXPIRES_IN }} EMAIL_CONFIRMATION_TOKEN_SECRET=${{ secrets.EMAIL_CONFIRMATION_TOKEN_SECRET }} EMAIL_CONFIRMATION_TOKEN_EXPIRES_IN=${{ secrets.EMAIL_CONFIRMATION_TOKEN_EXPIRES_IN }} AWS_SES_ACCESS_KEY_ID=${{ secrets.AWS_SES_ACCESS_KEY_ID }} AWS_SES_ACCESS_KEY_SECRET=${{ secrets.AWS_SES_ACCESS_KEY_SECRET }} AWS_SES_DOMAIN=${{ secrets.AWS_SES_DOMAIN }} - AWS_SES_REGION=${{ secrets.AWS_SES_REGION }} + AWS_SES_REGION=${{ secrets.AWS_REGION }} context: . cache-from: type=gha cache-to: type=gha,mode=max diff --git a/api/Dockerfile b/api/Dockerfile index 1227ee64..b4327ac8 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -22,7 +22,7 @@ ENV DB_NAME $DB_NAME ENV DB_USERNAME $DB_USERNAME ENV DB_PASSWORD $DB_PASSWORD ENV ACCESS_TOKEN_SECRET $ACCESS_TOKEN_SECRET -ENV ACCESS_TOKEN_SECRET $ACCESS_TOKEN_EXPIRES_IN +ENV ACCESS_TOKEN_EXPIRES_IN $ACCESS_TOKEN_EXPIRES_IN ENV RESET_PASSWORD_TOKEN_SECRET $RESET_PASSWORD_TOKEN_SECRET ENV RESET_PASSWORD_TOKEN_EXPIRES_IN $RESET_PASSWORD_TOKEN_EXPIRES_IN ENV EMAIL_CONFIRMATION_TOKEN_SECRET $EMAIL_CONFIRMATION_TOKEN_SECRET diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 194f2df0..16951d5b 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -5,9 +5,16 @@ import { ApiConfigModule } from '@api/modules/config/app-config.module'; import { AuthModule } from '@api/modules/auth/auth.module'; import { NotificationsModule } from '@api/modules/notifications/notifications.module'; import { EventsModule } from '@api/modules/events/events.module'; +import { AdminModule } from '@api/modules/admin/admin.module'; @Module({ - imports: [ApiConfigModule, AuthModule, NotificationsModule, EventsModule], + imports: [ + ApiConfigModule, + AuthModule, + NotificationsModule, + EventsModule, + AdminModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/api/src/modules/admin/admin.controller.ts b/api/src/modules/admin/admin.controller.ts new file mode 100644 index 00000000..5cccf6e8 --- /dev/null +++ b/api/src/modules/admin/admin.controller.ts @@ -0,0 +1,27 @@ +import { Controller, UseGuards } from '@nestjs/common'; +import { RolesGuard } from '@api/modules/auth/guards/roles.guard'; +import { AuthenticationService } from '@api/modules/auth/authentication/authentication.service'; +import { JwtAuthGuard } from '@api/modules/auth/guards/jwt-auth.guard'; +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; +import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator'; +import { tsRestHandler, TsRestHandler } from '@ts-rest/nest'; +import { ControllerResponse } from '@api/types/controller-response.type'; +import { adminContract } from '@shared/contracts/admin.contract'; + +@Controller() +@UseGuards(JwtAuthGuard, RolesGuard) +export class AdminController { + constructor(private readonly auth: AuthenticationService) {} + + @RequiredRoles(ROLES.ADMIN) + @TsRestHandler(adminContract.createUser) + async createUser(): Promise { + return tsRestHandler(adminContract.createUser, async ({ body }) => { + await this.auth.createUser(body); + return { + status: 201, + body: null, + }; + }); + } +} diff --git a/api/src/modules/admin/admin.module.ts b/api/src/modules/admin/admin.module.ts new file mode 100644 index 00000000..3a161b38 --- /dev/null +++ b/api/src/modules/admin/admin.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AdminController } from './admin.controller'; +import { AuthModule } from '@api/modules/auth/auth.module'; + +@Module({ + imports: [AuthModule], + controllers: [AdminController], +}) +export class AdminModule {} diff --git a/api/src/modules/auth/auth.module.ts b/api/src/modules/auth/auth.module.ts index c2ab41d5..ab4ea284 100644 --- a/api/src/modules/auth/auth.module.ts +++ b/api/src/modules/auth/auth.module.ts @@ -10,5 +10,6 @@ import { AuthenticationController } from '@api/modules/auth/authentication/authe imports: [AuthenticationModule, AuthorisationModule, NotificationsModule], controllers: [AuthenticationController], providers: [PasswordRecoveryService, AuthMailer], + exports: [AuthenticationModule, AuthMailer], }) export class AuthModule {} diff --git a/api/src/modules/auth/authentication/authentication.controller.ts b/api/src/modules/auth/authentication/authentication.controller.ts index f1ca8b83..1683bef6 100644 --- a/api/src/modules/auth/authentication/authentication.controller.ts +++ b/api/src/modules/auth/authentication/authentication.controller.ts @@ -12,11 +12,12 @@ import { LocalAuthGuard } from '@api/modules/auth/guards/local-auth.guard'; import { GetUser } from '@api/modules/auth/decorators/get-user.decorator'; import { Public } from '@api/modules/auth/decorators/is-public.decorator'; import { PasswordRecoveryService } from '@api/modules/auth/services/password-recovery.service'; -import { authContract } from '@shared/contracts/auth/auth.contract'; + import { tsRestHandler, TsRestHandler } from '@ts-rest/nest'; import { ControllerResponse } from '@api/types/controller-response.type'; import { AuthGuard } from '@nestjs/passport'; import { ResetPassword } from '@api/modules/auth/strategies/reset-password.strategy'; +import { authContract } from '@shared/contracts/auth.contract'; @Controller() @UseInterceptors(ClassSerializerInterceptor) diff --git a/api/src/modules/auth/authentication/authentication.service.ts b/api/src/modules/auth/authentication/authentication.service.ts index 52c8087d..2badf47a 100644 --- a/api/src/modules/auth/authentication/authentication.service.ts +++ b/api/src/modules/auth/authentication/authentication.service.ts @@ -3,13 +3,13 @@ import { JwtService } from '@nestjs/jwt'; import { UsersService } from '@api/modules/users/users.service'; import { User } from '@shared/entities/users/user.entity'; import * as bcrypt from 'bcrypt'; -import { LoginDto } from '@api/modules/auth/dtos/login.dto'; -import { JwtPayload } from '@api/modules/auth/strategies/jwt.strategy'; -import { EventBus } from '@nestjs/cqrs'; -import { UserSignedUpEvent } from '@api/modules/events/user-events/user-signed-up.event'; +import { CommandBus, EventBus } from '@nestjs/cqrs'; import { UserWithAccessToken } from '@shared/dtos/user.dto'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; import { ApiConfigService } from '@api/modules/config/app-config.service'; +import { CreateUserDto } from '@shared/schemas/users/create-user.schema'; +import { randomBytes } from 'node:crypto'; +import { SendWelcomeEmailCommand } from '@api/modules/notifications/email/commands/send-welcome-email.command'; @Injectable() export class AuthenticationService { @@ -18,6 +18,7 @@ export class AuthenticationService { private readonly jwt: JwtService, private readonly apiConfig: ApiConfigService, private readonly eventBus: EventBus, + private readonly commandBus: CommandBus, ) {} async validateUser(email: string, password: string): Promise { const user = await this.usersService.findByEmail(email); @@ -27,19 +28,28 @@ export class AuthenticationService { throw new UnauthorizedException(`Invalid credentials`); } - // TODO: Move logic to createUser service - async signUp(signupDto: LoginDto): Promise { - const passwordHash = await bcrypt.hash(signupDto.password, 10); + async createUser(createUser: CreateUserDto): Promise { + // TODO: This is sync, check how to improve it + const { email, name, partnerName } = createUser; + const plainTextPassword = randomBytes(8).toString('hex'); + const passwordHash = await bcrypt.hash(plainTextPassword, 10); const newUser = await this.usersService.createUser({ - email: signupDto.email, + name, + email, password: passwordHash, + partnerName, + isActive: false, }); - this.eventBus.publish(new UserSignedUpEvent(newUser.id, newUser.email)); + await this.commandBus + .execute(new SendWelcomeEmailCommand(newUser, plainTextPassword)) + .catch(() => this.usersService.delete(newUser)); } async logIn(user: User): Promise { - const payload: JwtPayload = { id: user.id }; - const accessToken: string = this.jwt.sign(payload); + const { token: accessToken } = await this.signTokenByType( + TOKEN_TYPE_ENUM.ACCESS, + user.id, + ); return { user, accessToken }; } @@ -52,4 +62,19 @@ export class AuthenticationService { throw new UnauthorizedException(); } } + + private async signTokenByType( + tokenType: TOKEN_TYPE_ENUM, + userId: string, + ): Promise<{ token: string; expiresIn: string }> { + const { secret, expiresIn } = this.apiConfig.getJWTConfigByType(tokenType); + const token = await this.jwt.signAsync( + { id: userId }, + { + secret, + expiresIn, + }, + ); + return { token, expiresIn }; + } } diff --git a/api/src/modules/auth/authorisation/roles.enum.ts b/api/src/modules/auth/authorisation/roles.enum.ts index 6f3f7d7b..7b3418b5 100644 --- a/api/src/modules/auth/authorisation/roles.enum.ts +++ b/api/src/modules/auth/authorisation/roles.enum.ts @@ -3,9 +3,3 @@ export enum ROLES { PARTNER = 'partner', GENERAL_USER = 'general_user', } - -export const ROLES_HIERARCHY = { - [ROLES.ADMIN]: [ROLES.PARTNER, ROLES.GENERAL_USER], - [ROLES.PARTNER]: [ROLES.GENERAL_USER], - [ROLES.GENERAL_USER]: [], -}; diff --git a/api/src/modules/auth/guards/roles.guard.ts b/api/src/modules/auth/guards/roles.guard.ts index 5fc0aa86..485dd7bc 100644 --- a/api/src/modules/auth/guards/roles.guard.ts +++ b/api/src/modules/auth/guards/roles.guard.ts @@ -1,9 +1,6 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { - ROLES, - ROLES_HIERARCHY, -} from '@api/modules/auth/authorisation/roles.enum'; +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; import { ROLES_KEY } from '@api/modules/auth/decorators/roles.decorator'; @Injectable() @@ -24,10 +21,6 @@ export class RolesGuard implements CanActivate { } private hasRequiredRole(userRole: ROLES, requiredRoles: ROLES[]): boolean { - return requiredRoles.some( - (requiredRole) => - userRole === requiredRole || - ROLES_HIERARCHY[userRole]?.includes(requiredRole), - ); + return requiredRoles.some((role) => userRole === role); } } diff --git a/api/src/modules/auth/services/auth.mailer.ts b/api/src/modules/auth/services/auth.mailer.ts index bb206139..daa8806a 100644 --- a/api/src/modules/auth/services/auth.mailer.ts +++ b/api/src/modules/auth/services/auth.mailer.ts @@ -5,10 +5,11 @@ import { } from '@api/modules/notifications/email/email-service.interface'; import { ApiConfigService } from '@api/modules/config/app-config.service'; import { TOKEN_TYPE_ENUM } from '@shared/schemas/auth/token-type.schema'; +import { JwtService } from '@nestjs/jwt'; +import { User } from '@shared/entities/users/user.entity'; -export type PasswordRecovery = { - email: string; - token: string; +export type PasswordRecoveryDto = { + user: User; origin: string; }; @@ -18,19 +19,17 @@ export class AuthMailer { @Inject(IEmailServiceToken) private readonly emailService: IEmailServiceInterface, private readonly apiConfig: ApiConfigService, + private readonly jwt: JwtService, ) {} async sendPasswordRecoveryEmail( - passwordRecovery: PasswordRecovery, + passwordRecovery: PasswordRecoveryDto, ): Promise { - // TODO: Investigate if it's worth using a template engine to generate the email content, the mail service provider allows it - // TODO: Use a different expiration time, or different secret altogether for password recovery - - const { expiresIn } = this.apiConfig.getJWTConfigByType( + const { token, expiresIn } = await this.signTokenByType( TOKEN_TYPE_ENUM.RESET_PASSWORD, + passwordRecovery.user.id, ); - - const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${passwordRecovery.token}`; + const resetPasswordUrl = `${passwordRecovery.origin}/auth/forgot-password/${token}`; const htmlContent: string = `

Dear User,

@@ -47,11 +46,58 @@ export class AuthMailer { await this.emailService.sendMail({ from: 'password-recovery', - to: passwordRecovery.email, + to: passwordRecovery.user.email, subject: 'Recover Password', html: htmlContent, }); } + + async sendWelcomeEmail(welcomeEmailDto: { + user: User; + defaultPassword: string; + }) { + const { token, expiresIn } = await this.signTokenByType( + TOKEN_TYPE_ENUM.EMAIL_CONFIRMATION, + welcomeEmailDto.user.id, + ); + + // TODO: We need to know the URL to confirm the email, we could rely on origin but we would need to pass it through a lot of code. + // probably better to have a config value for this. + const resetPasswordUrl = `TODO/auth/sign-up/${token}`; + + const htmlContent: string = ` +

Dear User,

+
+

Welcome to the TNC Blue Carbon Cost Tool Platform

+
+

Thank you for signing up. We're excited to have you on board. Please active you account by signing up adding a password of your choice

+

Sign Up Link

+
+

Your one-time password is ${welcomeEmailDto.defaultPassword}

+

For security reasons, this link will expire after ${passwordRecoveryTokenExpirationHumanReadable(expiresIn)}.

+
+

Thank you for using the platform. We're committed to ensuring your account's security.

+

Best regards.

`; + + await this.emailService.sendMail({ + from: 'welcome', + to: welcomeEmailDto.user.email, + subject: 'Welcome to TNC Blue Carbon Cost Tool Platform', + html: htmlContent, + }); + } + + private async signTokenByType( + tokenType: TOKEN_TYPE_ENUM, + userId: string, + ): Promise<{ token: string; expiresIn: string }> { + const { secret, expiresIn } = this.apiConfig.getJWTConfigByType(tokenType); + const token = await this.jwt.signAsync( + { id: userId }, + { secret, expiresIn }, + ); + return { token, expiresIn }; + } } const passwordRecoveryTokenExpirationHumanReadable = ( diff --git a/api/src/modules/auth/services/password-recovery.service.ts b/api/src/modules/auth/services/password-recovery.service.ts index e87ec9e9..394d1c04 100644 --- a/api/src/modules/auth/services/password-recovery.service.ts +++ b/api/src/modules/auth/services/password-recovery.service.ts @@ -14,10 +14,8 @@ export class PasswordRecoveryService { logger: Logger = new Logger(PasswordRecoveryService.name); constructor( private readonly users: UsersService, - private readonly jwt: JwtService, private readonly authMailer: AuthMailer, private readonly eventBus: EventBus, - private readonly apiConfig: ApiConfigService, ) {} async requestPasswordRecovery(email: string, origin: string): Promise { @@ -29,13 +27,8 @@ export class PasswordRecoveryService { this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, null)); return; } - const { secret, expiresIn } = this.apiConfig.getJWTConfigByType( - TOKEN_TYPE_ENUM.RESET_PASSWORD, - ); - const token = this.jwt.sign({ id: user.id }, { secret, expiresIn }); await this.authMailer.sendPasswordRecoveryEmail({ - email: user.email, - token, + user, origin, }); this.eventBus.publish(new PasswordRecoveryRequestedEvent(email, user.id)); diff --git a/api/src/modules/notifications/email/commands/send-welcome-email.command.ts b/api/src/modules/notifications/email/commands/send-welcome-email.command.ts new file mode 100644 index 00000000..25d31e69 --- /dev/null +++ b/api/src/modules/notifications/email/commands/send-welcome-email.command.ts @@ -0,0 +1,8 @@ +import { User } from '@shared/entities/users/user.entity'; + +export class SendWelcomeEmailCommand { + constructor( + public readonly user: User, + public readonly plainPassword: string, + ) {} +} diff --git a/api/src/modules/notifications/email/commands/send-welcome-email.handler.ts b/api/src/modules/notifications/email/commands/send-welcome-email.handler.ts new file mode 100644 index 00000000..818f866b --- /dev/null +++ b/api/src/modules/notifications/email/commands/send-welcome-email.handler.ts @@ -0,0 +1,19 @@ +// send-welcome-email.handler.ts +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { SendWelcomeEmailCommand } from './send-welcome-email.command'; +import { AuthMailer } from '@api/modules/auth/services/auth.mailer'; + +@CommandHandler(SendWelcomeEmailCommand) +export class SendWelcomeEmailHandler + implements ICommandHandler +{ + constructor(private readonly authMailer: AuthMailer) {} + + async execute(command: SendWelcomeEmailCommand): Promise { + const { user, plainPassword } = command; + await this.authMailer.sendWelcomeEmail({ + user, + defaultPassword: plainPassword, + }); + } +} diff --git a/api/src/modules/notifications/email/email.module.ts b/api/src/modules/notifications/email/email.module.ts index 22992669..eec81558 100644 --- a/api/src/modules/notifications/email/email.module.ts +++ b/api/src/modules/notifications/email/email.module.ts @@ -1,10 +1,14 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; import { NodemailerEmailService } from '@api/modules/notifications/email/nodemailer.email.service'; +import { SendWelcomeEmailHandler } from '@api/modules/notifications/email/commands/send-welcome-email.handler'; +import { AuthModule } from '@api/modules/auth/auth.module'; @Module({ + imports: [forwardRef(() => AuthModule)], providers: [ { provide: IEmailServiceToken, useClass: NodemailerEmailService }, + SendWelcomeEmailHandler, ], exports: [IEmailServiceToken], }) diff --git a/api/src/modules/notifications/email/nodemailer.email.service.ts b/api/src/modules/notifications/email/nodemailer.email.service.ts index da5b1c6e..912256fa 100644 --- a/api/src/modules/notifications/email/nodemailer.email.service.ts +++ b/api/src/modules/notifications/email/nodemailer.email.service.ts @@ -12,7 +12,6 @@ import { } from '@api/modules/notifications/email/email-service.interface'; import { ConfigService } from '@nestjs/config'; import { EventBus } from '@nestjs/cqrs'; -import { EmailFailedEventHandler } from '@api/modules/events/api-events/handlers/emai-failed-event.handler'; import { EmailFailedEvent } from '@api/modules/events/api-events/email-failed.event'; @Injectable() @@ -54,11 +53,10 @@ export class NodemailerEmailService implements IEmailServiceInterface { private getMailConfig() { const accessKeyId = this.configService.get('AWS_SES_ACCESS_KEY_ID'); const secretAccessKey = this.configService.get( - 'AWS_SES_SECRET_ACCESS_KEY', + 'AWS_SES_ACCESS_KEY_SECRET', ); const region = this.configService.get('AWS_SES_REGION'); const domain = this.configService.get('AWS_SES_DOMAIN'); - console.log(accessKeyId, secretAccessKey, region, domain); if (!accessKeyId || !secretAccessKey || !region || !domain) { this.logger.error( 'Variables for Email Service not set. Email not available', diff --git a/api/src/modules/users/users.controller.ts b/api/src/modules/users/users.controller.ts index 99562534..6dd3ad27 100644 --- a/api/src/modules/users/users.controller.ts +++ b/api/src/modules/users/users.controller.ts @@ -1,26 +1,4 @@ import { Controller, Get } from '@nestjs/common'; -import { RequiredRoles } from '@api/modules/auth/decorators/roles.decorator'; -import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; @Controller('users') -export class UsersController { - // TODO: All of these endpoints are fake, only to test the role guard - - @RequiredRoles(ROLES.ADMIN) - @Get('admin') - async createUserAsAdmin() { - return [ROLES.ADMIN]; - } - - @RequiredRoles(ROLES.PARTNER) - @Get('partner') - async createUserAsPartner() { - return [ROLES.PARTNER, ROLES.ADMIN]; - } - - @RequiredRoles(ROLES.GENERAL_USER) - @Get('user') - async createUserAsUser() { - return [ROLES.GENERAL_USER, ROLES.PARTNER, ROLES.ADMIN]; - } -} +export class UsersController {} diff --git a/api/src/modules/users/users.service.ts b/api/src/modules/users/users.service.ts index 1f3ea07b..68569011 100644 --- a/api/src/modules/users/users.service.ts +++ b/api/src/modules/users/users.service.ts @@ -17,18 +17,20 @@ export class UsersService { return this.repo.findOne({ where: { email } }); } - async createUser(createUserDto: { email: string; password: string }) { - const existingUser = await this.findByEmail(createUserDto.email); + async createUser(newUser: Partial) { + const existingUser = await this.findByEmail(newUser.email); if (existingUser) { - throw new ConflictException( - `Email ${createUserDto.email} already exists`, - ); + throw new ConflictException(`Email ${newUser.email} already exists`); } - return this.repo.save(createUserDto); + return this.repo.save(newUser); } async updatePassword(user: User, newPassword: string) { user.password = newPassword; return this.repo.save(user); } + + async delete(user: User) { + return this.repo.remove(user); + } } diff --git a/api/test/auth/authorization.spec.ts b/api/test/auth/authorization.spec.ts deleted file mode 100644 index 0c799016..00000000 --- a/api/test/auth/authorization.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; -import { TestManager } from '../utils/test-manager'; -import { User } from '@shared/entities/users/user.entity'; - -describe('Authorization', () => { - let testManager: TestManager; - - beforeAll(async () => { - testManager = await TestManager.createTestManager(); - }); - - afterEach(async () => { - await testManager.clearDatabase(); - }); - - afterAll(async () => { - await testManager.close(); - }); - - test('a user should have a default general user role when signing up', async () => { - await testManager - .request() - .post('/authentication/signup') - .send({ email: 'test@test.com', password: '123456' }); - - const user = await testManager - .getDataSource() - .getRepository(User) - .findOne({ where: { email: 'test@test.com' } }); - - expect(user.role).toEqual(ROLES.GENERAL_USER); - }); - - describe('ROLE TEST ENDPOINTS, REMOVE!', () => { - test('when role required is GENERAL_USER, all roles should have access', async () => { - const roles = [ROLES.GENERAL_USER, ROLES.PARTNER, ROLES.ADMIN]; - - for (const role of roles) { - const user = await testManager - .mocks() - .createUser({ role, email: `${role}@email.com` }); - const { jwtToken } = await testManager.logUserIn(user); - - const response = await testManager - .request() - .get('/users/user') - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(200); - expect(response.body).toEqual( - expect.arrayContaining([ - ROLES.GENERAL_USER, - ROLES.PARTNER, - ROLES.ADMIN, - ]), - ); - } - }); - - test('when role required is PARTNER, only PARTNER and ADMIN roles should have access', async () => { - const allowedRoles = [ROLES.PARTNER, ROLES.ADMIN]; - const deniedRoles = [ROLES.GENERAL_USER]; - - for (const role of allowedRoles) { - const user = await testManager - .mocks() - .createUser({ role, email: `${role}@email.com` }); - const { jwtToken } = await testManager.logUserIn(user); - - const response = await testManager - .request() - .get('/users/partner') - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(200); - expect(response.body).toEqual( - expect.arrayContaining([ROLES.PARTNER, ROLES.ADMIN]), - ); - } - - for (const role of deniedRoles) { - const user = await testManager - .mocks() - .createUser({ role, email: `${role}@email.com` }); - const { jwtToken } = await testManager.logUserIn(user); - - const response = await testManager - .request() - .get('/users/partner') - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(403); - } - }); - - test('when role required is ADMIN, only ADMIN role should have access', async () => { - const allowedRoles = [ROLES.ADMIN]; - const deniedRoles = [ROLES.GENERAL_USER, ROLES.PARTNER]; - - for (const role of allowedRoles) { - const user = await testManager - .mocks() - .createUser({ role, email: `${role}@email.com` }); - const { jwtToken } = await testManager.logUserIn(user); - - const response = await testManager - .request() - .get('/users/admin') - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(200); - expect(response.body).toEqual([ROLES.ADMIN]); - } - - for (const role of deniedRoles) { - const user = await testManager - .mocks() - .createUser({ role, email: `${role}@email.com` }); - const { jwtToken } = await testManager.logUserIn(user); - - const response = await testManager - .request() - .get('/users/admin') - .set('Authorization', `Bearer ${jwtToken}`); - - expect(response.status).toBe(403); - } - }); - }); -}); diff --git a/api/test/e2e/features/create-user.feature b/api/test/e2e/features/create-user.feature new file mode 100644 index 00000000..da9e8923 --- /dev/null +++ b/api/test/e2e/features/create-user.feature @@ -0,0 +1,23 @@ +Feature: Create user as Admin + + Scenario: A user can not create a user if it is not an admin + Given a user exists with valid credentials + But the user has the role "partner" + When the user creates a new user + Then the user should receive a 403 status code + + + Scenario: An Admin tries to register a partner with an existing email + Given a admin user exists with valid credentials + When the user creates a new user + But the email is already in use + Then the user should receive a 409 status code + And the user should receive a message containing "Email already exists" + + + Scenario: An Admin registers a new user + Given a admin user exists with valid credentials + When the user creates a new user + Then the user should receive a 201 status code + And the user should not be active + And an email should be sent diff --git a/api/test/e2e/features/sign-up.feature b/api/test/e2e/features/sign-up.feature new file mode 100644 index 00000000..e69de29b diff --git a/api/test/integration/auth/create-user.spec.ts b/api/test/integration/auth/create-user.spec.ts new file mode 100644 index 00000000..28d44e8d --- /dev/null +++ b/api/test/integration/auth/create-user.spec.ts @@ -0,0 +1,100 @@ +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; +import { TestManager } from '../../utils/test-manager'; +import { User } from '@shared/entities/users/user.entity'; +import { HttpStatus } from '@nestjs/common'; +import { MockEmailService } from '../../utils/mocks/mock-email.service'; +import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; + +//create-user.feature + +describe('Create Users', () => { + let testManager: TestManager; + let testUser: User; + let jwtToken: string; + let mockEmailService: MockEmailService; + + beforeAll(async () => { + testManager = await TestManager.createTestManager(); + + mockEmailService = + testManager.getModule(IEmailServiceToken); + }); + beforeEach(async () => { + const { user, jwtToken: token } = await testManager.setUpTestUser(); + testUser = user; + jwtToken = token; + }); + + afterEach(async () => { + await testManager.clearDatabase(); + }); + + afterAll(async () => { + await testManager.close(); + }); + + test('A user can not create a user if it is not an admin', async () => { + // Given a user exists with valid credentials + // But the user has the role partner + + const user = await testManager + .mocks() + .createUser({ role: ROLES.PARTNER, email: 'random@test.com' }); + const { jwtToken } = await testManager.logUserIn(user); + + // When the user creates a new user + + const response = await testManager + .request() + .post('/admin/users') + .set('Authorization', `Bearer ${jwtToken}`); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); + test('An Admin tries to register a partner with an existing email', async () => { + // Given a admin user exists with valid credentials + // beforeAll + + // When the user creates a new user + // But the email is already in use + + const response = await testManager + .request() + .post('/admin/users') + .set('Authorization', `Bearer ${jwtToken}`) + .send({ email: testUser.email, partnerName: 'test' }); + + // Then the user should receive a 409 status code + expect(response.status).toBe(HttpStatus.CONFLICT); + // And the user should receive a message containing "Email already exists" + expect(response.body.message).toBe( + `Email ${testUser.email} already exists`, + ); + }); + + test('An Admin registers a new user', async () => { + // Given a admin user exists with valid credentials + // beforeAll + const newUser = { + email: 'test@test.com', + partnerName: 'test', + }; + + const response = await testManager + .request() + .post('/admin/users') + .set('Authorization', `Bearer ${jwtToken}`) + .send(newUser); + + // Then the user should receive a 201 status code + expect(response.status).toBe(HttpStatus.CREATED); + // And the user should not be active + const createdUser = await testManager + .getDataSource() + .getRepository(User) + .findOne({ where: { email: newUser.email } }); + + expect(createdUser.isActive).toBe(false); + expect(mockEmailService.sendMail).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/test/jest-config.json b/api/test/jest-config.json index 2b0e1ce8..a3717c69 100644 --- a/api/test/jest-config.json +++ b/api/test/jest-config.json @@ -3,7 +3,7 @@ "rootDir": "../", "roots": ["/src/", "/test/"], "testEnvironment": "node", - "testRegex": ".steps.ts$", + "testRegex": "(.steps.ts|.spec.ts)$", "transform": { "^.+\\.(t|j)s$": "ts-jest" }, diff --git a/api/test/utils/test-manager.ts b/api/test/utils/test-manager.ts index f1e94ff2..7665e43a 100644 --- a/api/test/utils/test-manager.ts +++ b/api/test/utils/test-manager.ts @@ -13,6 +13,7 @@ import { createUser } from './mocks/entity-mocks'; import { User } from '@shared/entities/users/user.entity'; import { IEmailServiceToken } from '@api/modules/notifications/email/email-service.interface'; import { MockEmailService } from './mocks/mock-email.service'; +import { ROLES } from '@api/modules/auth/authorisation/roles.enum'; /** * @description: Abstraction for NestJS testing workflow. For now its a basic implementation to create a test app, but can be extended to encapsulate @@ -72,7 +73,7 @@ export class TestManager { } async setUpTestUser() { - const user = await createUser(this.getDataSource()); + const user = await createUser(this.getDataSource(), { role: ROLES.ADMIN }); return logUserIn(this, user); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b16f515..0df1892b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7161,8 +7161,8 @@ snapshots: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0) - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.10.0(eslint@8.57.0) eslint-plugin-react: 7.35.2(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.0) @@ -7185,37 +7185,37 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.3.6 enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.1.0 is-glob: 4.0.3 optionalDependencies: - eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 7.2.0(eslint@8.57.0)(typescript@5.4.5) eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0): + eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.8 @@ -7226,7 +7226,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 diff --git a/shared/contracts/admin.contract.ts b/shared/contracts/admin.contract.ts new file mode 100644 index 00000000..2ab8f917 --- /dev/null +++ b/shared/contracts/admin.contract.ts @@ -0,0 +1,18 @@ +import { initContract } from "@ts-rest/core"; +import { JSONAPIError } from "@shared/dtos/json-api.error"; +import { CreateUserSchema } from "@shared/schemas/users/create-user.schema"; + +// TODO: This is a scaffold. We need to define types for responses, zod schemas for body and query param validation etc. + +const contract = initContract(); +export const adminContract = contract.router({ + createUser: { + method: "POST", + path: "/admin/users", + responses: { + 201: contract.type(), + 401: contract.type(), + }, + body: CreateUserSchema, + }, +}); diff --git a/shared/contracts/auth/auth.contract.ts b/shared/contracts/auth.contract.ts similarity index 100% rename from shared/contracts/auth/auth.contract.ts rename to shared/contracts/auth.contract.ts diff --git a/shared/contracts/index.ts b/shared/contracts/index.ts index 3f13f97d..cb5986fc 100644 --- a/shared/contracts/index.ts +++ b/shared/contracts/index.ts @@ -1,8 +1,10 @@ import { initContract } from "@ts-rest/core"; -import { authContract } from "./auth/auth.contract"; +import { adminContract } from "@shared/contracts/admin.contract"; +import { authContract } from "@shared/contracts/auth.contract"; const contract = initContract(); export const router = contract.router({ auth: authContract, + admin: adminContract, }); diff --git a/shared/entities/users/user.entity.ts b/shared/entities/users/user.entity.ts index 826cde11..8aa68baf 100644 --- a/shared/entities/users/user.entity.ts +++ b/shared/entities/users/user.entity.ts @@ -15,6 +15,15 @@ export class User { @Column({ unique: true }) email: string; + @Column({ nullable: true }) + name: string; + + @Column({ default: "default_partner", name: "partner_name" }) + partnerName: string; + + @Column({ type: "boolean", default: false, name: "is_active" }) + isActive: boolean; + @Column() @Exclude() password: string; diff --git a/shared/schemas/users/create-user.schema.ts b/shared/schemas/users/create-user.schema.ts new file mode 100644 index 00000000..d2274a7f --- /dev/null +++ b/shared/schemas/users/create-user.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const CreateUserSchema = z.object({ + email: z.string().email(), + name: z.string().optional(), + partnerName: z.string(), +}); + +export type CreateUserDto = z.infer;