diff --git a/src/__snapshots__/app.e2e.snap b/src/__snapshots__/app.e2e.snap index c3e35f7a..9e832df4 100644 --- a/src/__snapshots__/app.e2e.snap +++ b/src/__snapshots__/app.e2e.snap @@ -69,6 +69,60 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` ] } }, + "/accounts/{uid}": { + "delete": { + "operationId": "accounts-delete", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "description": "id of account to delete", + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": {}, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidCredentialException" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MissingAccountException" + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerException" + } + } + } + } + }, + "security": [ + { + "jwt": [] + } + ], + "tags": [ + "accounts" + ] + } + }, "/accounts/whoami": { "get": { "operationId": "accounts-whoami", @@ -76,12 +130,8 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` { "name": "authorization", "in": "header", + "description": "bearer auth token", "required": false, - "examples": { - "bearer": { - "value": "bearer " - } - }, "schema": { "type": "string" } @@ -123,6 +173,14 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` } }, "components": { + "securitySchemes": { + "bearer": { + "scheme": "bearer", + "bearerFormat": "JWT", + "name": "jwt", + "type": "http" + } + }, "schemas": { "AccountCreatedPayload": { "type": "object", @@ -251,6 +309,82 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` "reason" ] }, + "InvalidCredentialException": { + "type": "object", + "properties": { + "code": { + "type": "number", + "description": "http response status code", + "enum": [ + 401 + ] + }, + "id": { + "type": "string", + "description": "unique id representing the exception", + "enum": [ + "accounts/invalid-credential" + ] + }, + "message": { + "type": "string", + "description": "human-readable description of the exception" + }, + "reason": { + "type": "null" + } + }, + "required": [ + "code", + "id", + "message", + "reason" + ] + }, + "MissingAccount": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "id of missing account" + } + }, + "required": [ + "uid" + ] + }, + "MissingAccountException": { + "type": "object", + "properties": { + "code": { + "type": "number", + "description": "http response status code", + "enum": [ + 404 + ] + }, + "id": { + "type": "string", + "description": "unique id representing the exception", + "enum": [ + "accounts/not-found" + ] + }, + "message": { + "type": "string", + "description": "human-readable description of the exception" + }, + "reason": { + "$ref": "#/components/schemas/MissingAccount" + } + }, + "required": [ + "code", + "id", + "message", + "reason" + ] + }, "ValidationException": { "type": "object", "properties": { diff --git a/src/database/providers/__tests__/base.repository.functional.spec.mts b/src/database/providers/__tests__/base.repository.functional.spec.mts index 1dfa8acf..8a128e5c 100644 --- a/src/database/providers/__tests__/base.repository.functional.spec.mts +++ b/src/database/providers/__tests__/base.repository.functional.spec.mts @@ -36,6 +36,32 @@ describe('functional:database/providers/Repository', () => { }) }) + describe('#delete', () => { + let record: IDocument + let seeder: Seeder + let subject: TestSubject + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + subject = new TestSubject(mapper) + seeder = await new Seeder(factory, subject).up(1) + record = seeder.seeds[0]! + }) + + it('should return entity representing deleted record', async () => { + // Act + const result = await subject.delete(record._id) + + // Expect + expect(result).to.be.instanceof(Entity) + expect(result).to.have.property('uid', String(record._id)) + expect(subject).to.have.nested.property('store.size', 0) + }) + }) + describe('#entities', () => { let count: number let seeder: Seeder @@ -62,20 +88,43 @@ describe('functional:database/providers/Repository', () => { }) }) - describe('#insert', () => { - let entity: Entity - let has: (store: Map) => boolean + describe('#findById', () => { + let record: IDocument let seeder: Seeder let subject: TestSubject - afterEach(async () => { + afterAll(async () => { await seeder.down() }) + beforeAll(async () => { + subject = new TestSubject(mapper) + seeder = await new Seeder(factory, subject).up() + record = seeder.seeds[3]! + }) + + it('should return `null` if matching entity is not found', async () => { + expect(await subject.findById(new ObjectId())).to.be.null + }) + + it('should return entity representing matched record', async () => { + // Act + const result = await subject.findById(record._id) + + // Expect + expect(result).to.be.instanceof(Entity) + expect(result).to.have.property('uid', String(record._id)) + }) + }) + + describe('#insert', () => { + let entity: Entity + let has: (store: Map) => boolean + let subject: TestSubject + beforeAll(() => { entity = mapper.toDomain(factory.makeOne()) subject = new TestSubject(mapper) - seeder = new Seeder(factory, subject) /** * Check if `store` contains a record for {@linkcode entity}. diff --git a/src/database/providers/base.repository.mts b/src/database/providers/base.repository.mts index 97f6b330..7ddc50bd 100644 --- a/src/database/providers/base.repository.mts +++ b/src/database/providers/base.repository.mts @@ -3,9 +3,13 @@ * @module sneusers/database/providers/Repository */ -import type { DatabaseRecord, Entity, - Mapper } from '@flex-development/sneusers/database' +import type { + DatabaseRecord, + Entity, + Mapper +} from '@flex-development/sneusers/database' import type { ObjectId } from 'bson' +import { ok } from 'devlop' /** * Database repository model. @@ -62,6 +66,57 @@ class Repository { return [...this.store.values()] } + /** + * Delete a record by `uid`. + * + * @public + * @instance + * @async + * + * @param {ObjectId | string} uid + * The id of the record to remove + * @return {Promise} + * An entity representing the deleted record + */ + public async delete(uid: ObjectId | string): Promise { + /** + * The entity to remove. + * + * @const {T | null} entity + */ + const entity: T | null = await this.findById(uid) + + ok(entity, 'expected `entity` to remove') + this.store.delete(entity.uid) + + return entity + } + + /** + * Retrieve a record by `uid`. + * + * @public + * @instance + * @async + * + * @param {ObjectId | string} uid + * The id of the record to find + * @return {Promise} + * An entity representing the matched record or `null` + */ + public async findById(uid: ObjectId | string): Promise { + return new Promise(resolve => { + /** + * The matching record. + * + * @const {DatabaseRecord | undefined} record + */ + const record: DatabaseRecord | undefined = this.store.get(String(uid)) + + return void resolve(record ? this.mapper.toDomain(record) : null) + }) + } + /** * Add a new record. * diff --git a/src/enums/subroutes.mts b/src/enums/subroutes.mts index 666f0061..8fa39b3b 100644 --- a/src/enums/subroutes.mts +++ b/src/enums/subroutes.mts @@ -10,6 +10,7 @@ */ const enum subroutes { ACCOUNTS_CREATE = '', + ACCOUNTS_UID = '/:uid', ACCOUNTS_WHOAMI = '/whoami' } diff --git a/src/errors/enums/exception-id.mts b/src/errors/enums/exception-id.mts index 8a286fc4..7c70189a 100644 --- a/src/errors/enums/exception-id.mts +++ b/src/errors/enums/exception-id.mts @@ -11,6 +11,8 @@ enum ExceptionId { EMAIL_CONFLICT = 'accounts/email-conflict', INTERNAL_SERVER_ERROR = 'sneusers/internal-error', + INVALID_CREDENTIAL = 'accounts/invalid-credential', + MISSING_ACCOUNT = 'accounts/not-found', VALIDATION_FAILURE = 'sneusers/validation-failure' } diff --git a/src/hooks/use-swagger.hook.mts b/src/hooks/use-swagger.hook.mts index 3e20186a..857aae23 100644 --- a/src/hooks/use-swagger.hook.mts +++ b/src/hooks/use-swagger.hook.mts @@ -3,6 +3,7 @@ * @module sneusers/hooks/useSwagger */ +import AuthStrategy from '#enums/auth-strategy' import routes from '#enums/routes' import pkg from '@flex-development/sneusers/package.json' with { type: 'json' } import { @@ -47,6 +48,8 @@ function useSwagger(this: void, app: INestApplication): undefined { docs.setTitle(pkg.openapi.title) docs.setDescription(pkg.openapi.description) + docs.addBearerAuth({ name: AuthStrategy.JWT, type: 'http' }) + return void SwaggerModule.setup( routes.APP, app, diff --git a/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts b/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts index 7fa2fafe..9b5d2e17 100644 --- a/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts +++ b/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts @@ -6,6 +6,7 @@ import TestSubject from '#accounts/accounts.module' import Account from '#accounts/entities/account.entity' import CreateAccountHandler from '#accounts/handlers/create-account.handler' +import DeleteAccountHandler from '#accounts/handlers/delete-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' import AuthService from '#accounts/services/auth.service' import routes from '#enums/routes' @@ -27,9 +28,11 @@ import { HttpStatus, type ModuleMetadata } from '@nestjs/common' import type { NestFastifyApplication } from '@nestjs/platform-fastify' import { Test, type TestingModule } from '@nestjs/testing' import type { InjectOptions, Response } from 'light-my-request' +import type { IncomingHttpHeaders } from 'node:http' describe('e2e:accounts/AccountsModule', () => { let app: NestFastifyApplication + let factory: AccountFactory let metadata: ModuleMetadata let ref: TestingModule let repo: AccountsRepository @@ -45,11 +48,11 @@ describe('e2e:accounts/AccountsModule', () => { ref = await Test.createTestingModule(metadata).compile() + factory = new AccountFactory() repo = ref.get(AccountsRepository) app = await createApp(ref) - seeder = new Seeder(new AccountFactory(), repo) - await seeder.up() + seeder = new Seeder(factory, repo) }) describe('POST /accounts', () => { @@ -57,10 +60,16 @@ describe('e2e:accounts/AccountsModule', () => { let method: Required['method'] let url: string - beforeAll(() => { + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { headers = { 'content-type': 'application/json' } method = 'post' url = routes.ACCOUNTS + + await seeder.up() }) describe('201 (CREATED)', () => { @@ -259,13 +268,208 @@ describe('e2e:accounts/AccountsModule', () => { }) }) + describe('DELETE /accounts/:uid', () => { + let auth: AuthService + let method: Required['method'] + + beforeAll(() => { + auth = ref.get(AuthService) + method = 'delete' + }) + + describe('204 (NO CONTENT)', () => { + let account: Account + let result: Response + + beforeAll(async () => { + await seeder.up(1) + account = new Account(seeder.seeds[0]!) + + result = await app.inject({ + headers: { + authorization: `bearer ${await auth.accessToken(account)}` + }, + method, + url: routes.ACCOUNTS + routes.APP + account.uid + }) + }) + + it('successful account deletion', () => { + expect(result).to.have.status(HttpStatus.NO_CONTENT) + expect(result).to.have.property('body', '') + }) + }) + + describe('401 (UNAUTHORIZED)', () => { + let account1: Account + let account2: Account + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + await seeder.up(2) + account1 = new Account(seeder.seeds[0]!) + account2 = new Account(seeder.seeds[1]!) + }) + + it('authentication failure (no auth token)', async () => { + // Arrange + const request: InjectOptions = { + method, + url: routes.ACCOUNTS + routes.APP + account1.uid + } + + // Act + const result = await app.inject(request) + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.UNAUTHORIZED) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', HttpStatus.UNAUTHORIZED) + expect(payload).to.have.property('id', ExceptionId.INVALID_CREDENTIAL) + expect(payload).to.have.property('message').be.a('string').and.not.empty + expect(payload).to.have.property('reason', null) + }) + + it('authentication failure (token mismatch)', async () => { + // Arrange + const request: InjectOptions = { + headers: { + authorization: `bearer ${await auth.accessToken(account1)}` + }, + method, + url: routes.ACCOUNTS + routes.APP + account2.uid + } + + // Act + const result = await app.inject(request) + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.UNAUTHORIZED) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', HttpStatus.UNAUTHORIZED) + expect(payload).to.have.property('id', ExceptionId.INVALID_CREDENTIAL) + expect(payload).to.have.property('message').be.a('string').and.not.empty + expect(payload).to.have.property('reason', null) + }) + }) + + describe('404 (NOT FOUND)', () => { + let account: Account + let url: string + + beforeAll(async () => { + account = new Account(factory.makeOne()) + url = routes.ACCOUNTS + routes.APP + account.uid + }) + + it('fail on missing account (no auth token)', async () => { + // Act + const result = await app.inject({ method, url }) + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.NOT_FOUND) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', HttpStatus.NOT_FOUND) + expect(payload).to.have.property('id', ExceptionId.MISSING_ACCOUNT) + expect(payload).to.have.property('message').be.a('string').and.not.empty + expect(payload).to.have.property('reason').with.keys(['uid']) + expect(payload).to.have.nested.property('reason.uid', account.uid) + }) + + it('fail on missing account (with auth token)', async () => { + // Arrange + const headers: IncomingHttpHeaders = { + authorization: `bearer ${await auth.accessToken(account)}` + } + + // Act + const result = await app.inject({ headers, method, url }) + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.NOT_FOUND) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', HttpStatus.NOT_FOUND) + expect(payload).to.have.property('id', ExceptionId.MISSING_ACCOUNT) + expect(payload).to.have.property('message').be.a('string').and.not.empty + expect(payload).to.have.property('reason').with.keys(['uid']) + expect(payload).to.have.nested.property('reason.uid', account.uid) + }) + }) + + describe('500 (INTERNAL_SERVER_ERROR)', () => { + let account: Account + let app: NestFastifyApplication + let result: Response + let url: string + + afterAll(async () => { + await seeder.down() + await app.close() + }) + + beforeAll(async () => { + await seeder.up(1) + + account = new Account(seeder.seeds[0]!) + url = routes.ACCOUNTS + routes.APP + account.uid + + app = await createApp( + await Test.createTestingModule(metadata) + .overrideProvider(DeleteAccountHandler) + .useValue(stub500(url, 'execute')) + .overrideProvider(AccountsRepository) + .useValue(repo) + .compile() + ) + + result = await app.inject({ + headers: { + authorization: `bearer ${await auth.accessToken(account)}` + }, + method, + url + }) + }) + + it('unhandled error', () => { + // Arrange + const code: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR + const id: ExceptionId = ExceptionId.INTERNAL_SERVER_ERROR + + // Act + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(code) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', code) + expect(payload).to.have.property('id', id) + expect(payload).to.have.property('message').be.a('string').and.not.empty + expect(payload).to.have.property('reason', null) + }) + }) + }) + describe('GET /accounts/whoami', () => { let method: Required['method'] let url: string - beforeAll(() => { + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { method = 'get' url = routes.ACCOUNTS + subroutes.ACCOUNTS_WHOAMI + + await seeder.up() }) describe('200 (OK)', () => { @@ -310,7 +514,7 @@ describe('e2e:accounts/AccountsModule', () => { }) }) - it('unauthenticated user', () => { + it('authentication failure', () => { // Act const payload = result.json() diff --git a/src/subdomains/accounts/accounts.module.mts b/src/subdomains/accounts/accounts.module.mts index 023bbefc..3d97acf6 100644 --- a/src/subdomains/accounts/accounts.module.mts +++ b/src/subdomains/accounts/accounts.module.mts @@ -7,6 +7,7 @@ import AccountsController from '#accounts/controllers/accounts.controller' import Account from '#accounts/entities/account.entity' import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' import CreateAccountHandler from '#accounts/handlers/create-account.handler' +import DeleteAccountHandler from '#accounts/handlers/delete-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' import AuthService from '#accounts/services/auth.service' import JwtStrategy from '#accounts/strategies/jwt.strategy' @@ -29,6 +30,7 @@ import { JwtModule } from '@nestjs/jwt' AccountsRepository, AuthService, CreateAccountHandler, + DeleteAccountHandler, JwtOptionsFactory, JwtStrategy ] diff --git a/src/subdomains/accounts/commands/delete-account.command.mts b/src/subdomains/accounts/commands/delete-account.command.mts new file mode 100644 index 00000000..08b6424c --- /dev/null +++ b/src/subdomains/accounts/commands/delete-account.command.mts @@ -0,0 +1,29 @@ +/** + * @file Commands - DeleteAccountCommand + * @module sneusers/accounts/commands/DeleteAccount + */ + +import type { Account } from '@flex-development/sneusers/accounts' +import { Command } from '@nestjs/cqrs' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * Account deletion command. + * + * @class + * @extends {Command} + */ +@ApiSchema() +class DeleteAccountCommand extends Command { + /** + * The id of the account to delete. + * + * @public + * @instance + * @member {string} uid + */ + @ApiProperty({ description: 'id of account to delete', type: 'string' }) + public uid!: string +} + +export default DeleteAccountCommand diff --git a/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts b/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts index c187eeb2..92a89183 100644 --- a/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts +++ b/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts @@ -4,15 +4,19 @@ */ import CreateAccountCommand from '#accounts/commands/create-account.command' +import DeleteAccountCommand from '#accounts/commands/delete-account.command' import TestSubject from '#accounts/controllers/accounts.controller' import AccountCreatedPayload from '#accounts/dto/account-created.payload' import Account from '#accounts/entities/account.entity' import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' import CreateAccountHandler from '#accounts/handlers/create-account.handler' +import DeleteAccountHandler from '#accounts/handlers/delete-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' import AuthService from '#accounts/services/auth.service' import DependenciesModule from '#modules/dependencies.module' import AccountFactory from '#tests/utils/account.factory' +import Seeder from '#tests/utils/seeder' +import type { AccountDocument } from '@flex-development/sneusers/accounts' import DatabaseModule from '@flex-development/sneusers/database' import { JwtModule } from '@nestjs/jwt' import { Test, type TestingModule } from '@nestjs/testing' @@ -29,7 +33,12 @@ describe('unit:accounts/controllers/AccountsController', () => { DependenciesModule, JwtModule.registerAsync({ useClass: JwtOptionsFactory }) ], - providers: [AccountsRepository, AuthService, CreateAccountHandler] + providers: [ + AccountsRepository, + AuthService, + CreateAccountHandler, + DeleteAccountHandler + ] }).compile() subject = ref.get(TestSubject) @@ -55,4 +64,21 @@ describe('unit:accounts/controllers/AccountsController', () => { expect(result).to.be.instanceof(AccountCreatedPayload) }) }) + + describe('#delete', () => { + let params: DeleteAccountCommand + let seeder: Seeder + + beforeAll(async () => { + seeder = new Seeder(new AccountFactory(), ref.get(AccountsRepository)) + await seeder.up(1) + + params = new DeleteAccountCommand() + params.uid = String(seeder.seeds[0]!._id) + }) + + it('should return `null`', async () => { + expect(await subject.delete(params)).to.be.null + }) + }) }) diff --git a/src/subdomains/accounts/controllers/accounts.controller.mts b/src/subdomains/accounts/controllers/accounts.controller.mts index b7d2a0c4..da1616ff 100644 --- a/src/subdomains/accounts/controllers/accounts.controller.mts +++ b/src/subdomains/accounts/controllers/accounts.controller.mts @@ -4,11 +4,16 @@ */ import CreateAccountCommand from '#accounts/commands/create-account.command' +import DeleteAccountCommand from '#accounts/commands/delete-account.command' import User from '#accounts/decorators/user.decorator' import AccountCreatedPayload from '#accounts/dto/account-created.payload' import WhoamiPayload from '#accounts/dto/whoami.payload' +import UnauthorizedExceptionFilter from '#accounts/filters/unauthorized.filter' +import ExistingAccountGuard from '#accounts/guards/existing-account.guard' +import JwtGuard from '#accounts/guards/jwt.guard' import WhoamiGuard from '#accounts/guards/whoami.guard' import AuthService from '#accounts/services/auth.service' +import AuthStrategy from '#enums/auth-strategy' import routes from '#enums/routes' import subroutes from '#enums/subroutes' import ExceptionFilter from '#filters/exception.filter' @@ -16,7 +21,9 @@ import UnhandledExceptionFilter from '#filters/unhandled.filter' import TransformPipe from '#pipes/transform.pipe' import type { Account } from '@flex-development/sneusers/accounts' import { - EmailConflictException + EmailConflictException, + InvalidCredentialException, + MissingAccountException } from '@flex-development/sneusers/accounts/errors' import { InternalServerException, @@ -25,9 +32,11 @@ import { import { Body, Controller, + Delete, Get, HttpCode, HttpStatus, + Param, Post, Res, UseFilters, @@ -37,10 +46,13 @@ import { import { CommandBus } from '@nestjs/cqrs' import { ApiBadRequestResponse, + ApiBearerAuth, ApiConflictResponse, ApiCreatedResponse, ApiHeader, ApiInternalServerErrorResponse, + ApiNoContentResponse, + ApiNotFoundResponse, ApiOkResponse, ApiTags, ApiUnauthorizedResponse @@ -106,6 +118,34 @@ class AccountsController { ) } + /** + * Delete an account. + * + * @public + * @instance + * @async + * + * @param {DeleteAccountCommand} params + * Route parameters object + * @param {string} params.uid + * The id of the account to delete + * @return {Promise} + * Deleted account payload + */ + @Delete(subroutes.ACCOUNTS_UID) + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtGuard) + @UseGuards(ExistingAccountGuard) + @UseFilters(UnauthorizedExceptionFilter) + @ApiBearerAuth(AuthStrategy.JWT) + @ApiNoContentResponse() + @ApiUnauthorizedResponse({ type: InvalidCredentialException }) + @ApiNotFoundResponse({ type: MissingAccountException }) + public async delete(@Param() params: DeleteAccountCommand): Promise { + ok(params instanceof DeleteAccountCommand, 'expected a command') + return await this.commands.execute(params), null + } + /** * Check authentication. * @@ -123,7 +163,7 @@ class AccountsController { @UseGuards(WhoamiGuard) @HttpCode(HttpStatus.OK) @ApiHeader({ - examples: { bearer: { value: 'bearer ' } }, + description: 'bearer auth token', name: 'authorization', required: false }) diff --git a/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts b/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts index 1a5cc826..91cf529b 100644 --- a/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts +++ b/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts @@ -16,8 +16,7 @@ describe('unit:accounts/errors/EmailConflictException', () => { let subject: TestSubject beforeAll(() => { - email = faker.internet.email() - subject = new TestSubject(email) + subject = new TestSubject(email = faker.internet.email()) }) it('should be instanceof Exception', () => { diff --git a/src/subdomains/accounts/errors/__tests__/invalid-credential.exception.spec.mts b/src/subdomains/accounts/errors/__tests__/invalid-credential.exception.spec.mts new file mode 100644 index 00000000..85b69ef9 --- /dev/null +++ b/src/subdomains/accounts/errors/__tests__/invalid-credential.exception.spec.mts @@ -0,0 +1,51 @@ +/** + * @file Unit Tests - InvalidCredentialException + * @module sneusers/accounts/errors/tests/unit/InvalidCredentialException + */ + +import TestSubject from '#accounts/errors/invalid-credential.exception' +import ExceptionCode from '#errors/enums/exception-code' +import ExceptionId from '#errors/enums/exception-id' +import Exception from '#errors/models/base.exception' + +describe('unit:accounts/errors/InvalidCredentialException', () => { + describe('constructor', () => { + let subject: TestSubject + + beforeAll(() => { + subject = new TestSubject() + }) + + it('should be instanceof Exception', () => { + expect(subject).to.be.instanceof(Exception) + }) + + it('should set #cause', () => { + expect(subject).to.have.property('cause', null) + }) + + it('should set #code', () => { + expect(subject).to.have.property('code', ExceptionCode.UNAUTHORIZED) + }) + + it('should set #id', () => { + expect(subject).to.have.property('id', ExceptionId.INVALID_CREDENTIAL) + }) + + it('should set #message', () => { + expect(subject).to.have.property('message', 'Invalid credential') + }) + + it('should set #name', () => { + expect(subject).to.have.property('name', TestSubject.name) + }) + + it('should set #reason', () => { + expect(subject).to.have.property('reason', null) + }) + + it('should set #stack', () => { + expect(subject).to.have.property('stack').be.a('string').that.is.not.empty + }) + }) +}) diff --git a/src/subdomains/accounts/errors/__tests__/missing-account.exception.spec.mts b/src/subdomains/accounts/errors/__tests__/missing-account.exception.spec.mts new file mode 100644 index 00000000..20084c73 --- /dev/null +++ b/src/subdomains/accounts/errors/__tests__/missing-account.exception.spec.mts @@ -0,0 +1,59 @@ +/** + * @file Unit Tests - MissingAccountException + * @module sneusers/accounts/errors/tests/unit/MissingAccountException + */ + +import TestSubject from '#accounts/errors/missing-account.exception' +import Reason from '#accounts/errors/missing-account.reason' +import ExceptionCode from '#errors/enums/exception-code' +import ExceptionId from '#errors/enums/exception-id' +import Exception from '#errors/models/base.exception' +import { isObjectPlain } from '@flex-development/tutils' +import { ObjectId } from 'bson' + +describe('unit:accounts/errors/MissingAccountException', () => { + describe('constructor', () => { + let subject: TestSubject + let uid: ObjectId + + beforeAll(() => { + subject = new TestSubject(uid = new ObjectId()) + }) + + it('should be instanceof Exception', () => { + expect(subject).to.be.instanceof(Exception) + }) + + it('should set #cause', () => { + expect(subject).to.have.property('cause').satisfy(isObjectPlain) + expect(subject.cause).to.have.keys(['uid']) + expect(subject.cause).to.have.property('uid', String(uid)) + }) + + it('should set #code', () => { + expect(subject).to.have.property('code', ExceptionCode.NOT_FOUND) + }) + + it('should set #id', () => { + expect(subject).to.have.property('id', ExceptionId.MISSING_ACCOUNT) + }) + + it('should set #message', () => { + expect(subject).to.have.property('message', 'Account not found') + }) + + it('should set #name', () => { + expect(subject).to.have.property('name', TestSubject.name) + }) + + it('should set #reason', () => { + expect(subject).to.have.property('reason').be.instanceof(Reason) + expect(subject.reason).to.have.keys(['uid']) + expect(subject.reason).to.have.property('uid', String(uid)) + }) + + it('should set #stack', () => { + expect(subject).to.have.property('stack').be.a('string').that.is.not.empty + }) + }) +}) diff --git a/src/subdomains/accounts/errors/email-conflict.exception.mts b/src/subdomains/accounts/errors/email-conflict.exception.mts index 714c223b..3fb89e9e 100644 --- a/src/subdomains/accounts/errors/email-conflict.exception.mts +++ b/src/subdomains/accounts/errors/email-conflict.exception.mts @@ -4,8 +4,11 @@ */ import Reason from '#accounts/errors/email-conflict.reason' -import { Exception, ExceptionCode, - ExceptionId } from '@flex-development/sneusers/errors' +import { + Exception, + ExceptionCode, + ExceptionId +} from '@flex-development/sneusers/errors' import { ApiProperty, ApiSchema } from '@nestjs/swagger' /** diff --git a/src/subdomains/accounts/errors/email-conflict.reason.mts b/src/subdomains/accounts/errors/email-conflict.reason.mts index 4aae9150..6d46f343 100644 --- a/src/subdomains/accounts/errors/email-conflict.reason.mts +++ b/src/subdomains/accounts/errors/email-conflict.reason.mts @@ -26,7 +26,7 @@ class EmailConflict extends Reason { public email: string /** - * Create a an email conflict exception info object. + * Create an email conflict exception info object. * * @param {string} email * The conflicting email address diff --git a/src/subdomains/accounts/errors/index.mts b/src/subdomains/accounts/errors/index.mts index 2f81c289..015cad9f 100644 --- a/src/subdomains/accounts/errors/index.mts +++ b/src/subdomains/accounts/errors/index.mts @@ -6,3 +6,9 @@ export { default as EmailConflictException } from '#accounts/errors/email-conflict.exception' +export { + default as InvalidCredentialException +} from '#accounts/errors/invalid-credential.exception' +export { + default as MissingAccountException +} from '#accounts/errors/missing-account.exception' diff --git a/src/subdomains/accounts/errors/invalid-credential.exception.mts b/src/subdomains/accounts/errors/invalid-credential.exception.mts new file mode 100644 index 00000000..fc9543f3 --- /dev/null +++ b/src/subdomains/accounts/errors/invalid-credential.exception.mts @@ -0,0 +1,66 @@ +/** + * @file Errors - InvalidCredentialException + * @module sneusers/accounts/errors/InvalidCredentialException + */ + +import { + Exception, + ExceptionCode, + ExceptionId +} from '@flex-development/sneusers/errors' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * An invalid credential exception. + * + * @class + * @extends {Exception} + */ +@ApiSchema() +class InvalidCredentialException extends Exception { + /** + * HTTP response status code. + * + * @public + * @instance + * @member {ExceptionCode.UNAUTHORIZED} code + */ + @ApiProperty({ enum: [ExceptionCode.UNAUTHORIZED] }) + declare public code: (typeof ExceptionCode)['UNAUTHORIZED'] + + /** + * Unique id representing the exception. + * + * @public + * @instance + * @member {ExceptionId.INVALID_CREDENTIAL} code + */ + @ApiProperty({ enum: [ExceptionId.INVALID_CREDENTIAL] }) + declare public id: (typeof ExceptionId)['INVALID_CREDENTIAL'] + + /** + * The reason for the exception. + * + * @public + * @instance + * @member {null} reason + */ + @ApiProperty({ type: 'null' }) + declare public reason: null + + /** + * Create a new invalid credential exception. + */ + constructor() { + super({ + code: ExceptionCode.UNAUTHORIZED, + id: ExceptionId.INVALID_CREDENTIAL, + message: 'Invalid credential', + reason: null + }) + + this.name = 'InvalidCredentialException' + } +} + +export default InvalidCredentialException diff --git a/src/subdomains/accounts/errors/missing-account.exception.mts b/src/subdomains/accounts/errors/missing-account.exception.mts new file mode 100644 index 00000000..15f3859f --- /dev/null +++ b/src/subdomains/accounts/errors/missing-account.exception.mts @@ -0,0 +1,71 @@ +/** + * @file Errors - MissingAccountException + * @module sneusers/accounts/errors/MissingAccountException + */ + +import Reason from '#accounts/errors/missing-account.reason' +import { + Exception, + ExceptionCode, + ExceptionId +} from '@flex-development/sneusers/errors' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' +import type { ObjectId } from 'bson' + +/** + * A missing account exception. + * + * @class + * @extends {Exception} + */ +@ApiSchema() +class MissingAccountException extends Exception { + /** + * HTTP response status code. + * + * @public + * @instance + * @member {ExceptionCode.NOT_FOUND} code + */ + @ApiProperty({ enum: [ExceptionCode.NOT_FOUND] }) + declare public code: (typeof ExceptionCode)['NOT_FOUND'] + + /** + * Unique id representing the exception. + * + * @public + * @instance + * @member {ExceptionId.ACCOUNT_NOT_FOUND} code + */ + @ApiProperty({ enum: [ExceptionId.MISSING_ACCOUNT] }) + declare public id: (typeof ExceptionId)['MISSING_ACCOUNT'] + + /** + * The reason for the exception. + * + * @public + * @instance + * @member {Reason} reason + */ + @ApiProperty({ type: Reason }) + declare public reason: Reason + + /** + * Create a new missing account exception. + * + * @param {ObjectId | string} uid + * The id of the account that was not found + */ + constructor(uid: ObjectId | string) { + super({ + code: ExceptionCode.NOT_FOUND, + id: ExceptionId.MISSING_ACCOUNT, + message: 'Account not found', + reason: new Reason(uid) + }) + + this.name = 'MissingAccountException' + } +} + +export default MissingAccountException diff --git a/src/subdomains/accounts/errors/missing-account.reason.mts b/src/subdomains/accounts/errors/missing-account.reason.mts new file mode 100644 index 00000000..4fd5054a --- /dev/null +++ b/src/subdomains/accounts/errors/missing-account.reason.mts @@ -0,0 +1,54 @@ +/** + * @file Errors - MissingAccount + * @module sneusers/accounts/errors/MissingAccount + */ + +import { Reason } from '@flex-development/sneusers/errors' +import type { JsonObject } from '@flex-development/sneusers/types' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' +import type { ObjectId } from 'bson' + +/** + * The reason for a missing account exception. + * + * @class + * @extends {Reason} + */ +@ApiSchema() +class MissingAccount extends Reason { + /** + * The id of the missing account. + * + * @public + * @instance + * @member {string} uid + */ + @ApiProperty({ description: 'id of missing account', type: 'string' }) + public uid: string + + /** + * Create a missing account exception info object. + * + * @param {ObjectId | string} uid + * The id of the account that was not found + */ + constructor(uid: ObjectId | string) { + super() + this.uid = String(uid) + } + + /** + * Get a JSON representation of the exception info. + * + * @public + * @instance + * + * @return {JsonObject} + * JSON representation of `this` exception info + */ + public toJSON(): JsonObject { + return { uid: this.uid } + } +} + +export default MissingAccount diff --git a/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts b/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts index 99b81b8d..f2626bcf 100644 --- a/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts +++ b/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts @@ -1,6 +1,6 @@ /** * @file Unit Tests - AccountCreatedEvent - * @module sneusers/accounts/events/tests/unit/AccountCreatedEvent + * @module sneusers/accounts/events/tests/unit/AccountCreated */ import Account from '#accounts/entities/account.entity' diff --git a/src/subdomains/accounts/events/__tests__/account-deleted.event.spec.mts b/src/subdomains/accounts/events/__tests__/account-deleted.event.spec.mts new file mode 100644 index 00000000..6e3e5192 --- /dev/null +++ b/src/subdomains/accounts/events/__tests__/account-deleted.event.spec.mts @@ -0,0 +1,29 @@ +/** + * @file Unit Tests - AccountDeletedEvent + * @module sneusers/accounts/events/tests/unit/AccountDeleted + */ + +import Account from '#accounts/entities/account.entity' +import TestSubject from '#accounts/events/account-deleted.event' +import AccountFactory from '#tests/utils/account.factory' + +describe('unit:accounts/events/AccountDeletedEvent', () => { + let factory: AccountFactory + + beforeAll(() => { + factory = new AccountFactory() + }) + + describe('constructor', () => { + let account: Account + let subject: TestSubject + + beforeAll(() => { + subject = new TestSubject(account = new Account(factory.makeOne())) + }) + + it('should set #account', () => { + expect(subject).to.have.property('account', account) + }) + }) +}) diff --git a/src/subdomains/accounts/events/account-created.event.mts b/src/subdomains/accounts/events/account-created.event.mts index c9a973c0..14087aee 100644 --- a/src/subdomains/accounts/events/account-created.event.mts +++ b/src/subdomains/accounts/events/account-created.event.mts @@ -6,13 +6,13 @@ import type { Account } from '@flex-development/sneusers/accounts' /** - * User account created event. + * User account creation event. * * @class */ class AccountCreatedEvent { /** - * Create a new user account created event. + * Create a new user account creation event. * * @param {Account} account * The new account diff --git a/src/subdomains/accounts/events/account-deleted.event.mts b/src/subdomains/accounts/events/account-deleted.event.mts new file mode 100644 index 00000000..678ee586 --- /dev/null +++ b/src/subdomains/accounts/events/account-deleted.event.mts @@ -0,0 +1,23 @@ +/** + * @file Events - AccountDeletedEvent + * @module sneusers/accounts/events/AccountDeleted + */ + +import type { Account } from '@flex-development/sneusers/accounts' + +/** + * User account deletion event. + * + * @class + */ +class AccountDeletedEvent { + /** + * Create a new user account deletion event. + * + * @param {Account} account + * The deleted account + */ + constructor(public account: Account) {} +} + +export default AccountDeletedEvent diff --git a/src/subdomains/accounts/filters/unauthorized.filter.mts b/src/subdomains/accounts/filters/unauthorized.filter.mts new file mode 100644 index 00000000..3e212bba --- /dev/null +++ b/src/subdomains/accounts/filters/unauthorized.filter.mts @@ -0,0 +1,48 @@ +/** + * @file Filters - UnauthorizedExceptionFilter + * @module sneusers/accounts/filters/UnauthorizedException + */ + +import { + InvalidCredentialException +} from '@flex-development/sneusers/accounts/errors' +import { ExceptionCode } from '@flex-development/sneusers/errors' +import { + Catch, + UnauthorizedException, + type ArgumentsHost, + type ExceptionFilter as IExceptionFilter +} from '@nestjs/common' +import type { FastifyReply } from 'fastify' + +/** + * Unauthorized exception filter. + * + * @class + * @implements {IExceptionFilter} + */ +@Catch(UnauthorizedException) +class UnauthorizedExceptionFilter + implements IExceptionFilter { + /** + * Send an invalid credential exception response. + * + * @public + * @instance + * + * @param {UnauthorizedException} e + * The error to handle + * @param {ArgumentsHost} host + * Methods for retrieving arguments passed to execution context handler + * @return {undefined} + */ + public catch(e: UnauthorizedException, host: ArgumentsHost): undefined { + return void host.switchToHttp() + .getResponse() + .header('content-type', 'application/json') + .status(ExceptionCode.UNAUTHORIZED) + .send(new InvalidCredentialException().toJSON()) + } +} + +export default UnauthorizedExceptionFilter diff --git a/src/subdomains/accounts/guards/existing-account.guard.mts b/src/subdomains/accounts/guards/existing-account.guard.mts new file mode 100644 index 00000000..63e0e206 --- /dev/null +++ b/src/subdomains/accounts/guards/existing-account.guard.mts @@ -0,0 +1,72 @@ +/** + * @file Guards - ExistingAccountGuard + * @module sneusers/accounts/guards/ExistingAccount + */ + +import AccountsRepository from '#accounts/providers/accounts.repository' +import { + MissingAccountException +} from '@flex-development/sneusers/accounts/errors' +import { + Injectable, + type CanActivate, + type ExecutionContext +} from '@nestjs/common' +import type { FastifyRequest } from 'fastify' + +/** + * Existing account guard. + * + * @class + * @implements {CanActivate} + */ +@Injectable() +class ExistingAccountGuard implements CanActivate { + /** + * Create a new existing account guard. + * + * @param {AccountsRepository} accounts + * User accounts repository + */ + constructor(protected accounts: AccountsRepository) {} + + /** + * Check `request.params.uid` references an existing account. + * + * Fails if an account is not found. + * + * @public + * @instance + * @async + * + * @param {ExecutionContext} context + * Object containing details about the current request pipeline + * @return {Promise} + * Whether the current request is allowed to proceed + * @throws {MissingAccountException} + * If an account is not found + */ + public async canActivate(context: ExecutionContext): Promise { + /** + * The incoming request object. + * + * @const {FastifyRequest} req + */ + const req: FastifyRequest = context.switchToHttp().getRequest() + + /** + * Unique account id. + * + * @const {string} uid + */ + const uid: string = String(req.params.uid) + + if (!await this.accounts.findById(uid)) { + throw new MissingAccountException(uid) + } + + return true + } +} + +export default ExistingAccountGuard diff --git a/src/subdomains/accounts/handlers/__tests__/create-account.handler.functional.spec.mts b/src/subdomains/accounts/handlers/__tests__/create-account.handler.functional.spec.mts index 3282044f..08d7102e 100644 --- a/src/subdomains/accounts/handlers/__tests__/create-account.handler.functional.spec.mts +++ b/src/subdomains/accounts/handlers/__tests__/create-account.handler.functional.spec.mts @@ -1,6 +1,6 @@ /** * @file Functional Tests - CreateAccountHandler - * @module sneusers/accounts/handlers/tests/functional/CreateAccountHandler + * @module sneusers/accounts/handlers/tests/functional/CreateAccount */ import CreateAccountCommand from '#accounts/commands/create-account.command' diff --git a/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts b/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts index cfb4064c..761aa510 100644 --- a/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts +++ b/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts @@ -1,6 +1,6 @@ /** * @file Unit Tests - CreateAccountHandler - * @module sneusers/accounts/handlers/tests/unit/CreateAccountHandler + * @module sneusers/accounts/handlers/tests/unit/CreateAccount */ import CreateAccountCommand from '#accounts/commands/create-account.command' diff --git a/src/subdomains/accounts/handlers/__tests__/delete-account.handler.functional.spec.mts b/src/subdomains/accounts/handlers/__tests__/delete-account.handler.functional.spec.mts new file mode 100644 index 00000000..379a3042 --- /dev/null +++ b/src/subdomains/accounts/handlers/__tests__/delete-account.handler.functional.spec.mts @@ -0,0 +1,96 @@ +/** + * @file Functional Tests - DeleteAccountHandler + * @module sneusers/accounts/handlers/tests/functional/DeleteAccount + */ + +import DeleteAccountCommand from '#accounts/commands/delete-account.command' +import Account from '#accounts/entities/account.entity' +import AccountDeletedEvent from '#accounts/events/account-deleted.event' +import TestSubject from '#accounts/handlers/delete-account.handler' +import AccountsRepository from '#accounts/providers/accounts.repository' +import AccountFactory from '#tests/utils/account.factory' +import Seeder from '#tests/utils/seeder' +import type { AccountDocument } from '@flex-development/sneusers/accounts' +import DatabaseModule from '@flex-development/sneusers/database' +import { + CqrsModule, + EventBus, + EventsHandler, + type IEventHandler +} from '@nestjs/cqrs' +import { Test, type TestingModule } from '@nestjs/testing' +import type { Mock, MockInstance } from 'vitest' + +describe('functional:accounts/handlers/DeleteAccountHandler', () => { + let handler: IEventHandler + let ref: TestingModule + let repository: AccountsRepository + let seeder: Seeder + let subject: TestSubject + + beforeAll(async () => { + @EventsHandler(AccountDeletedEvent) + class AccountDeletedHandler implements IEventHandler { + /** + * Handle an account deletion event. + * + * @public + * @instance + * + * @param {AccountDeletedEvent} event + * The account deletion event + * @return {undefined} + */ + public handle: Mock = vi.fn().mockName('AccountDeletedHandler#handle') + } + + ref = await Test.createTestingModule({ + imports: [CqrsModule.forRoot(), DatabaseModule.forFeature(Account)], + providers: [AccountDeletedHandler, AccountsRepository, TestSubject] + }).compile() + + handler = ref.get(AccountDeletedHandler) + repository = ref.get(AccountsRepository) + seeder = new Seeder(new AccountFactory(), repository) + subject = ref.get(TestSubject) + + await ref.init() + }) + + describe('#execute', () => { + let command: DeleteAccountCommand + let del: MockInstance + let handle: MockInstance['handle']> + let publish: MockInstance + + beforeAll(() => { + command = new DeleteAccountCommand() + }) + + beforeEach(async () => { + await seeder.up(1) + command.uid = String(seeder.seeds[0]!._id) + + handle = vi.spyOn(handler, 'handle') + publish = vi.spyOn(ref.get(EventBus), 'publish') + del = vi.spyOn(repository, 'delete') + + await subject.execute(command) + }) + + it('should publish account deleted event', () => { + expect(publish.mock.calls).to.be.of.length(1) + expect(publish.mock.lastCall).to.be.an('array').of.length(1) + expect(publish.mock.lastCall![0]).to.be.instanceof(AccountDeletedEvent) + expect(handle.mock.calls).to.be.of.length(1) + expect(handle.mock.lastCall).to.be.an('array').of.length(1) + expect(handle.mock.lastCall![0]).to.be.instanceof(AccountDeletedEvent) + }) + + it('should remove account from database', () => { + expect(del).toHaveBeenCalledOnce() + expect(del.mock.lastCall).to.be.an('array').of.length(1) + expect(del.mock.lastCall).to.have.property('0', command.uid) + }) + }) +}) diff --git a/src/subdomains/accounts/handlers/__tests__/delete-account.handler.spec.mts b/src/subdomains/accounts/handlers/__tests__/delete-account.handler.spec.mts new file mode 100644 index 00000000..6e489c15 --- /dev/null +++ b/src/subdomains/accounts/handlers/__tests__/delete-account.handler.spec.mts @@ -0,0 +1,57 @@ +/** + * @file Unit Tests - DeleteAccountHandler + * @module sneusers/accounts/handlers/tests/unit/DeleteAccount + */ + +import DeleteAccountCommand from '#accounts/commands/delete-account.command' +import Account from '#accounts/entities/account.entity' +import TestSubject from '#accounts/handlers/delete-account.handler' +import AccountsRepository from '#accounts/providers/accounts.repository' +import AccountFactory from '#tests/utils/account.factory' +import Seeder from '#tests/utils/seeder' +import type { AccountDocument } from '@flex-development/sneusers/accounts' +import DatabaseModule from '@flex-development/sneusers/database' +import { CqrsModule } from '@nestjs/cqrs' +import { Test, type TestingModule } from '@nestjs/testing' + +describe('unit:accounts/handlers/DeleteAccountHandler', () => { + let factory: AccountFactory + let ref: TestingModule + let seeder: Seeder + let subject: TestSubject + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + ref = await Test.createTestingModule({ + imports: [CqrsModule.forRoot(), DatabaseModule.forFeature(Account)], + providers: [AccountsRepository, TestSubject] + }).compile() + + factory = new AccountFactory() + seeder = await new Seeder(factory, ref.get(AccountsRepository)).up(1) + + subject = ref.get(TestSubject) + }) + + describe('#execute', () => { + let account: Account + let command: DeleteAccountCommand + + beforeAll(() => { + account = new Account(seeder.seeds[0]!) + command = new DeleteAccountCommand() + command.uid = account.uid + }) + + it('should return the deleted user account', async () => { + // Act + const result = await subject.execute(command) + + // Expect + expect(result).to.be.instanceof(Account).and.eql(account) + }) + }) +}) diff --git a/src/subdomains/accounts/handlers/create-account.handler.mts b/src/subdomains/accounts/handlers/create-account.handler.mts index 4ed7b830..0f8bd828 100644 --- a/src/subdomains/accounts/handlers/create-account.handler.mts +++ b/src/subdomains/accounts/handlers/create-account.handler.mts @@ -18,7 +18,7 @@ import { import bcrypt from 'bcrypt' /** - * User account creation handler. + * Account creation handler. * * @class * @implements {ICommandHandler} @@ -26,7 +26,7 @@ import bcrypt from 'bcrypt' @CommandHandler(CreateAccountCommand) class CreateAccountHandler implements ICommandHandler { /** - * Create a new user account creation handler. + * Create a new account creation handler. * * @param {AccountsRepository} accounts * User accounts repository diff --git a/src/subdomains/accounts/handlers/delete-account.handler.mts b/src/subdomains/accounts/handlers/delete-account.handler.mts new file mode 100644 index 00000000..b6171036 --- /dev/null +++ b/src/subdomains/accounts/handlers/delete-account.handler.mts @@ -0,0 +1,72 @@ +/** + * @file Handlers - DeleteAccountHandler + * @module sneusers/accounts/handlers/DeleteAccount + */ + +import DeleteAccountCommand from '#accounts/commands/delete-account.command' +import Account from '#accounts/entities/account.entity' +import AccountDeletedEvent from '#accounts/events/account-deleted.event' +import AccountsRepository from '#accounts/providers/accounts.repository' +import { + CommandHandler, + EventBus, + type ICommandHandler +} from '@nestjs/cqrs' +import { ok } from 'devlop' + +/** + * Account deletion handler. + * + * @class + * @implements {ICommandHandler} + */ +@CommandHandler(DeleteAccountCommand) +class DeleteAccountHandler implements ICommandHandler { + /** + * Create a new account deletion handler. + * + * @param {AccountsRepository} accounts + * User accounts repository + * @param {EventBus} events + * Event bus + */ + constructor( + protected accounts: AccountsRepository, + protected events: EventBus + ) {} + + /** + * Delete a user account. + * + * > 👉 **Note**: The account referenced by {@linkcode command.uid} is + * > expected to exist. The `ExistingAccountGuard` should be used before + * > attempting to execute `command`. + * + * @public + * @instance + * @async + * + * @param {DeleteAccountCommand} command + * The command to execute + * @return {Promise} + * The deleted user account + */ + public async execute(command: DeleteAccountCommand): Promise { + ok(typeof command.uid === 'string', 'expected account id to be a string') + ok(command.uid, 'expected account id to be non-empty string') + + /** + * The deleted user account. + * + * @const {Account} account + */ + const account: Account = await this.accounts.delete(command.uid) + + // publish domain event. + this.events.publish(new AccountDeletedEvent(account)) + + return account + } +} + +export default DeleteAccountHandler diff --git a/src/subdomains/accounts/index.mts b/src/subdomains/accounts/index.mts index 3dc86a11..eaf48fcc 100644 --- a/src/subdomains/accounts/index.mts +++ b/src/subdomains/accounts/index.mts @@ -9,3 +9,6 @@ export type { default as Role } from '#accounts/enums/role' export type { default as AccountDocument } from '#accounts/interfaces/account.document' +export type { + default as TokenPayload +} from '#accounts/interfaces/token.payload' diff --git a/src/subdomains/accounts/interfaces/__tests__/token.payload.spec-d.mts b/src/subdomains/accounts/interfaces/__tests__/token.payload.spec-d.mts new file mode 100644 index 00000000..35e2e0c0 --- /dev/null +++ b/src/subdomains/accounts/interfaces/__tests__/token.payload.spec-d.mts @@ -0,0 +1,46 @@ +/** + * @file Type Tests - TokenPayload + * @module sneusers/accounts/interfaces/tests/unit-d/TokenPayload + */ + +import type TestSubject from '#accounts/interfaces/token.payload' +import type { Account } from '@flex-development/sneusers/accounts' +import type { JsonObject } from '@flex-development/sneusers/types' + +describe('unit-d:accounts/interfaces/TokenPayload', () => { + it('should extend JsonObject', () => { + expectTypeOf().toExtend() + }) + + it('should match [aud: string]', () => { + expectTypeOf().toHaveProperty('aud').toEqualTypeOf() + }) + + it('should match [email: string]', () => { + expectTypeOf().toHaveProperty('email').toEqualTypeOf() + }) + + it('should match [exp: number]', () => { + expectTypeOf().toHaveProperty('exp').toEqualTypeOf() + }) + + it('should match [iat: number]', () => { + expectTypeOf().toHaveProperty('iat').toEqualTypeOf() + }) + + it('should match [iss: string]', () => { + expectTypeOf().toHaveProperty('iss').toEqualTypeOf() + }) + + it('should match [role: Account["role"]]', () => { + expectTypeOf() + .toHaveProperty('role') + .toEqualTypeOf() + }) + + it('should match [sub: Account["uid"]]', () => { + expectTypeOf() + .toHaveProperty('sub') + .toEqualTypeOf() + }) +}) diff --git a/src/subdomains/accounts/interfaces/token.payload.mts b/src/subdomains/accounts/interfaces/token.payload.mts new file mode 100644 index 00000000..ce6f25ea --- /dev/null +++ b/src/subdomains/accounts/interfaces/token.payload.mts @@ -0,0 +1,55 @@ +/** + * @file Interfaces - TokenPayload + * @module sneusers/accounts/interfaces/TokenPayload + */ + +import type { Account } from '@flex-development/sneusers/accounts' +import type { JsonObject } from '@flex-development/sneusers/types' + +/** + * JWT payload. + * + * @extends {JsonObject} + */ +interface TokenPayload extends JsonObject { + /** + * The hostname of the API the token is intended for. + */ + aud: string + + /** + * The email address of the user the token was created for. + */ + email: string + + /** + * [Unix timestamp][timestamp] indicating when the token expires. + * + * [timestamp]: https://unixtimestamp.com + */ + exp: number + + /** + * [Unix timestamp][timestamp] indicating when the token was issued. + * + * [timestamp]: https://unixtimestamp.com + */ + iat: number + + /** + * The hostname of the API that issued the token. + */ + iss: string + + /** + * The role of the user the token was created for. + */ + role: Account['role'] + + /** + * The id of the account the token was created for. + */ + sub: Account['uid'] +} + +export type { TokenPayload as default } diff --git a/src/subdomains/accounts/services/auth.service.mts b/src/subdomains/accounts/services/auth.service.mts index 09b09ae6..9dab99b6 100644 --- a/src/subdomains/accounts/services/auth.service.mts +++ b/src/subdomains/accounts/services/auth.service.mts @@ -37,7 +37,7 @@ class AuthService { * @async * * @param {Account} account - * The user account to create token for + * The user account to create the token for * @return {Promise} * User account access token */ @@ -60,7 +60,7 @@ class AuthService { * @async * * @param {Account} account - * The user account to create token for + * The user account to create the token for * @return {Promise} * User account access refresh token */ diff --git a/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts b/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts new file mode 100644 index 00000000..ed079d7b --- /dev/null +++ b/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts @@ -0,0 +1,78 @@ +/** + * @file Unit Tests - JwtStrategy + * @module sneusers/accounts/strategies/tests/unit/Jwt + */ + +import Account from '#accounts/entities/account.entity' +import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' +import AccountsRepository from '#accounts/providers/accounts.repository' +import TestSubject from '#accounts/strategies/jwt.strategy' +import ConfigModule from '#modules/config.module' +import AccountFactory from '#tests/utils/account.factory' +import Seeder from '#tests/utils/seeder' +import type { + AccountDocument, + TokenPayload +} from '@flex-development/sneusers/accounts' +import DatabaseModule from '@flex-development/sneusers/database' +import { Test, type TestingModule } from '@nestjs/testing' + +describe('unit:accounts/strategies/JwtStrategy', () => { + let factory: AccountFactory + let ref: TestingModule + let seeder: Seeder + let subject: TestSubject + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + ref = await Test.createTestingModule({ + imports: [ConfigModule, DatabaseModule.forFeature(Account)], + providers: [AccountsRepository, JwtOptionsFactory, TestSubject] + }).compile() + + factory = new AccountFactory() + seeder = new Seeder(factory, ref.get(AccountsRepository)) + subject = ref.get(TestSubject) + + await seeder.up() + }) + + describe('#validate', () => { + it('should return `null` if account is not found', async () => { + // Arrange + const account: Account = new Account(factory.makeOne()) + const params: Record = { uid: account.uid } + const payload: Pick = { sub: account.uid } + + // Act + Expect + expect(await subject.validate({ params }, payload)).to.be.null + }) + + it('should return `null` on uid mismatch', async () => { + // Arrange + const account1: Account = new Account(seeder.seeds[0]!) + const account2: Account = new Account(seeder.seeds[1]!) + const params: Record = { uid: account1.uid } + const payload: Pick = { sub: account2.uid } + + // Act + Expect + expect(await subject.validate({ params }, payload)).to.be.null + }) + + it('should return account of authenticated user', async () => { + // Arrange + const account: Account = new Account(seeder.seeds[2]!) + const params: Record = { uid: account.uid } + const payload: Pick = { sub: account.uid } + + // Act + const result = await subject.validate({ params }, payload) + + // Expect + expect(result).to.be.instanceof(Account).and.eql(account) + }) + }) +}) diff --git a/src/subdomains/accounts/strategies/jwt.strategy.mts b/src/subdomains/accounts/strategies/jwt.strategy.mts index 9ed411b8..111c0631 100644 --- a/src/subdomains/accounts/strategies/jwt.strategy.mts +++ b/src/subdomains/accounts/strategies/jwt.strategy.mts @@ -5,11 +5,11 @@ import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' import AccountsRepository from '#accounts/providers/accounts.repository' -import type { Account } from '@flex-development/sneusers/accounts' -import type { JsonObject } from '@flex-development/sneusers/types' +import type { Account, TokenPayload } from '@flex-development/sneusers/accounts' import { Injectable } from '@nestjs/common' import { PassportStrategy } from '@nestjs/passport' import { ok } from 'devlop' +import type { FastifyRequest } from 'fastify' import { ExtractJwt, Strategy } from 'passport-jwt' /** @@ -41,7 +41,7 @@ class JwtStrategy extends PassportStrategy(Strategy) { ignoreExpiration: false, issuer: signOptions.issuer, jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), - passReqToCallback: false, + passReqToCallback: true, secretOrKey: secret }) } @@ -53,12 +53,20 @@ class JwtStrategy extends PassportStrategy(Strategy) { * @instance * @async * - * @param {JsonObject} payload + * @param {Pick} request + * The incoming request object + * @param {Pick} payload * Token payload * @return {Account | null} * The account of the authenticated user or `null` */ - public async validate(payload: JsonObject): Promise { + public async validate( + request: Pick, + payload: Pick + ): Promise { + const { sub } = payload + const { uid = sub } = request.params + /** * The account of the authenticated user. * @@ -66,9 +74,12 @@ class JwtStrategy extends PassportStrategy(Strategy) { */ let account: Account | null = null - if ('email' in payload && typeof payload['email'] === 'string') { - account = await this.accounts.findByEmail(payload['email']) - } + ok(typeof sub === 'string', 'expected `payload.sub` to be a string') + ok(sub, 'expected `payload.sub` to be a non-empty string') + + // make sure token payload is for the current account + // before searching for the account by id. + if (sub === uid) account = await this.accounts.findById(sub) return account } diff --git a/typings/fastify/types/request.d.ts b/typings/fastify/types/request.d.ts index b4f401f1..cffb9b95 100644 --- a/typings/fastify/types/request.d.ts +++ b/typings/fastify/types/request.d.ts @@ -10,4 +10,17 @@ declare module 'fastify' { */ user?: Account | null | undefined } + + interface Params { + [x: string]: string | undefined + + /** + * The id of a user account. + */ + uid?: string | undefined + } + + interface RouteGenericInterface { + Params: Params + } }