From 6119d80ee8cd4f8f08a34c98dc329824a1d5373b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Thu, 14 Dec 2023 01:57:32 +0100 Subject: [PATCH 1/7] [GH-38] doc(User): add OAS3 specs --- src/apps/apiApp/openApi.yaml | 186 ++++++++++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 2 deletions(-) diff --git a/src/apps/apiApp/openApi.yaml b/src/apps/apiApp/openApi.yaml index 4c956c0..229692c 100644 --- a/src/apps/apiApp/openApi.yaml +++ b/src/apps/apiApp/openApi.yaml @@ -20,10 +20,182 @@ servers: variables: {} tags: + - name: Auth - name: Books - name: Health paths: + /api/v1/Auth/register: + post: + deprecated: false + tags: + - Auth + summary: Register User + operationId: RegisterUser + parameters: [] + requestBody: + description: '' + content: + application/json: + example: + username: '{{$randomNamePrefix}} {{$randomLastName}}' + email: '{{$randomEmail}}' + password: '{{$randomPassword}}' + repeatPassword: '{{$randomPassword}}' + required: true + responses: + '201': + description: Created + headers: + vary: + $ref: '#/components/headers/vary' + access-control-allow-credentials: + $ref: '#/components/headers/access-control-allow-credentials' + x-xss-protection: + $ref: '#/components/headers/x-xss-protection' + x-content-type-options: + $ref: '#/components/headers/x-content-type-options' + x-frame-options: + $ref: '#/components/headers/x-frame-options' + date: + $ref: '#/components/headers/date' + content-length: + $ref: '#/components/headers/content-length' + x-envoy-upstream-service-time: + $ref: '#/components/headers/x-envoy-upstream-service-time' + server: + $ref: '#/components/headers/server' + content: {} + + /api/v1/Auth/login: + post: + deprecated: false + tags: + - Auth + summary: Login User + operationId: LoginUser + parameters: [] + requestBody: + description: '' + content: + application/json: + example: + email: '{{$randomEmail}}' + password: '{{$randomPassword}}' + required: true + responses: + '200': + description: Successful operation + headers: + vary: + $ref: '#/components/headers/vary' + access-control-allow-credentials: + $ref: '#/components/headers/access-control-allow-credentials' + x-xss-protection: + $ref: '#/components/headers/x-xss-protection' + x-content-type-options: + $ref: '#/components/headers/x-content-type-options' + x-frame-options: + $ref: '#/components/headers/x-frame-options' + date: + $ref: '#/components/headers/date' + content-length: + $ref: '#/components/headers/content-length' + etag: + $ref: '#/components/headers/etag' + x-envoy-upstream-service-time: + $ref: '#/components/headers/x-envoy-upstream-service-time' + server: + $ref: '#/components/headers/server' + content: + application/json: + schema: + type: object + properties: + token: + type: string + example: + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjIzOTlmOWI0LTc1NWItNDBiZS04NTAwLTNkOTgxYzc2MmQ0NiIsImVtYWlsIjoidXNlcjE3MDI0MjI2ODgyMTFAdGVzdC5jb20iLCJ1c2VybmFtZSI6InVzZXIxNzAyNDIyNjg4MjExIiwicm9sZXMiOlsidXNlciJdLCJpYXQiOjE3MDI0MjMzMzksImV4cCI6MTcwMjQzMDUzOX0.zS6toKlHdEvuL4k5CBFlyNCCWjkAZbwC1x2AYQqNUhA" + + /api/v1/Auth/validate/{token}: + get: + deprecated: false + tags: + - Auth + summary: Validate Token + operationId: ValidateToken + parameters: + - name: token + in: path + description: required + required: true + style: simple + schema: + type: string + example: '{{$jwtToken}}' + responses: + '200': + description: Successful operation + headers: + vary: + $ref: '#/components/headers/vary' + access-control-allow-credentials: + $ref: '#/components/headers/access-control-allow-credentials' + x-xss-protection: + $ref: '#/components/headers/x-xss-protection' + x-content-type-options: + $ref: '#/components/headers/x-content-type-options' + x-frame-options: + $ref: '#/components/headers/x-frame-options' + date: + $ref: '#/components/headers/date' + content-length: + $ref: '#/components/headers/content-length' + etag: + $ref: '#/components/headers/etag' + x-envoy-upstream-service-time: + $ref: '#/components/headers/x-envoy-upstream-service-time' + server: + $ref: '#/components/headers/server' + content: + application/json: + schema: + type: object + properties: + token: + type: string + example: + token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjIzOTlmOWI0LTc1NWItNDBiZS04NTAwLTNkOTgxYzc2MmQ0NiIsImVtYWlsIjoidXNlcjE3MDI0MjI2ODgyMTFAdGVzdC5jb20iLCJ1c2VybmFtZSI6InVzZXIxNzAyNDIyNjg4MjExIiwicm9sZXMiOlsidXNlciJdLCJpYXQiOjE3MDI0MjMzMzksImV4cCI6MTcwMjQzMDUzOX0.zS6toKl" + '401': + description: Unauthorized + headers: + vary: + $ref: '#/components/headers/vary' + access-control-allow-credentials: + $ref: '#/components/headers/access-control-allow-credentials' + x-xss-protection: + $ref: '#/components/headers/x-xss-protection' + x-content-type-options: + $ref: '#/components/headers/x-content-type-options' + x-frame-options: + $ref: '#/components/headers/x-frame-options' + date: + $ref: '#/components/headers/date' + content-length: + $ref: '#/components/headers/content-length' + etag: + $ref: '#/components/headers/etag' + x-envoy-upstream-service-time: + $ref: '#/components/headers/x-envoy-upstream-service-time' + server: + $ref: '#/components/headers/server' + content: + application/json: + schema: + $ref: '#/components/schemas/401' + example: + message: 'Invalid token' + /api/v1/Books: get: tags: @@ -369,7 +541,7 @@ components: schemas: 400: - title: '400' + title: 'Bad Request' type: object properties: errors: @@ -392,8 +564,17 @@ components: - releaseDate: 'Invalid value at body. Value: leading-edge' - pages: 'Invalid value at body. Value: Baht' + 401: + title: 'Unauthorized' + type: object + properties: + message: + type: string + example: + message: 'Invalid token' + Health: - title: OK + title: "Health" type: object properties: version: @@ -403,6 +584,7 @@ components: example: version: 1.4.0 status: OK + Book: type: object properties: From c4f5a0ce5ec36296661b1fb564dbc0ffd601b98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Thu, 14 Dec 2023 01:57:31 +0100 Subject: [PATCH 2/7] [GH-38] feat(User): add value objects --- package-lock.json | 18 +++++ package.json | 1 + .../Auth/application/LoginUserRequest.ts | 4 + .../Auth/application/RegisterUserRequest.ts | 5 ++ src/Contexts/apiApp/Auth/domain/User.ts | 75 +++++++++++++++++++ src/Contexts/apiApp/Auth/domain/UserPatch.ts | 57 ++++++++++++++ .../apiApp/Auth/domain/UserRepository.ts | 11 +++ src/Contexts/apiApp/Auth/domain/UserRoles.ts | 21 ++++++ src/Contexts/apiApp/Auth/domain/Username.ts | 25 +++++++ src/Contexts/apiApp/Auth/domain/index.ts | 7 ++ .../domain/errors/InvalidArgumentError.ts | 7 +- .../shared/domain/value-object/Email.ts | 61 +++++++++++++++ .../domain/value-object/StringValueObject.ts | 12 ++- .../shared/domain/value-object/Uuid.ts | 6 ++ .../Contexts/apiApp/Auth/domain/User.test.ts | 49 ++++++++++++ .../apiApp/Auth/domain/UserName.test.ts | 18 +++++ .../apiApp/Auth/domain/UserRoles.test.ts | 17 +++++ .../apiApp/Auth/domain/mothers/UserMother.ts | 63 ++++++++++++++++ .../Auth/domain/mothers/UserRolesMother.ts | 12 +++ .../apiApp/Auth/domain/mothers/index.ts | 2 + tests/Contexts/fixtures/shared/random.ts | 6 +- tests/Contexts/shared/domain/Email.test.ts | 42 +++++++++++ .../shared/domain/mothers/EmailMother.ts | 12 +++ 23 files changed, 528 insertions(+), 3 deletions(-) create mode 100644 src/Contexts/apiApp/Auth/application/LoginUserRequest.ts create mode 100644 src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts create mode 100644 src/Contexts/apiApp/Auth/domain/User.ts create mode 100644 src/Contexts/apiApp/Auth/domain/UserPatch.ts create mode 100644 src/Contexts/apiApp/Auth/domain/UserRepository.ts create mode 100644 src/Contexts/apiApp/Auth/domain/UserRoles.ts create mode 100644 src/Contexts/apiApp/Auth/domain/Username.ts create mode 100644 src/Contexts/apiApp/Auth/domain/index.ts create mode 100644 src/Contexts/shared/domain/value-object/Email.ts create mode 100644 tests/Contexts/apiApp/Auth/domain/User.test.ts create mode 100644 tests/Contexts/apiApp/Auth/domain/UserName.test.ts create mode 100644 tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts create mode 100644 tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts create mode 100644 tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts create mode 100644 tests/Contexts/apiApp/Auth/domain/mothers/index.ts create mode 100644 tests/Contexts/shared/domain/Email.test.ts create mode 100644 tests/Contexts/shared/domain/mothers/EmailMother.ts diff --git a/package-lock.json b/package-lock.json index fab256c..a6031db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.1", "typescript": "^5.2.2", + "uuid": "^9.0.1", "uuid-validate": "^0.0.3", "winston": "^3.11.0", "winston-mongodb": "^5.1.1", @@ -12783,6 +12784,18 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/uuid-validate": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/uuid-validate/-/uuid-validate-0.0.3.tgz", @@ -23066,6 +23079,11 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==" }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + }, "uuid-validate": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/uuid-validate/-/uuid-validate-0.0.3.tgz", diff --git a/package.json b/package.json index 09fe696..a004714 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "swagger-ui-express": "^5.0.0", "ts-node": "^10.9.1", "typescript": "^5.2.2", + "uuid": "^9.0.1", "uuid-validate": "^0.0.3", "winston": "^3.11.0", "winston-mongodb": "^5.1.1", diff --git a/src/Contexts/apiApp/Auth/application/LoginUserRequest.ts b/src/Contexts/apiApp/Auth/application/LoginUserRequest.ts new file mode 100644 index 0000000..8574e0e --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/LoginUserRequest.ts @@ -0,0 +1,4 @@ +export interface LoginUserRequest { + email: string; + password: string; +} diff --git a/src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts b/src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts new file mode 100644 index 0000000..211ef82 --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/RegisterUserRequest.ts @@ -0,0 +1,5 @@ +import { LoginUserRequest } from './LoginUserRequest'; + +export interface RegisterUserRequest extends LoginUserRequest { + username: string; +} diff --git a/src/Contexts/apiApp/Auth/domain/User.ts b/src/Contexts/apiApp/Auth/domain/User.ts new file mode 100644 index 0000000..8fd3c9e --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/User.ts @@ -0,0 +1,75 @@ +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../shared/domain/value-object/Uuid'; +import { Email } from '../../../shared/domain/value-object/Email'; +import { UserRoles } from './UserRoles'; +import { Username } from './Username'; + +export class User extends AggregateRoot { + readonly id: Uuid; + readonly email: Email; + readonly username: Username; + readonly password: StringValueObject; + readonly emailValidated: boolean; + readonly roles: UserRoles; + + constructor({ + id, + email, + username, + password, + emailValidated, + roles + }: { + id: Uuid; + email: Email; + username: Username; + password: StringValueObject; + emailValidated: boolean; + roles: UserRoles; + }) { + super(); + this.id = id; + this.email = email; + this.username = username; + this.password = password; + this.emailValidated = emailValidated; + this.roles = roles; + } + + toPrimitives(): Record { + return { + id: this.id.value, + email: this.email.value, + username: this.username.value, + password: this.password.value, + emailValidated: this.emailValidated, + roles: this.roles.value + }; + } + + static fromPrimitives({ + id, + email, + username, + password, + emailValidated, + roles + }: { + id: string; + email: string; + username: string; + password: string; + emailValidated: boolean; + roles: string[]; + }): User { + return new User({ + id: new Uuid(id), + email: new Email(email), + username: new Username(username), + password: new StringValueObject(password), + emailValidated, + roles: new UserRoles(roles) + }); + } +} diff --git a/src/Contexts/apiApp/Auth/domain/UserPatch.ts b/src/Contexts/apiApp/Auth/domain/UserPatch.ts new file mode 100644 index 0000000..71ca0f1 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/UserPatch.ts @@ -0,0 +1,57 @@ +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../shared/domain/value-object/Uuid'; +import { UserRoles } from './UserRoles'; + +export class UserPatch extends AggregateRoot { + readonly id: Uuid; + readonly password?: StringValueObject; + readonly emailValidated?: boolean; + readonly roles?: UserRoles; + + constructor({ + id, + password, + emailValidated, + roles + }: { + id: Uuid; + password?: StringValueObject; + emailValidated?: boolean; + roles?: UserRoles; + }) { + super(); + this.id = id; + password && (this.password = password); + emailValidated && (this.emailValidated = emailValidated); + roles && (this.roles = roles); + } + + toPrimitives() { + return { + id: this.id.value, + ...(this.password?.value && { password: this.password.value }), + ...(this.emailValidated && { emailValidated: this.emailValidated }), + ...(this.roles?.value && { roles: this.roles.value }) + }; + } + + static fromPrimitives({ + id, + password, + emailValidated, + roles + }: { + id: string; + password?: string; + emailValidated?: boolean; + roles?: string[]; + }) { + return new UserPatch({ + id: new Uuid(id), + ...(password && { password: new StringValueObject(password) }), + ...(emailValidated && { emailValidated }), + ...(roles && { roles: new UserRoles(roles) }) + }); + } +} diff --git a/src/Contexts/apiApp/Auth/domain/UserRepository.ts b/src/Contexts/apiApp/Auth/domain/UserRepository.ts new file mode 100644 index 0000000..b718c9e --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/UserRepository.ts @@ -0,0 +1,11 @@ +import { Nullable } from '../../../shared/domain/Nullable'; +import { User } from './User'; +import { UserPatch } from './UserPatch'; + +export interface UserRepository { + save(user: User): Promise; + + update(user: UserPatch): Promise; + + search(email: string): Promise>; +} diff --git a/src/Contexts/apiApp/Auth/domain/UserRoles.ts b/src/Contexts/apiApp/Auth/domain/UserRoles.ts new file mode 100644 index 0000000..d2c1628 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/UserRoles.ts @@ -0,0 +1,21 @@ +import { InvalidArgumentError } from '../../../shared/domain/errors/InvalidArgumentError'; + +export class UserRoles { + private static validRoles = ['admin', 'user']; + readonly value: string[]; + + constructor(value: string[]) { + this.ensureRoles(value); + this.value = value; + } + + private ensureRoles(value: string[]): void { + value.forEach((role) => { + if (!UserRoles.validRoles.includes(role)) { + throw new InvalidArgumentError( + `<${this.constructor.name}> does not allow the value <${role}>` + ); + } + }); + } +} diff --git a/src/Contexts/apiApp/Auth/domain/Username.ts b/src/Contexts/apiApp/Auth/domain/Username.ts new file mode 100644 index 0000000..bf45013 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/Username.ts @@ -0,0 +1,25 @@ +import { InvalidArgumentError } from '../../../shared/domain/errors/InvalidArgumentError'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; + +export class Username extends StringValueObject { + constructor(value: string) { + super(value); + this.ensureLength(value); + } + + private ensureLength(value: string): string { + if (value.length < 4) { + throw new InvalidArgumentError( + ' must be at least 4 characters long' + ); + } + + if (value.length > 20) { + throw new InvalidArgumentError( + ' must be less than 20 characters long' + ); + } + + return value; + } +} diff --git a/src/Contexts/apiApp/Auth/domain/index.ts b/src/Contexts/apiApp/Auth/domain/index.ts new file mode 100644 index 0000000..13b8409 --- /dev/null +++ b/src/Contexts/apiApp/Auth/domain/index.ts @@ -0,0 +1,7 @@ +export * from './User'; +export * from '../../../shared/domain/value-object/Email'; +export * from './UserRoles'; +export * from './Username'; +export * from './UserPatch'; + +export * from './UserRepository'; diff --git a/src/Contexts/shared/domain/errors/InvalidArgumentError.ts b/src/Contexts/shared/domain/errors/InvalidArgumentError.ts index 1b49f05..7e991fa 100644 --- a/src/Contexts/shared/domain/errors/InvalidArgumentError.ts +++ b/src/Contexts/shared/domain/errors/InvalidArgumentError.ts @@ -1 +1,6 @@ -export class InvalidArgumentError extends Error {} +export class InvalidArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidArgumentError'; + } +} diff --git a/src/Contexts/shared/domain/value-object/Email.ts b/src/Contexts/shared/domain/value-object/Email.ts new file mode 100644 index 0000000..dae5fbd --- /dev/null +++ b/src/Contexts/shared/domain/value-object/Email.ts @@ -0,0 +1,61 @@ +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; + +export class Email { + private readonly emailRegex = /^[a-zA-Z0-9._-]+@[a-z0-9.-]+\.[a-z]{2,6}$/; + private readonly minLength = 6; + private readonly maxLength = 255; + private readonly domainsBlacklist = [ + 'mailinator.com', + 'guerrillamail.com', + 'sharklasers.com' + ]; + readonly value: string; + + constructor(value: string) { + this.value = this.ensureIsValidEmail(value); + } + + private ensureLength(value: string): string { + if (value.length < this.minLength) { + throw new InvalidArgumentError( + `<${this.constructor.name}> must be at least ${this.minLength} characters long` + ); + } + + if (value.length > this.maxLength) { + throw new InvalidArgumentError( + `<${this.constructor.name}> must be less than ${this.maxLength} characters long` + ); + } + + return value; + } + + private ensureDomainsBlacklist(value: string): string { + const domain = value.split('@')[1]; + if (this.domainsBlacklist.includes(domain)) { + throw new InvalidArgumentError( + `<${this.constructor.name}> does not allow the domain <${value}>` + ); + } + return value; + } + + private ensureIsEmailAddress(value: string): string { + if (!this.emailRegex.test(value)) { + throw new InvalidArgumentError( + `<${this.constructor.name}> does not allow the value <${value}>` + ); + } + return value; + } + + private ensureIsValidEmail(value: string): string { + const trimmedValue = value.trim(); + this.ensureLength(trimmedValue); + this.ensureIsEmailAddress(trimmedValue); + this.ensureDomainsBlacklist(trimmedValue); + + return trimmedValue; + } +} diff --git a/src/Contexts/shared/domain/value-object/StringValueObject.ts b/src/Contexts/shared/domain/value-object/StringValueObject.ts index 7edb726..44f8589 100644 --- a/src/Contexts/shared/domain/value-object/StringValueObject.ts +++ b/src/Contexts/shared/domain/value-object/StringValueObject.ts @@ -1,7 +1,17 @@ +import { InvalidArgumentError } from '../errors/InvalidArgumentError'; + export class StringValueObject { readonly value: string; constructor(value: string) { - this.value = value; + this.value = this.ensureIsValidValue(value); + } + + private ensureIsValidValue(value: string): string { + if (typeof value !== 'string') { + throw new InvalidArgumentError('Invalid value'); + } + + return value.trim(); } } diff --git a/src/Contexts/shared/domain/value-object/Uuid.ts b/src/Contexts/shared/domain/value-object/Uuid.ts index f0eb222..9d4fd63 100644 --- a/src/Contexts/shared/domain/value-object/Uuid.ts +++ b/src/Contexts/shared/domain/value-object/Uuid.ts @@ -1,4 +1,6 @@ import validate from 'uuid-validate'; +import { v4 as uuidv4 } from 'uuid'; + import { InvalidArgumentError } from '../errors/InvalidArgumentError'; export class Uuid { @@ -13,6 +15,10 @@ export class Uuid { return this.value; } + static random(): Uuid { + return new Uuid(uuidv4()); + } + private ensureIsValidUuid(id: string): void { if (!validate(id)) { throw new InvalidArgumentError( diff --git a/tests/Contexts/apiApp/Auth/domain/User.test.ts b/tests/Contexts/apiApp/Auth/domain/User.test.ts new file mode 100644 index 0000000..dc2d2b9 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/User.test.ts @@ -0,0 +1,49 @@ +import { User } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { UserMother } from './mothers/UserMother'; + +describe('User', () => { + it('should create a valid user', () => { + const user = UserMother.create(); + + expect(user).toBeInstanceOf(User); + expect(user.id).toBeDefined(); + expect(user.email).toBeDefined(); + expect(user.username).toBeDefined(); + expect(user.password).toBeDefined(); + expect(user.emailValidated).toBeDefined(); + expect(user.roles).toBeDefined(); + }); + + it('should return primitives from user', () => { + const user = UserMother.random(); + + const primitives = user.toPrimitives(); + + expect(primitives).toMatchObject({ + id: expect.any(String), + email: expect.any(String), + username: expect.any(String), + password: expect.any(String), + emailValidated: expect.any(Boolean), + roles: expect.any(Array) + }); + }); + + it('should create a valid user from primitives', () => { + const user = UserMother.random(); + + const userFromPrimitives = User.fromPrimitives( + user.toPrimitives() as { + id: string; + email: string; + username: string; + password: string; + emailValidated: boolean; + roles: string[]; + } + ); + + expect(userFromPrimitives).toBeInstanceOf(User); + expect(userFromPrimitives).toEqual(user); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/domain/UserName.test.ts b/tests/Contexts/apiApp/Auth/domain/UserName.test.ts new file mode 100644 index 0000000..3f2702e --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/UserName.test.ts @@ -0,0 +1,18 @@ +import { Username } from '../../../../../src/Contexts/apiApp/Auth/domain/Username'; +import { random } from '../../../fixtures/shared'; + +describe('UserName', () => { + it('should throw an error if user username is more than 20 chars long', () => { + const invalidUsername = random.word({ min: 21, max: 255 }); + expect(() => new Username(invalidUsername)).toThrowError( + ' must be less than 20 characters long' + ); + }); + + it('should throw an error if user username is less than 4 chars long', () => { + const invalidUsername = random.word({ min: 1, max: 3 }); + expect(() => new Username(invalidUsername)).toThrowError( + ' must be at least 4 characters long' + ); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts b/tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts new file mode 100644 index 0000000..7cd04de --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/UserRoles.test.ts @@ -0,0 +1,17 @@ +import { UserRoles } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../../fixtures/shared'; +import { UserRolesMother } from './mothers/UserRolesMother'; + +describe('UserRoles', () => { + it('should create a valid user roles', () => { + const userRoles = UserRolesMother.random(); + expect(userRoles).toBeInstanceOf(UserRoles); + }); + + it('should throw an error if user roles are invalid', () => { + const roles = [random.word()]; + expect(() => UserRolesMother.create(roles)).toThrowError( + ` does not allow the value <${roles}>` + ); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts b/tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts new file mode 100644 index 0000000..6a24798 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/mothers/UserMother.ts @@ -0,0 +1,63 @@ +import { RegisterUserRequest } from '../../../../../../src/Contexts/apiApp/Auth/application/RegisterUserRequest'; +import { + User, + Email, + UserRoles, + Username, + UserPatch +} from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../../../../src/Contexts/shared/domain/value-object/Uuid'; +import { random } from '../../../../fixtures/shared'; +import { EmailMother } from '../../../../shared/domain/mothers/EmailMother'; +import { UserRolesMother } from './UserRolesMother'; + +export class UserMother { + static create({ + id, + email, + username, + password, + emailValidated, + roles + }: { + id?: Uuid; + email?: Email; + username?: Username; + password?: StringValueObject; + emailValidated?: boolean; + roles?: UserRoles; + } = {}): User { + return new User({ + id: id ?? Uuid.random(), + email: email ?? EmailMother.random(), + username: username ?? new Username(random.word({ min: 4, max: 20 })), + password: password ?? new StringValueObject(random.word()), + emailValidated: emailValidated ?? random.boolean(), + roles: + roles ?? + UserRolesMother.create([`${random.arrayElement(['admin', 'user'])}`]) + }); + } + + static from(command: RegisterUserRequest): User { + return this.create({ + email: new Email(command.email), + username: new Username(command.username), + password: new StringValueObject(command.password) + }); + } + + static random(): User { + return this.create(); + } + + static randomPatch(id: string): UserPatch { + return new UserPatch({ + id: new Uuid(id), + password: new StringValueObject(random.word()), + emailValidated: random.boolean(), + roles: UserRolesMother.random() + }); + } +} diff --git a/tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts b/tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts new file mode 100644 index 0000000..9069a78 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/mothers/UserRolesMother.ts @@ -0,0 +1,12 @@ +import { UserRoles } from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../../../fixtures/shared'; + +export class UserRolesMother { + static create(value: string[]) { + return new UserRoles(value); + } + + static random() { + return this.create([`${random.arrayElement(['admin', 'user'])}`]); + } +} diff --git a/tests/Contexts/apiApp/Auth/domain/mothers/index.ts b/tests/Contexts/apiApp/Auth/domain/mothers/index.ts new file mode 100644 index 0000000..c56e656 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/domain/mothers/index.ts @@ -0,0 +1,2 @@ +export * from './UserMother'; +export * from './UserRolesMother'; diff --git a/tests/Contexts/fixtures/shared/random.ts b/tests/Contexts/fixtures/shared/random.ts index f7874a4..688ca36 100644 --- a/tests/Contexts/fixtures/shared/random.ts +++ b/tests/Contexts/fixtures/shared/random.ts @@ -43,7 +43,7 @@ export class Random { max = 256 }: { min?: number; max?: number } = {}): string { return this.chance.word({ - length: Math.floor(Math.random() * (max - min + 1)) + min + length: Math.floor(Math.random() * (max - min + 1) + min) }); } @@ -66,4 +66,8 @@ export class Random { return `${prefix}-${group1}-${group2}-${group3}-${group4}`; } + + public email(): string { + return this.chance.email(); + } } diff --git a/tests/Contexts/shared/domain/Email.test.ts b/tests/Contexts/shared/domain/Email.test.ts new file mode 100644 index 0000000..09a526f --- /dev/null +++ b/tests/Contexts/shared/domain/Email.test.ts @@ -0,0 +1,42 @@ +import { Email } from '../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../fixtures/shared'; +import { EmailMother } from './mothers/EmailMother'; + +describe('Email', () => { + it('should create a valid email', () => { + const email = EmailMother.random(); + expect(email).toBeInstanceOf(Email); + }); + + it('should throw an error if email is more than 255 chars long', () => { + const invalidEmail = random.word({ min: 256, max: 512 }); + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ' must be less than 255 characters long' + ); + }); + + it('should throw an error if email is less than 6 chars long', () => { + const invalidEmail = random.word({ min: 1, max: 3 }); + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ' must be at least 6 characters long' + ); + }); + + it('should throw an error if email is not an email address', () => { + const invalidEmail = random.word({ min: 6, max: 255 }); + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ` does not allow the value <${invalidEmail}>` + ); + }); + + it('should throw an error if email domain is in the blackList', () => { + const invalidEmail = `test@${random.arrayElement([ + 'mailinator.com', + 'guerrillamail.com', + 'sharklasers.com' + ])}`; + expect(() => EmailMother.create(invalidEmail)).toThrowError( + ` does not allow the domain <${invalidEmail}>` + ); + }); +}); diff --git a/tests/Contexts/shared/domain/mothers/EmailMother.ts b/tests/Contexts/shared/domain/mothers/EmailMother.ts new file mode 100644 index 0000000..03a75ec --- /dev/null +++ b/tests/Contexts/shared/domain/mothers/EmailMother.ts @@ -0,0 +1,12 @@ +import { Email } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { random } from '../../../fixtures/shared'; + +export class EmailMother { + static create(value: string) { + return new Email(value); + } + + static random() { + return this.create(random.email()); + } +} From 284b7384557074b8cf813f2dceabaaedf05c9a72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Thu, 14 Dec 2023 01:57:31 +0100 Subject: [PATCH 3/7] [GH-38] feat(User): add MongoAuthRepository --- .../persistence/MongoAuthRepository.ts | 57 +++++++++++++++++ .../Auth/application.yaml | 31 ++++++++++ .../dependency-injection/application.yaml | 1 + .../persistence/MongoAuthRepository.test.ts | 62 +++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts create mode 100644 src/apps/apiApp/dependency-injection/Auth/application.yaml create mode 100644 tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts diff --git a/src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts b/src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts new file mode 100644 index 0000000..2121bdf --- /dev/null +++ b/src/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.ts @@ -0,0 +1,57 @@ +import { MongoClient } from 'mongodb'; +import { Nullable } from '../../../../shared/domain/Nullable'; +import { MongoRepository } from '../../../../shared/infrastructure/persistence/mongo/MongoRepository'; +import { User, UserRepository } from '../../domain'; +import { UserPatch } from '../../domain/UserPatch'; + +export interface AuthDocument { + _id: string; + email: string; + username: string; + password: string; + emailValidated: boolean; + roles: string[]; +} + +export class MongoAuthRepository + extends MongoRepository + implements UserRepository +{ + constructor(client: Promise) { + super(client); + this.createUniqueIndex(); + } + protected collectionName(): string { + return 'users'; + } + + async save(user: User): Promise { + return this.persist(user.id.value, user); + } + + async update(user: UserPatch): Promise { + return this.patch(user); + } + + async search(email: string): Promise> { + const collection = await this.collection(); + const document = await collection.findOne({ email }); + + return document + ? User.fromPrimitives({ + id: document._id, + email: document.email, + username: document.username, + password: document.password, + emailValidated: document.emailValidated, + roles: document.roles + }) + : null; + } + + private async createUniqueIndex(): Promise { + const collection = await this.collection(); + await collection.createIndex({ email: 1 }, { unique: true }); + await collection.createIndex({ username: 1 }, { unique: true }); + } +} diff --git a/src/apps/apiApp/dependency-injection/Auth/application.yaml b/src/apps/apiApp/dependency-injection/Auth/application.yaml new file mode 100644 index 0000000..c79a78e --- /dev/null +++ b/src/apps/apiApp/dependency-injection/Auth/application.yaml @@ -0,0 +1,31 @@ +services: + apiApp.MongoConfig: + factory: + class: ../../../../Contexts/shared/infrastructure/persistence/mongo/MongoConfigFactory + method: 'createConfig' + + apiApp.MongoClient: + factory: + class: ../../../../Contexts/shared/infrastructure/persistence/mongo/MongoClientFactory + method: 'createClient' + arguments: ['apiApp', '@apiApp.MongoConfig'] + + # plugin.Encrypter: + # class: ../../../../Contexts/shared/plugins/CryptAdapter + # arguments: [] + + apiApp.Auth.domain.AuthRepository: + class: ../../../../Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository + arguments: ['@apiApp.MongoClient'] + + # apiApp.Auth.application.LoginUser: + # class: ../../../../Contexts/apiApp/Auth/application/LoginUser + # arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + + # apiApp.Auth.application.RegisterUser: + # class: ../../../../Contexts/apiApp/Auth/application/RegisterUser + # arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + + # apiApp.Auth.application.ValidateMail: + # class: ../../../../Contexts/apiApp/Auth/application/ValidateMail + # arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] diff --git a/src/apps/apiApp/dependency-injection/application.yaml b/src/apps/apiApp/dependency-injection/application.yaml index 9ae361e..d93cafa 100644 --- a/src/apps/apiApp/dependency-injection/application.yaml +++ b/src/apps/apiApp/dependency-injection/application.yaml @@ -1,3 +1,4 @@ imports: - { resource: ./apps/application.yaml } + - { resource: ./Auth/application.yaml } - { resource: ./Books/application.yaml } diff --git a/tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts b/tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts new file mode 100644 index 0000000..72cecea --- /dev/null +++ b/tests/Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository.test.ts @@ -0,0 +1,62 @@ +import container from '../../../../../../src/apps/apiApp/dependency-injection'; +import { UserRepository } from '../../../../../../src/Contexts/apiApp/Auth/domain/UserRepository'; + +import { EnvironmentArranger } from '../../../../shared/infrastructure/arranger/EnvironmentArranger'; +import { UserMother } from '../../domain/mothers'; + +const repository: UserRepository = container.get( + 'apiApp.Auth.domain.AuthRepository' +); + +const environmentArranger: Promise = container.get( + 'apiApp.EnvironmentArranger' +); + +describe('MongoAuthRepository', () => { + beforeEach(async () => { + await (await environmentArranger).arrange(); + }); + + afterAll(async () => { + await (await environmentArranger).arrange(); + await (await environmentArranger).close(); + }); + + describe('save', () => { + it('should save a user', async () => { + const user = UserMother.random(); + + await repository.save(user); + }); + }); + + describe('update', () => { + it('should update an existing user', async () => { + const user = UserMother.random(); + await repository.save(user); + const userPatch = UserMother.randomPatch(user.id.value); + + await repository.update(userPatch); + + expect(await repository.search(user.email.value)).toMatchObject( + userPatch + ); + }); + }); + + describe('search', () => { + it('should return an existing user', async () => { + const user = UserMother.random(); + + await repository.save(user); + + expect(await repository.search(user.email.value)).toMatchObject(user); + }); + + it('should not return a non existing user', async () => { + expect(await repository.search(UserMother.random().email.value)).toBe( + null + ); + }); + }); +}); From af1902231cf16084923a48facf30ae75cb875f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Thu, 14 Dec 2023 01:57:31 +0100 Subject: [PATCH 4/7] [GH-38] feat(User): add use cases --- .../apiApp/Auth/application/LoginUser.ts | 45 ++++++++++++ .../apiApp/Auth/application/RegisterUser.ts | 42 +++++++++++ .../apiApp/Auth/application/ValidateMail.ts | 40 +++++++++++ src/Contexts/apiApp/Auth/application/index.ts | 5 ++ .../shared/domain/errors/AuthError.ts | 6 ++ .../apiApp/Auth/__mocks__/CryptAdapterMock.ts | 71 +++++++++++++++++++ .../Auth/__mocks__/UserRepositoryMock.ts | 54 ++++++++++++++ .../apiApp/Auth/application/LoginUser.test.ts | 48 +++++++++++++ .../Auth/application/RegisterUser.test.ts | 49 +++++++++++++ .../Auth/application/ValidateMail.test.ts | 44 ++++++++++++ .../mothers/LoginUserRequestMother.ts | 20 ++++++ .../mothers/RegisterUserRequestMother.ts | 30 ++++++++ 12 files changed, 454 insertions(+) create mode 100644 src/Contexts/apiApp/Auth/application/LoginUser.ts create mode 100644 src/Contexts/apiApp/Auth/application/RegisterUser.ts create mode 100644 src/Contexts/apiApp/Auth/application/ValidateMail.ts create mode 100644 src/Contexts/apiApp/Auth/application/index.ts create mode 100644 src/Contexts/shared/domain/errors/AuthError.ts create mode 100644 tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts create mode 100644 tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts create mode 100644 tests/Contexts/apiApp/Auth/application/LoginUser.test.ts create mode 100644 tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts create mode 100644 tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts create mode 100644 tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts create mode 100644 tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts diff --git a/src/Contexts/apiApp/Auth/application/LoginUser.ts b/src/Contexts/apiApp/Auth/application/LoginUser.ts new file mode 100644 index 0000000..f5007df --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/LoginUser.ts @@ -0,0 +1,45 @@ +import { EncrypterTool } from '../../../shared/plugins/EncrypterTool'; +import { Nullable } from '../../../shared/domain/Nullable'; +import { AuthError } from '../../../shared/domain/errors/AuthError'; +import { buildLogger } from '../../../shared/plugins/logger.plugin'; + +import { UserRepository } from '../domain'; + +import { LoginUserRequest } from './LoginUserRequest'; + +const logger = buildLogger('loginUser'); + +export class LoginUser { + private readonly repository: UserRepository; + private readonly encrypter: EncrypterTool; + + constructor(repository: UserRepository, encrypter: EncrypterTool) { + this.repository = repository; + this.encrypter = encrypter; + } + + async run({ email, password }: LoginUserRequest): Promise> { + const storedUser = await this.repository.search(email); + if (!storedUser) { + throw new AuthError('Invalid credentials'); + } + + const success = await this.encrypter.compare( + password, + storedUser.password.value + ); + if (!success) { + throw new AuthError('Invalid credentials'); + } + + const token = await this.encrypter.generateToken({ + id: storedUser.id.value, + email: storedUser.email.value, + username: storedUser.username.value, + roles: storedUser.roles.value + }); + + logger.info(`User <${storedUser.username.value}> logged in`); + return token; + } +} diff --git a/src/Contexts/apiApp/Auth/application/RegisterUser.ts b/src/Contexts/apiApp/Auth/application/RegisterUser.ts new file mode 100644 index 0000000..74b28fd --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/RegisterUser.ts @@ -0,0 +1,42 @@ +import { EncrypterTool } from '../../../shared/plugins/EncrypterTool'; +import { InvalidArgumentError } from '../../../shared/domain/errors/InvalidArgumentError'; +import { StringValueObject } from '../../../shared/domain/value-object/StringValueObject'; +import { Uuid } from '../../../shared/domain/value-object/Uuid'; +import { buildLogger } from '../../../shared/plugins/logger.plugin'; + +import { User, Email, UserRepository, UserRoles, Username } from '../domain'; + +import { RegisterUserRequest } from './RegisterUserRequest'; + +const logger = buildLogger('registerUser'); + +export class RegisterUser { + private readonly repository: UserRepository; + private readonly encrypter: EncrypterTool; + + constructor(repository: UserRepository, encrypter: EncrypterTool) { + this.repository = repository; + this.encrypter = encrypter; + } + + async run({ password, username, email }: RegisterUserRequest): Promise { + const storedUser = await this.repository.search(email); + if (storedUser) { + throw new InvalidArgumentError(`User <${email}> already exists`); + } + + const encryptedPassword = this.encrypter.hash(password); + + const user = new User({ + id: Uuid.random(), + email: new Email(email), + username: new Username(username), + password: new StringValueObject(encryptedPassword), + emailValidated: false, + roles: new UserRoles(['user']) + }); + + await this.repository.save(user); + logger.info(`User <${user.username.value}> registered`); + } +} diff --git a/src/Contexts/apiApp/Auth/application/ValidateMail.ts b/src/Contexts/apiApp/Auth/application/ValidateMail.ts new file mode 100644 index 0000000..7c0d81a --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/ValidateMail.ts @@ -0,0 +1,40 @@ +import { EncrypterTool } from '../../../shared/plugins/EncrypterTool'; +import { UserRepository } from '../domain'; +import { UserPatch } from '../domain/UserPatch'; +import { buildLogger } from '../../../shared/plugins/logger.plugin'; +import { Nullable } from '../../../shared/domain/Nullable'; +import { AuthError } from '../../../shared/domain/errors/AuthError'; + +const logger = buildLogger('validateMail'); + +export class ValidateMail { + private readonly repository: UserRepository; + private readonly encrypter: EncrypterTool; + + constructor(repository: UserRepository, encrypter: EncrypterTool) { + this.repository = repository; + this.encrypter = encrypter; + } + + async run({ token }: { token: string }): Promise> { + const validToken = await this.encrypter.verifyToken(token); + if (!validToken) { + throw new AuthError('Invalid token'); + } + const { email } = validToken as unknown as { email: string }; + + const storedUser = await this.repository.search(email); + if (!storedUser) { + throw new AuthError('Invalid token'); + } + const userToPatch = UserPatch.fromPrimitives({ + id: storedUser.id.value, + emailValidated: true + }); + + await this.repository.update(userToPatch); + logger.info(`User <${storedUser.username.value}> validated email`); + + return this.encrypter.refreshToken(token); + } +} diff --git a/src/Contexts/apiApp/Auth/application/index.ts b/src/Contexts/apiApp/Auth/application/index.ts new file mode 100644 index 0000000..2802fd2 --- /dev/null +++ b/src/Contexts/apiApp/Auth/application/index.ts @@ -0,0 +1,5 @@ +export * from './LoginUser'; +export * from './RegisterUser'; +export * from './ValidateMail'; + +export * from './LoginUserRequest'; diff --git a/src/Contexts/shared/domain/errors/AuthError.ts b/src/Contexts/shared/domain/errors/AuthError.ts new file mode 100644 index 0000000..c4ec6f2 --- /dev/null +++ b/src/Contexts/shared/domain/errors/AuthError.ts @@ -0,0 +1,6 @@ +export class AuthError extends Error { + constructor(message: string) { + super(message); + this.name = 'AuthError'; + } +} diff --git a/tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts b/tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts new file mode 100644 index 0000000..30e5e1a --- /dev/null +++ b/tests/Contexts/apiApp/Auth/__mocks__/CryptAdapterMock.ts @@ -0,0 +1,71 @@ +import { CryptAdapter } from '../../../../../src/Contexts/shared/plugins/CryptAdapter'; +import { Nullable } from '../../../../../src/Contexts/shared/domain/Nullable'; +import { random } from '../../../fixtures/shared'; +import { EmailMother } from '../../../shared/domain/mothers/EmailMother'; + +export class CryptAdapterMock implements CryptAdapter { + private hashMock: jest.Mock; + private compareMock: jest.Mock; + private generateTokenMock: jest.Mock; + private verifyTokenMock: jest.Mock; + private refreshTokenMock: jest.Mock; + + constructor( + { login, token }: { login?: boolean; token?: boolean } = { + login: false, + token: false + } + ) { + this.hashMock = jest.fn().mockReturnValue('encryptedPassword'); + this.compareMock = jest.fn().mockReturnValue(login); + this.generateTokenMock = jest.fn().mockReturnValue(random.word()); + this.verifyTokenMock = token + ? jest.fn().mockReturnValue({ email: EmailMother.random().value }) + : jest.fn().mockReturnValue(null); + this.refreshTokenMock = jest.fn().mockReturnValue(random.word()); + } + + hash(password: string): string { + return this.hashMock(password); + } + + assertHashHasBeenCalledWith(expected: string): void { + expect(this.hashMock).toHaveBeenCalledWith(expected); + } + + compare(value: string, encryptedValue: string): boolean { + return this.compareMock(value, encryptedValue); + } + + assertCompareHasBeenCalledWith( + expectedValue: string, + expectedEncryptedValue: string + ): void { + expect(this.compareMock).toHaveBeenCalledWith( + expectedValue, + expectedEncryptedValue + ); + } + + generateToken( + payload: Record, + duration?: string + ): Promise> { + return this.generateTokenMock(payload, duration); + } + verifyToken(token: string): Promise>> { + return this.verifyTokenMock(token); + } + + assertVerifyTokenHasBeenCalledWith(expected: string): void { + expect(this.verifyTokenMock).toHaveBeenCalledWith(expected); + } + + refreshToken(token: string): Promise> { + return this.refreshTokenMock(token); + } + + assertRefreshTokenHasBeenCalledWith(expected: string): void { + expect(this.refreshTokenMock).toHaveBeenCalledWith(expected); + } +} diff --git a/tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts b/tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts new file mode 100644 index 0000000..8a79e00 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/__mocks__/UserRepositoryMock.ts @@ -0,0 +1,54 @@ +import { Email, User } from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { UserPatch } from '../../../../../src/Contexts/apiApp/Auth/domain/UserPatch'; +import { UserRepository } from '../../../../../src/Contexts/apiApp/Auth/domain/UserRepository'; +import { Nullable } from '../../../../../src/Contexts/shared/domain/Nullable'; +import { StringValueObject } from '../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { UserMother } from '../domain/mothers/UserMother'; + +export class UserRepositoryMock implements UserRepository { + private saveMock: jest.Mock; + private updateMock: jest.Mock; + private findMock: jest.Mock; + private password: StringValueObject = new StringValueObject( + '$2a$12$mZgfH4D7z4dZcZHDKyogqOOnEWS6XHLdczPJktzD88djpvlr3Bq1C' + ); + + constructor({ exists }: { exists: boolean }) { + if (exists) { + this.findMock = jest.fn().mockImplementation((email: string) => { + return UserMother.create({ + email: new Email(email), + password: this.password + }); + }); + } else { + this.findMock = jest.fn().mockReturnValue(null); + } + this.saveMock = jest.fn(); + this.updateMock = jest.fn(); + } + + async save(user: User): Promise { + this.saveMock(user); + } + + assertSaveHasBeenCalledWith(expected: User): void { + expect(this.saveMock).toHaveBeenCalledWith(expected); + } + + async update(user: UserPatch): Promise { + this.updateMock(user); + } + + assertUpdateHasBeenCalledWith(expected: UserPatch): void { + expect(this.updateMock).toHaveBeenCalledWith(expected); + } + + async search(email: string): Promise> { + return this.findMock(email); + } + + assertSearchHasBeenCalledWith(expected: string): void { + expect(this.findMock).toHaveBeenCalledWith(expected); + } +} diff --git a/tests/Contexts/apiApp/Auth/application/LoginUser.test.ts b/tests/Contexts/apiApp/Auth/application/LoginUser.test.ts new file mode 100644 index 0000000..2564fe0 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/LoginUser.test.ts @@ -0,0 +1,48 @@ +import { LoginUser } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { CryptAdapterMock } from '../__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../__mocks__/UserRepositoryMock'; +import { LoginUserRequestMother } from './mothers/LoginUserRequestMother'; + +describe('LoginUser', () => { + let encrypter: CryptAdapterMock; + let repository: UserRepositoryMock; + let loginUser: LoginUser; + + beforeEach(() => { + encrypter = new CryptAdapterMock({ login: true }); + repository = new UserRepositoryMock({ exists: true }); + loginUser = new LoginUser(repository, encrypter); + }); + + it('should login a valid user', async () => { + const request = LoginUserRequestMother.random(); + + await loginUser.run(request); + + repository.assertSearchHasBeenCalledWith(request.email); + encrypter.assertCompareHasBeenCalledWith( + request.password, + expect.any(String) + ); + }); + + it('should throw an error when the user does not exist', async () => { + repository = new UserRepositoryMock({ exists: false }); + loginUser = new LoginUser(repository, encrypter); + const request = LoginUserRequestMother.random(); + + expect(async () => { + await loginUser.run(request); + }).rejects.toThrowError(`Invalid credentials`); + }); + + it('should throw an error when the password is invalid', async () => { + encrypter = new CryptAdapterMock({ login: false }); + loginUser = new LoginUser(repository, encrypter); + const request = LoginUserRequestMother.random(); + + expect(async () => { + await loginUser.run(request); + }).rejects.toThrowError(`Invalid credentials`); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts b/tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts new file mode 100644 index 0000000..4221870 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/RegisterUser.test.ts @@ -0,0 +1,49 @@ +import { RegisterUser } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { + Email, + UserRoles, + Username +} from '../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { CryptAdapterMock } from '../__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../__mocks__/UserRepositoryMock'; +import { RegisterUserRequestMother } from './mothers/RegisterUserRequestMother'; + +describe('RegisterUser', () => { + let encrypter: CryptAdapterMock; + let repository: UserRepositoryMock; + let registerUser: RegisterUser; + + beforeEach(() => { + encrypter = new CryptAdapterMock({ login: false }); + repository = new UserRepositoryMock({ exists: false }); + registerUser = new RegisterUser(repository, encrypter); + }); + + it('should register a valid user', async () => { + const request = RegisterUserRequestMother.random(); + + await registerUser.run(request); + + repository.assertSearchHasBeenCalledWith(request.email); + repository.assertSaveHasBeenCalledWith( + expect.objectContaining({ + email: expect.any(Email), + username: expect.any(Username), + password: expect.any(StringValueObject), + emailValidated: expect.any(Boolean), + roles: expect.any(UserRoles) + }) + ); + }); + + it('should throw an error when the user already exists', async () => { + const request = RegisterUserRequestMother.random(); + repository = new UserRepositoryMock({ exists: true }); + registerUser = new RegisterUser(repository, encrypter); + + expect(async () => { + await registerUser.run(request); + }).rejects.toThrowError(`User <${request.email}> already exists`); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts b/tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts new file mode 100644 index 0000000..3135023 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/ValidateMail.test.ts @@ -0,0 +1,44 @@ +import { ValidateMail } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { UserPatch } from '../../../../../src/Contexts/apiApp/Auth/domain/UserPatch'; +import { random } from '../../../fixtures/shared'; +import { CryptAdapterMock } from '../__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../__mocks__/UserRepositoryMock'; + +describe('ValidateMail', () => { + let encrypter: CryptAdapterMock; + let repository: UserRepositoryMock; + let service: ValidateMail; + + beforeEach(() => { + encrypter = new CryptAdapterMock({ token: true }); + repository = new UserRepositoryMock({ exists: true }); + service = new ValidateMail(repository, encrypter); + }); + + it('should validate the user', async () => { + const token = random.word({ min: 6, max: 255 }); + + await service.run({ token }); + + encrypter.assertVerifyTokenHasBeenCalledWith(token); + repository.assertSearchHasBeenCalledWith(expect.any(String)); + repository.assertUpdateHasBeenCalledWith(expect.any(UserPatch)); + encrypter.assertRefreshTokenHasBeenCalledWith(token); + }); + + it('should throw an error if the token is invalid', async () => { + encrypter = new CryptAdapterMock({ token: false }); + service = new ValidateMail(repository, encrypter); + const token = random.word({ min: 6, max: 255 }); + + await expect(service.run({ token })).rejects.toThrowError('Invalid token'); + }); + + it('should throw an error if the user is not found', async () => { + repository = new UserRepositoryMock({ exists: false }); + service = new ValidateMail(repository, encrypter); + const token = random.word({ min: 6, max: 255 }); + + await expect(service.run({ token })).rejects.toThrowError('Invalid token'); + }); +}); diff --git a/tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts b/tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts new file mode 100644 index 0000000..9b38593 --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother.ts @@ -0,0 +1,20 @@ +import { LoginUserRequest } from '../../../../../../src/Contexts/apiApp/Auth/application'; +import { Email } from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { EmailMother } from '../../../../shared/domain/mothers/EmailMother'; + +export class LoginUserRequestMother { + static create(email: Email, password: StringValueObject): LoginUserRequest { + return { + email: email.value, + password: password.value + }; + } + + static random(): LoginUserRequest { + return this.create( + EmailMother.random(), + new StringValueObject('%aD3f3s.0%') + ); + } +} diff --git a/tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts b/tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts new file mode 100644 index 0000000..828dddd --- /dev/null +++ b/tests/Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother.ts @@ -0,0 +1,30 @@ +import { RegisterUserRequest } from '../../../../../../src/Contexts/apiApp/Auth/application/RegisterUserRequest'; +import { + Email, + Username +} from '../../../../../../src/Contexts/apiApp/Auth/domain'; +import { StringValueObject } from '../../../../../../src/Contexts/shared/domain/value-object/StringValueObject'; +import { random } from '../../../../fixtures/shared'; +import { EmailMother } from '../../../../shared/domain/mothers/EmailMother'; + +export class RegisterUserRequestMother { + static create( + email: Email, + username: Username, + password: StringValueObject + ): RegisterUserRequest { + return { + email: email.value, + username: username.value, + password: password.value + }; + } + + static random(): RegisterUserRequest { + return this.create( + EmailMother.random(), + new Username(random.word({ min: 4, max: 20 })), + new StringValueObject('%aD3f3s.0%') + ); + } +} From 49ac910935933d4fb608b2967535ff0ec2d5c3e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Thu, 14 Dec 2023 01:57:32 +0100 Subject: [PATCH 5/7] [GH-38] feat(User): add controllers --- .../controllers/Auth/LoginController.ts | 24 +++++++ .../controllers/Auth/RegisterController.ts | 28 +++++++++ .../Auth/ValidateMailController.ts | 24 +++++++ src/apps/apiApp/controllers/Auth/index.ts | 3 + .../controllers/Auth/LoginController.test.ts | 56 +++++++++++++++++ .../Auth/RegisterController.test.ts | 62 +++++++++++++++++++ .../Auth/ValidateMailControllet.test.ts | 52 ++++++++++++++++ 7 files changed, 249 insertions(+) create mode 100644 src/apps/apiApp/controllers/Auth/LoginController.ts create mode 100644 src/apps/apiApp/controllers/Auth/RegisterController.ts create mode 100644 src/apps/apiApp/controllers/Auth/ValidateMailController.ts create mode 100644 src/apps/apiApp/controllers/Auth/index.ts create mode 100644 tests/apps/apiApp/controllers/Auth/LoginController.test.ts create mode 100644 tests/apps/apiApp/controllers/Auth/RegisterController.test.ts create mode 100644 tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts diff --git a/src/apps/apiApp/controllers/Auth/LoginController.ts b/src/apps/apiApp/controllers/Auth/LoginController.ts new file mode 100644 index 0000000..6bbf7b1 --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/LoginController.ts @@ -0,0 +1,24 @@ +import httpStatus from 'http-status'; +import { NextFunction, Request, Response } from 'express'; +import { Controller } from '../../shared/interfaces/Controller'; +import { LoginUser } from '../../../../Contexts/apiApp/Auth/application'; + +export class LoginController implements Controller { + constructor(protected login: LoginUser) {} + + async run(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, password } = req.body; + + const token = await this.login.run({ email, password }); + + res.status(this.status()).json({ token }); + } catch (error) { + next(error); + } + } + + protected status() { + return httpStatus.OK; + } +} diff --git a/src/apps/apiApp/controllers/Auth/RegisterController.ts b/src/apps/apiApp/controllers/Auth/RegisterController.ts new file mode 100644 index 0000000..4f89477 --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/RegisterController.ts @@ -0,0 +1,28 @@ +import httpStatus from 'http-status'; +import { NextFunction, Request, Response } from 'express'; +import { Controller } from '../../shared/interfaces/Controller'; +import { InvalidArgumentError } from '../../../../Contexts/shared/domain/errors/InvalidArgumentError'; +import { RegisterUser } from '../../../../Contexts/apiApp/Auth/application'; + +export class RegisterController implements Controller { + constructor(protected register: RegisterUser) {} + + async run(req: Request, res: Response, next: NextFunction): Promise { + try { + const { email, username, password, repeatPassword } = req.body; + if (password !== repeatPassword) { + throw new InvalidArgumentError('Passwords do not match'); + } + + await this.register.run({ email, password, username }); + + res.status(this.status()).send(); + } catch (error) { + next(error); + } + } + + protected status() { + return httpStatus.CREATED; + } +} diff --git a/src/apps/apiApp/controllers/Auth/ValidateMailController.ts b/src/apps/apiApp/controllers/Auth/ValidateMailController.ts new file mode 100644 index 0000000..7f63433 --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/ValidateMailController.ts @@ -0,0 +1,24 @@ +import httpStatus from 'http-status'; +import { NextFunction, Request, Response } from 'express'; +import { Controller } from '../../shared/interfaces/Controller'; +import { ValidateMail } from '../../../../Contexts/apiApp/Auth/application'; + +export class ValidateMailController implements Controller { + constructor(protected validateMail: ValidateMail) {} + + async run(req: Request, res: Response, next: NextFunction): Promise { + try { + const { token } = req.params; + + const newToken = await this.validateMail.run({ token }); + + res.status(this.status()).json({ token: newToken }); + } catch (error) { + next(error); + } + } + + protected status() { + return httpStatus.OK; + } +} diff --git a/src/apps/apiApp/controllers/Auth/index.ts b/src/apps/apiApp/controllers/Auth/index.ts new file mode 100644 index 0000000..c5486fd --- /dev/null +++ b/src/apps/apiApp/controllers/Auth/index.ts @@ -0,0 +1,3 @@ +export * from './LoginController'; +export * from './RegisterController'; +export * from './ValidateMailController'; diff --git a/tests/apps/apiApp/controllers/Auth/LoginController.test.ts b/tests/apps/apiApp/controllers/Auth/LoginController.test.ts new file mode 100644 index 0000000..8463265 --- /dev/null +++ b/tests/apps/apiApp/controllers/Auth/LoginController.test.ts @@ -0,0 +1,56 @@ +import { Request, Response } from 'express'; +import httpStatus from 'http-status'; +import { + LoginUser, + LoginUserRequest +} from '../../../../../src/Contexts/apiApp/Auth/application'; +import { LoginController } from '../../../../../src/apps/apiApp/controllers/Auth'; +import { UserRepositoryMock } from '../../../../Contexts/apiApp/Auth/__mocks__/UserRepositoryMock'; +import { CryptAdapterMock } from '../../../../Contexts/apiApp/Auth/__mocks__/CryptAdapterMock'; +import { LoginUserRequestMother } from '../../../../Contexts/apiApp/Auth/application/mothers/LoginUserRequestMother'; +import { AuthError } from '../../../../../src/Contexts/shared/domain/errors/AuthError'; + +describe('LoginController', () => { + let repository: UserRepositoryMock; + let encrypter: CryptAdapterMock; + let controller: LoginController; + let service: LoginUser; + let request: LoginUserRequest; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + const spyService = jest.spyOn(LoginUser.prototype, 'run'); + + beforeEach(() => { + repository = new UserRepositoryMock({ exists: true }); + encrypter = new CryptAdapterMock({ login: true, token: true }); + service = new LoginUser(repository, encrypter); + controller = new LoginController(service); + request = LoginUserRequestMother.random(); + req = { body: request }; + res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + next = jest.fn(); + }); + + describe('run', () => { + it('should login the user and send 200 status', async () => { + await controller.run(req as Request, res as Response, next); + + expect(spyService).toHaveBeenCalledWith(request); + expect(res.status).toHaveBeenCalledWith(httpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ token: expect.any(String) }); + }); + + it('should call next with the AuthError if login fails', async () => { + encrypter = new CryptAdapterMock({ login: false }); + service = new LoginUser(repository, encrypter); + controller = new LoginController(service); + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(AuthError)); + }); + }); +}); diff --git a/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts b/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts new file mode 100644 index 0000000..f69cabc --- /dev/null +++ b/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts @@ -0,0 +1,62 @@ +import httpStatus from 'http-status'; +import { Request, Response } from 'express'; +import { RegisterUser } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { RegisterUserRequest } from '../../../../../src/Contexts/apiApp/Auth/application/RegisterUserRequest'; +import { RegisterController } from '../../../../../src/apps/apiApp/controllers/Auth'; +import { CryptAdapterMock } from '../../../../Contexts/apiApp/Auth/__mocks__/CryptAdapterMock'; +import { UserRepositoryMock } from '../../../../Contexts/apiApp/Auth/__mocks__/UserRepositoryMock'; +import { RegisterUserRequestMother } from '../../../../Contexts/apiApp/Auth/application/mothers/RegisterUserRequestMother'; +import { InvalidArgumentError } from '../../../../../src/Contexts/shared/domain/errors/InvalidArgumentError'; + +describe('RegisterController', () => { + let repository: UserRepositoryMock; + let encrypter: CryptAdapterMock; + let controller: RegisterController; + let service: RegisterUser; + let request: RegisterUserRequest; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + const spyService = jest.spyOn(RegisterUser.prototype, 'run'); + + beforeEach(() => { + repository = new UserRepositoryMock({ exists: false }); + encrypter = new CryptAdapterMock({ token: true }); + service = new RegisterUser(repository, encrypter); + controller = new RegisterController(service); + request = RegisterUserRequestMother.random(); + req = { body: { ...request, repeatPassword: request.password } }; + res = { status: jest.fn().mockReturnThis(), send: jest.fn() }; + next = jest.fn(); + }); + + describe('run', () => { + it('should register the user and send 201 status', async () => { + await controller.run(req as Request, res as Response, next); + + expect(spyService).toHaveBeenCalledWith(request); + expect(res.status).toHaveBeenCalledWith(httpStatus.CREATED); + expect(res.send).toHaveBeenCalledWith(); + }); + + it("should fail if passwords don't match", async () => { + req = { body: { ...request, repeatPassword: 'differentPassword' } }; + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(InvalidArgumentError)); + }); + + it('should fail if user exists', async () => { + repository = new UserRepositoryMock({ exists: true }); + service = new RegisterUser(repository, encrypter); + controller = new RegisterController(service); + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(InvalidArgumentError)); + }); + }); +}); diff --git a/tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts b/tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts new file mode 100644 index 0000000..c81b547 --- /dev/null +++ b/tests/apps/apiApp/controllers/Auth/ValidateMailControllet.test.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import httpStatus from 'http-status'; +import { UserRepositoryMock } from '../../../../Contexts/apiApp/Auth/__mocks__/UserRepositoryMock'; +import { ValidateMailController } from '../../../../../src/apps/apiApp/controllers/Auth'; +import { ValidateMail } from '../../../../../src/Contexts/apiApp/Auth/application'; +import { CryptAdapterMock } from '../../../../Contexts/apiApp/Auth/__mocks__/CryptAdapterMock'; +import { AuthError } from '../../../../../src/Contexts/shared/domain/errors/AuthError'; + +describe('ValidateMailController', () => { + let repository: UserRepositoryMock; + let encrypter: CryptAdapterMock; + let controller: ValidateMailController; + let service: ValidateMail; + let request: { token: string }; + + let req: Partial; + let res: Partial; + let next: jest.Mock; + + const spyService = jest.spyOn(ValidateMail.prototype, 'run'); + + beforeEach(() => { + repository = new UserRepositoryMock({ exists: true }); + encrypter = new CryptAdapterMock({ login: true, token: true }); + service = new ValidateMail(repository, encrypter); + controller = new ValidateMailController(service); + request = { token: 'token' }; + req = { params: request }; + res = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + next = jest.fn(); + }); + + describe('run', () => { + it('should validate the mail and send 200 status', async () => { + await controller.run(req as Request, res as Response, next); + + expect(spyService).toHaveBeenCalledWith(request); + expect(res.status).toHaveBeenCalledWith(httpStatus.OK); + expect(res.json).toHaveBeenCalledWith({ token: expect.any(String) }); + }); + + it('should call next with the AuthError if login fails', async () => { + encrypter = new CryptAdapterMock({ login: false }); + service = new ValidateMail(repository, encrypter); + controller = new ValidateMailController(service); + + await controller.run(req as Request, res as Response, next); + + expect(next).toHaveBeenCalledWith(expect.any(AuthError)); + }); + }); +}); From 6336633e89ed9d65d1a0a846f9841391166eac65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Thu, 14 Dec 2023 01:57:32 +0100 Subject: [PATCH 6/7] [GH-38] feat(User): add routes --- .../controllers/Auth/RegisterController.ts | 9 +- .../Auth/application.yaml | 26 +++--- .../apps/application.yaml | 12 +++ src/apps/apiApp/routes/Auth/auth.routes.ts | 78 +++++++++++++++++ .../apiApp/routes/shared/apiErrorHandler.ts | 12 ++- .../apiApp/routes/shared/validateReqSchema.ts | 7 +- .../Auth/RegisterController.test.ts | 10 +-- .../apiApp/features/Auth/login-user.feature | 46 ++++++++++ .../features/Auth/register-user.feature | 86 +++++++++++++++++++ .../features/Auth/validate-token.feature | 8 ++ .../step_definitions/controller.steps.ts | 4 + 11 files changed, 267 insertions(+), 31 deletions(-) create mode 100644 src/apps/apiApp/routes/Auth/auth.routes.ts create mode 100644 tests/apps/apiApp/features/Auth/login-user.feature create mode 100644 tests/apps/apiApp/features/Auth/register-user.feature create mode 100644 tests/apps/apiApp/features/Auth/validate-token.feature diff --git a/src/apps/apiApp/controllers/Auth/RegisterController.ts b/src/apps/apiApp/controllers/Auth/RegisterController.ts index 4f89477..72e2d53 100644 --- a/src/apps/apiApp/controllers/Auth/RegisterController.ts +++ b/src/apps/apiApp/controllers/Auth/RegisterController.ts @@ -1,7 +1,6 @@ import httpStatus from 'http-status'; import { NextFunction, Request, Response } from 'express'; import { Controller } from '../../shared/interfaces/Controller'; -import { InvalidArgumentError } from '../../../../Contexts/shared/domain/errors/InvalidArgumentError'; import { RegisterUser } from '../../../../Contexts/apiApp/Auth/application'; export class RegisterController implements Controller { @@ -9,12 +8,8 @@ export class RegisterController implements Controller { async run(req: Request, res: Response, next: NextFunction): Promise { try { - const { email, username, password, repeatPassword } = req.body; - if (password !== repeatPassword) { - throw new InvalidArgumentError('Passwords do not match'); - } - - await this.register.run({ email, password, username }); + const { email, username, password } = req.body; + await this.register.run({ email, username, password }); res.status(this.status()).send(); } catch (error) { diff --git a/src/apps/apiApp/dependency-injection/Auth/application.yaml b/src/apps/apiApp/dependency-injection/Auth/application.yaml index c79a78e..b7488fe 100644 --- a/src/apps/apiApp/dependency-injection/Auth/application.yaml +++ b/src/apps/apiApp/dependency-injection/Auth/application.yaml @@ -10,22 +10,22 @@ services: method: 'createClient' arguments: ['apiApp', '@apiApp.MongoConfig'] - # plugin.Encrypter: - # class: ../../../../Contexts/shared/plugins/CryptAdapter - # arguments: [] - apiApp.Auth.domain.AuthRepository: class: ../../../../Contexts/apiApp/Auth/infrastructure/persistence/MongoAuthRepository arguments: ['@apiApp.MongoClient'] - # apiApp.Auth.application.LoginUser: - # class: ../../../../Contexts/apiApp/Auth/application/LoginUser - # arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + plugin.Encrypter: + class: ../../../../Contexts/shared/plugins/CryptAdapter + arguments: [] + + apiApp.Auth.application.LoginUser: + class: ../../../../Contexts/apiApp/Auth/application/LoginUser + arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] - # apiApp.Auth.application.RegisterUser: - # class: ../../../../Contexts/apiApp/Auth/application/RegisterUser - # arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + apiApp.Auth.application.RegisterUser: + class: ../../../../Contexts/apiApp/Auth/application/RegisterUser + arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] - # apiApp.Auth.application.ValidateMail: - # class: ../../../../Contexts/apiApp/Auth/application/ValidateMail - # arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] + apiApp.Auth.application.ValidateMail: + class: ../../../../Contexts/apiApp/Auth/application/ValidateMail + arguments: ['@apiApp.Auth.domain.AuthRepository', '@plugin.Encrypter'] diff --git a/src/apps/apiApp/dependency-injection/apps/application.yaml b/src/apps/apiApp/dependency-injection/apps/application.yaml index 5b95356..84bd665 100644 --- a/src/apps/apiApp/dependency-injection/apps/application.yaml +++ b/src/apps/apiApp/dependency-injection/apps/application.yaml @@ -3,6 +3,18 @@ services: class: ../../controllers/health/GetStatusController arguments: [] + Apps.apiApp.controllers.Auth.LoginController: + class: ../../controllers/Auth/LoginController + arguments: ['@apiApp.Auth.application.LoginUser'] + + Apps.apiApp.controllers.Auth.RegisterController: + class: ../../controllers/Auth/RegisterController + arguments: ['@apiApp.Auth.application.RegisterUser'] + + Apps.apiApp.controllers.Auth.ValidateMailController: + class: ../../controllers/Auth/ValidateMailController + arguments: ['@apiApp.Auth.application.ValidateMail'] + Apps.apiApp.controllers.Books.PostBookController: class: ../../controllers/Books/PostBookController arguments: ['@apiApp.Books.application.BookCreator'] diff --git a/src/apps/apiApp/routes/Auth/auth.routes.ts b/src/apps/apiApp/routes/Auth/auth.routes.ts new file mode 100644 index 0000000..7a5ee64 --- /dev/null +++ b/src/apps/apiApp/routes/Auth/auth.routes.ts @@ -0,0 +1,78 @@ +import { NextFunction, Request, Response, Router } from 'express'; +import { body, check, checkExact, param } from 'express-validator'; + +import container from '../../dependency-injection'; + +import { validateBody, validateReqSchema } from '../shared'; +import { + LoginController, + RegisterController, + ValidateMailController +} from '../../controllers/Auth'; + +const prefix = '/api/v1/Auth'; + +export const register = (router: Router) => { + const loginReqSchema = [ + body('email').exists().isEmail(), + body('password').exists().isString(), + checkExact() + ]; + + const registerReqSchema = [ + body('email').exists().isEmail(), + body('username').exists().isString(), + body('password').exists().isStrongPassword(), + body('repeatPassword').exists().isStrongPassword(), + check('repeatPassword', 'Passwords do not match').custom( + (value: string, { req }) => value === req.body.password + ), + checkExact() + ]; + + const validateMailReqSchema = [ + param('token').exists().isString(), + checkExact() + ]; + + const loginController: LoginController = container.get( + 'Apps.apiApp.controllers.Auth.LoginController' + ); + + const registerController: RegisterController = container.get( + 'Apps.apiApp.controllers.Auth.RegisterController' + ); + + const validateMailController: ValidateMailController = container.get( + 'Apps.apiApp.controllers.Auth.ValidateMailController' + ); + + router.post( + `${prefix}/login`, + validateBody, + loginReqSchema, + validateReqSchema, + (req: Request, res: Response, next: NextFunction) => { + loginController.run(req, res, next); + } + ); + + router.post( + `${prefix}/register`, + validateBody, + registerReqSchema, + validateReqSchema, + (req: Request, res: Response, next: NextFunction) => { + registerController.run(req, res, next); + } + ); + + router.get( + `${prefix}/validate/:token`, + validateMailReqSchema, + validateReqSchema, + (req: Request, res: Response, next: NextFunction) => { + validateMailController.run(req, res, next); + } + ); +}; diff --git a/src/apps/apiApp/routes/shared/apiErrorHandler.ts b/src/apps/apiApp/routes/shared/apiErrorHandler.ts index 8f7e9c0..23a0850 100644 --- a/src/apps/apiApp/routes/shared/apiErrorHandler.ts +++ b/src/apps/apiApp/routes/shared/apiErrorHandler.ts @@ -4,6 +4,7 @@ import { NotFoundError } from '../../../../Contexts/shared/domain/errors/NotFoun import { InvalidArgumentError } from '../../../../Contexts/shared/domain/errors/InvalidArgumentError'; import { buildLogger } from '../../../../Contexts/shared/plugins/logger.plugin'; +import { AuthError } from '../../../../Contexts/shared/domain/errors/AuthError'; const logger = buildLogger('apiErrorHandler'); @@ -16,7 +17,11 @@ export const apiErrorHandler = ( let statusCode; let message = err.message; + // TODO: to avoid case explosion, we could use an custom error Factory, GH issue #121 switch (true) { + case err instanceof AuthError: + statusCode = httpStatus.UNAUTHORIZED; + break; case err instanceof NotFoundError: statusCode = httpStatus.NOT_FOUND; break; @@ -28,6 +33,11 @@ export const apiErrorHandler = ( message = 'Internal Server Error'; } - logger.error(message); + const stack = + statusCode === httpStatus.INTERNAL_SERVER_ERROR + ? `Stack: ${err.stack}` + : ''; + + logger.error(`Error: ${err.message}. ${stack}`); res.status(statusCode).json({ message }); }; diff --git a/src/apps/apiApp/routes/shared/validateReqSchema.ts b/src/apps/apiApp/routes/shared/validateReqSchema.ts index 43cc846..63facfe 100644 --- a/src/apps/apiApp/routes/shared/validateReqSchema.ts +++ b/src/apps/apiApp/routes/shared/validateReqSchema.ts @@ -28,7 +28,12 @@ export const validateReqSchema = ( error: FieldValidationError ): ValidationErrorInfo | null => { const errorInfoKey = error.path; - const errorInfoValue = `${error.msg} at ${error.location}. Value: ${error.value}`; + const hiddenFields = ['password', 'repeatPassword']; + const baseMessage = `${error.msg} at ${error.location}.`; + const errorInfoValue = hiddenFields.includes(errorInfoKey) + ? baseMessage + : `${baseMessage} Value: ${error.value}`; + return { [errorInfoKey]: errorInfoValue }; }; diff --git a/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts b/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts index f69cabc..fd20128 100644 --- a/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts +++ b/tests/apps/apiApp/controllers/Auth/RegisterController.test.ts @@ -41,15 +41,7 @@ describe('RegisterController', () => { expect(res.send).toHaveBeenCalledWith(); }); - it("should fail if passwords don't match", async () => { - req = { body: { ...request, repeatPassword: 'differentPassword' } }; - - await controller.run(req as Request, res as Response, next); - - expect(next).toHaveBeenCalledWith(expect.any(InvalidArgumentError)); - }); - - it('should fail if user exists', async () => { + it('should fail if user email exists', async () => { repository = new UserRepositoryMock({ exists: true }); service = new RegisterUser(repository, encrypter); controller = new RegisterController(service); diff --git a/tests/apps/apiApp/features/Auth/login-user.feature b/tests/apps/apiApp/features/Auth/login-user.feature new file mode 100644 index 0000000..d959baa --- /dev/null +++ b/tests/apps/apiApp/features/Auth/login-user.feature @@ -0,0 +1,46 @@ +Feature: Login + In order to use the application + As a user + I want to be able to login + + Background: + Scenario: Register with valid credentials + Given a POST request to "/api/v1/Auth/register" with body + """ + { + "username": "login1", + "email": "login@aa.com", + "password": "#aD3fe2.0%", + "repeatPassword": "#aD3fe2.0%" + } + """ + Then the response status code should be 201 + Then the response body should be empty + + + Scenario: Login with valid credentials + Given a POST request to "/api/v1/Auth/login" with body + """ + { + "email": "login@aa.com", + "password": "#aD3fe2.0%" + } + """ + Then the response status code should be 200 + Then the response body should include an auth token + + Scenario: Fail with invalid credentials + Given a POST request to "/api/v1/Auth/login" with body + """ + { + "email": "login@aa.com", + "password": "#aDXXXXXXX3fe2.0%" + } + """ + Then the response status code should be 401 + Then the response body should be + """ + { + "message": "Invalid credentials" + } + """ diff --git a/tests/apps/apiApp/features/Auth/register-user.feature b/tests/apps/apiApp/features/Auth/register-user.feature new file mode 100644 index 0000000..09eb6db --- /dev/null +++ b/tests/apps/apiApp/features/Auth/register-user.feature @@ -0,0 +1,86 @@ +Feature: Register a new user + In order to use the application + I want to register a new user + + Scenario: Register a valid user + Given a POST request to "/api/v1/Auth/register" with body + """ + { + "username": "register1", + "email": "register@aa.com", + "password": "#aD3fe2.0%", + "repeatPassword": "#aD3fe2.0%" + } + """ + Then the response status code should be 201 + Then the response body should be empty + + Scenario: Existing email + Given a POST request to "/api/v1/Auth/register" with body + """ + { + "username": "register", + "email": "register@aa.com", + "password": "#aD3fe2.0%", + "repeatPassword": "#aD3fe2.0%" + } + """ + Then the response status code should be 400 + Then the response body should be + """ + { + "message": "User already exists" + } + """ + + Scenario: Password and repeat password are different + Given a POST request to "/api/v1/Auth/register" with body + """ + { + "username": "register", + "email": "register@aa.com", + "password": "#aD3fe2.0%", + "repeatPassword": "#aD3fe2.0%1" + } + """ + Then the response status code should be 400 + Then the response body should be + """ + { + "errors": [ + { + "repeatPassword": "Passwords do not match at body." + } + ] + } + """ + + Scenario: Invalid arguments + Given a POST request to "/api/v1/Auth/register" with body + """ + { + "email": "aaJaa", + "password": "1234", + "repeatPassword": "1234" + } + """ + Then the response status code should be 400 + Then the response body should be + """ + { + "errors": [ + { + "email": "Invalid value at body. Value: aaJaa" + }, + { + "username": "Invalid value at body. Value: undefined" + }, + { + "password": "Invalid value at body." + }, + { + "repeatPassword": "Invalid value at body." + } + ] + } + """ diff --git a/tests/apps/apiApp/features/Auth/validate-token.feature b/tests/apps/apiApp/features/Auth/validate-token.feature new file mode 100644 index 0000000..c3f038a --- /dev/null +++ b/tests/apps/apiApp/features/Auth/validate-token.feature @@ -0,0 +1,8 @@ +Feature: Validate token + In order to grant access to the application + As an user + I want to validate my token + + Scenario: Invalid token + Given a GET request to "/api/v1/Auth/validate/dasda" + Then the response status code should be 401 diff --git a/tests/apps/apiApp/features/step_definitions/controller.steps.ts b/tests/apps/apiApp/features/step_definitions/controller.steps.ts index caf2192..79891ad 100644 --- a/tests/apps/apiApp/features/step_definitions/controller.steps.ts +++ b/tests/apps/apiApp/features/step_definitions/controller.steps.ts @@ -73,6 +73,10 @@ Then( } ); +Then('the response body should include an auth token', async () => { + assert.isNotEmpty(_response.body.token); +}); + Then('the response body should be empty', async () => { assert.isEmpty(_response.body); }); From 4cbd8269790ba266c4d16d2cbef73d85f3f3e82b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Jacobo=20Vi=C3=B1a=20Rebolledo?= Date: Fri, 15 Dec 2023 01:51:47 +0100 Subject: [PATCH 7/7] [GH-38] fix(Book): fix flaky tests --- tests/Contexts/apiApp/Books/domain/Book.test.ts | 2 +- .../Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts | 2 +- tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts | 4 ++-- tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts | 2 +- .../persistence/MongoBookRepository.test.ts | 0 5 files changed, 5 insertions(+), 5 deletions(-) rename tests/Contexts/apiApp/Books/{infraestructure => infrastructure}/persistence/MongoBookRepository.test.ts (100%) diff --git a/tests/Contexts/apiApp/Books/domain/Book.test.ts b/tests/Contexts/apiApp/Books/domain/Book.test.ts index 5a7c6ca..0aeb71a 100644 --- a/tests/Contexts/apiApp/Books/domain/Book.test.ts +++ b/tests/Contexts/apiApp/Books/domain/Book.test.ts @@ -60,7 +60,7 @@ describe('Book', () => { it('should throw an error when the title is longer than 100 characters', () => { let title; expect(() => { - title = new BookTitle(random.word({ min: 101 })); + title = new BookTitle(random.word({ min: 101, max: 255 })); }).toThrowError(InvalidArgumentError); expect(title).toBeUndefined(); diff --git a/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts b/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts index 98c4414..bff79ac 100644 --- a/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts +++ b/tests/Contexts/apiApp/Books/domain/mothers/BookAuthorMother.ts @@ -11,6 +11,6 @@ export class BookAuthorMother { } static invalidValue() { - return random.word({ min: 41 }); + return random.word({ min: 41, max: 255 }); } } diff --git a/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts b/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts index 53857ed..fff5086 100644 --- a/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts +++ b/tests/Contexts/apiApp/Books/domain/mothers/BookMother.ts @@ -2,12 +2,12 @@ import { BookCreatorRequest } from '../../../../../../src/Contexts/apiApp/Books/ import { Book, BookAuthor, - BookId, BookPages, BookReleaseDate, BookTitle, Isbn } from '../../../../../../src/Contexts/apiApp/Books/domain'; +import { Uuid } from '../../../../../../src/Contexts/shared/domain/value-object/Uuid'; import { BookAuthorMother } from './BookAuthorMother'; import { BookIdMother } from './BookIdMother'; @@ -18,7 +18,7 @@ import { ISBNMother } from './ISBNMother'; export class BookMother { static create( - id: BookId, + id: Uuid, title: BookTitle, author: BookAuthor, isbn: Isbn, diff --git a/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts b/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts index 0fb999a..2003f54 100644 --- a/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts +++ b/tests/Contexts/apiApp/Books/domain/mothers/BookTitleMother.ts @@ -13,7 +13,7 @@ export class BookTitleMother { static invalidValue(): unknown { return random.arrayElement([ - random.word({ min: 101 }), + random.word({ min: 101, max: 255 }), random.integer(), random.boolean() ]); diff --git a/tests/Contexts/apiApp/Books/infraestructure/persistence/MongoBookRepository.test.ts b/tests/Contexts/apiApp/Books/infrastructure/persistence/MongoBookRepository.test.ts similarity index 100% rename from tests/Contexts/apiApp/Books/infraestructure/persistence/MongoBookRepository.test.ts rename to tests/Contexts/apiApp/Books/infrastructure/persistence/MongoBookRepository.test.ts