diff --git a/__tests__/utils/stub500.mts b/__tests__/utils/stub500.mts index a28dd253..11205c06 100644 --- a/__tests__/utils/stub500.mts +++ b/__tests__/utils/stub500.mts @@ -4,33 +4,27 @@ */ /** - * Create an object with a method named `method` that throws an error. + * Create a function that throws an error. * * @this {void} * * @param {string} url * The endpoint being tested - * @param {string} method - * The name of the method to stub - * @return {Record never>} - * Object with `method` stub + * @return {(this: void) => never} + * Error stub */ -function stub500( - this: void, - url: string, - method: string -): Record never> { - return { - /** - * @this {void} - * - * @return {never} - * Never - * @throws {Error} - */ - [method](this: void): never { - throw new Error(url) - } +function stub500(this: void, url: string): (this: void) => never { + return stub + + /** + * @this {void} + * + * @return {never} + * Never + * @throws {Error} + */ + function stub(this: void): never { + throw new Error(url) } } diff --git a/src/__snapshots__/app.e2e.snap b/src/__snapshots__/app.e2e.snap index be117cfc..d1a0e851 100644 --- a/src/__snapshots__/app.e2e.snap +++ b/src/__snapshots__/app.e2e.snap @@ -130,6 +130,75 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` "tags": [ "accounts" ] + }, + "get": { + "operationId": "accounts-get", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "description": "id of account to retrieve", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountPayload" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidCredentialException" + } + } + } + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessDeniedException" + } + } + } + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MissingAccountException" + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerException" + } + } + } + } + }, + "security": [ + { + "jwt": [] + } + ], + "tags": [ + "accounts" + ] } }, "/accounts/whoami": { @@ -245,6 +314,33 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` "uid" ] }, + "AccountPayload": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "primary email address", + "format": "email" + }, + "type": { + "type": "string", + "description": "account type", + "enum": [ + "developer", + "user" + ] + }, + "uid": { + "type": "string", + "description": "unique account id" + } + }, + "required": [ + "email", + "type", + "uid" + ] + }, "CreateAccountCommand": { "type": "object", "properties": { diff --git a/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts b/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts index cf77c6a5..db60d72f 100644 --- a/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts +++ b/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts @@ -7,6 +7,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 GetAccountHandler from '#accounts/handlers/get-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' import AuthService from '#accounts/services/auth.service' import routes from '#enums/routes' @@ -32,6 +33,7 @@ import type { IncomingHttpHeaders } from 'node:http' describe('e2e:accounts/AccountsModule', () => { let app: NestFastifyApplication + let auth: AuthService let factory: AccountFactory let metadata: ModuleMetadata let ref: TestingModule @@ -48,6 +50,7 @@ describe('e2e:accounts/AccountsModule', () => { ref = await Test.createTestingModule(metadata).compile() + auth = ref.get(AuthService) factory = new AccountFactory() repo = ref.get(AccountsRepository) app = await createApp(ref) @@ -230,12 +233,16 @@ describe('e2e:accounts/AccountsModule', () => { beforeAll(async () => { app = await createApp( await Test.createTestingModule(metadata) - .overrideProvider(CreateAccountHandler) - .useValue(stub500(url, 'execute')) .overrideProvider(AccountsRepository) .useValue(repo) .compile() ) + }) + + beforeEach(async () => { + vi + .spyOn(CreateAccountHandler.prototype, 'execute') + .mockImplementation(stub500(url)) result = await app.inject({ body: { @@ -269,11 +276,9 @@ describe('e2e:accounts/AccountsModule', () => { }) describe('DELETE /accounts/:uid', () => { - let auth: AuthService let method: Required['method'] beforeAll(() => { - auth = ref.get(AuthService) method = 'delete' }) @@ -312,7 +317,240 @@ describe('e2e:accounts/AccountsModule', () => { account = new Account(seeder.seeds[0]!) }) - it('authentication failure (invalid token)', async () => { + it('fail on invalid token', async () => { + // Arrange + const request: InjectOptions = { + headers: { + authorization: `bearer ${faker.internet.jwt()}` + }, + method, + url: routes.ACCOUNTS + routes.APP + account.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('fail on missing token', async () => { + // Arrange + const request: InjectOptions = { + method, + url: routes.ACCOUNTS + routes.APP + account.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('403 (FORBIDDEN)', () => { + let account1: Account + let account2: Account + let result: Response + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + await seeder.up(2) + account1 = new Account(seeder.seeds[0]!) + account2 = new Account(seeder.seeds[1]!) + + result = await app.inject({ + headers: { + authorization: `bearer ${await auth.accessToken(account2)}` + }, + method, + url: routes.ACCOUNTS + routes.APP + account1.uid + }) + }) + + it('fail on uid mismatch', async () => { + // Act + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.FORBIDDEN) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', HttpStatus.FORBIDDEN) + expect(payload).to.have.property('id', ExceptionId.ACCESS_DENIED) + 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 (authenticated)', 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) + }) + + it('fail on missing account (unauthenticated)', 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) + }) + }) + + 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(AccountsRepository) + .useValue(repo) + .compile() + ) + }) + + beforeEach(async () => { + vi + .spyOn(DeleteAccountHandler.prototype, 'execute') + .mockImplementation(stub500(url)) + + 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/:uid', () => { + let method: Required['method'] + + beforeAll(() => { + method = 'get' + }) + + describe('200 (OK)', () => { + 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 retrieval', () => { + // Act + const payload: JsonObject = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.OK) + expect(payload).to.have.keys(['email', 'type', 'uid']) + expect(payload['email']).to.eq(account.email) + expect(payload['type']).to.eq(account.role) + expect(payload['uid']).to.eq(account.uid) + }) + }) + + describe('401 (UNAUTHORIZED)', () => { + let account: Account + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + await seeder.up(1) + account = new Account(seeder.seeds[0]!) + }) + + it('fail on invalid token', async () => { // Arrange const request: InjectOptions = { headers: { @@ -335,7 +573,7 @@ describe('e2e:accounts/AccountsModule', () => { expect(payload).to.have.property('reason', null) }) - it('authentication failure (missing token)', async () => { + it('fail on missing token', async () => { // Arrange const request: InjectOptions = { method, @@ -379,7 +617,7 @@ describe('e2e:accounts/AccountsModule', () => { }) }) - it('authentication failure (uid mismatch)', async () => { + it('fail on uid mismatch', async () => { // Act const payload = result.json() @@ -457,12 +695,16 @@ describe('e2e:accounts/AccountsModule', () => { app = await createApp( await Test.createTestingModule(metadata) - .overrideProvider(DeleteAccountHandler) - .useValue(stub500(url, 'execute')) .overrideProvider(AccountsRepository) .useValue(repo) .compile() ) + }) + + beforeEach(async () => { + vi + .spyOn(GetAccountHandler.prototype, 'execute') + .mockImplementation(stub500(url)) result = await app.inject({ headers: { @@ -509,11 +751,9 @@ describe('e2e:accounts/AccountsModule', () => { describe('200 (OK)', () => { let account: Account - let auth: AuthService beforeAll(() => { account = new Account(seeder.seeds[3]!) - auth = ref.get(AuthService) }) it.each<'accessToken' | 'refreshToken'>([ diff --git a/src/subdomains/accounts/accounts.module.mts b/src/subdomains/accounts/accounts.module.mts index 3d97acf6..60e948eb 100644 --- a/src/subdomains/accounts/accounts.module.mts +++ b/src/subdomains/accounts/accounts.module.mts @@ -8,6 +8,7 @@ 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 GetAccountHandler from '#accounts/handlers/get-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' import AuthService from '#accounts/services/auth.service' import JwtStrategy from '#accounts/strategies/jwt.strategy' @@ -31,6 +32,7 @@ import { JwtModule } from '@nestjs/jwt' AuthService, CreateAccountHandler, DeleteAccountHandler, + GetAccountHandler, JwtOptionsFactory, JwtStrategy ] diff --git a/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts b/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts index 92a89183..706f757c 100644 --- a/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts +++ b/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts @@ -7,11 +7,14 @@ 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 AccountPayload from '#accounts/dto/account.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 GetAccountHandler from '#accounts/handlers/get-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' +import GetAccountQuery from '#accounts/queries/get-account.query' import AuthService from '#accounts/services/auth.service' import DependenciesModule from '#modules/dependencies.module' import AccountFactory from '#tests/utils/account.factory' @@ -37,7 +40,8 @@ describe('unit:accounts/controllers/AccountsController', () => { AccountsRepository, AuthService, CreateAccountHandler, - DeleteAccountHandler + DeleteAccountHandler, + GetAccountHandler ] }).compile() @@ -81,4 +85,31 @@ describe('unit:accounts/controllers/AccountsController', () => { expect(await subject.delete(params)).to.be.null }) }) + + describe('#get', () => { + let account: Account + let params: GetAccountQuery + let seeder: Seeder + + beforeAll(async () => { + seeder = new Seeder(new AccountFactory(), ref.get(AccountsRepository)) + + await seeder.up(1) + + account = new Account(seeder.seeds[0]!) + params = new GetAccountQuery(account._id) + }) + + it('should return existing account payload', async () => { + // Act + const result = await subject.get(params) + + // Expect + expect(result).to.be.instanceof(AccountPayload) + expect(result).to.have.keys(['email', 'type', 'uid']) + expect(result).to.have.property('email', account.email) + expect(result).to.have.property('type', account.role) + expect(result).to.have.property('uid', account.uid) + }) + }) }) diff --git a/src/subdomains/accounts/controllers/accounts.controller.mts b/src/subdomains/accounts/controllers/accounts.controller.mts index 7ff3f621..435c6a6e 100644 --- a/src/subdomains/accounts/controllers/accounts.controller.mts +++ b/src/subdomains/accounts/controllers/accounts.controller.mts @@ -7,11 +7,12 @@ 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 AccountPayload from '#accounts/dto/account.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 GetAccountQuery from '#accounts/queries/get-account.query' import AuthService from '#accounts/services/auth.service' import AuthStrategy from '#enums/auth-strategy' import routes from '#enums/routes' @@ -44,7 +45,7 @@ import { UseGuards, UsePipes } from '@nestjs/common' -import { CommandBus } from '@nestjs/cqrs' +import { CommandBus, QueryBus } from '@nestjs/cqrs' import { ApiBadRequestResponse, ApiBearerAuth, @@ -79,10 +80,16 @@ class AccountsController { * * @param {CommandBus} commands * The command bus + * @param {QueryBus} queries + * The query bus * @param {AuthService} auth * Authentication and authorization service */ - constructor(protected commands: CommandBus, protected auth: AuthService) {} + constructor( + protected commands: CommandBus, + protected queries: QueryBus, + protected auth: AuthService + ) {} /** * Create a new account using email and password. @@ -138,7 +145,6 @@ class AccountsController { @HttpCode(HttpStatus.NO_CONTENT) @UseGuards(JwtGuard) @UseGuards(ExistingAccountGuard) - @UseFilters(UnauthorizedExceptionFilter) @ApiBearerAuth(AuthStrategy.JWT) @ApiNoContentResponse() @ApiUnauthorizedResponse({ type: InvalidCredentialException }) @@ -149,6 +155,34 @@ class AccountsController { return await this.commands.execute(params), null } + /** + * Get an account by id. + * + * @public + * @instance + * @async + * + * @param {GetAccountQuery} params + * Route parameters object + * @param {string} params.uid + * The id of the account to retrieve + * @return {Promise} + * Account payload + */ + @Get(subroutes.ACCOUNTS_UID) + @HttpCode(HttpStatus.OK) + @UseGuards(JwtGuard) + @UseGuards(ExistingAccountGuard) + @ApiBearerAuth(AuthStrategy.JWT) + @ApiOkResponse({ type: AccountPayload }) + @ApiUnauthorizedResponse({ type: InvalidCredentialException }) + @ApiForbiddenResponse({ type: AccessDeniedException }) + @ApiNotFoundResponse({ type: MissingAccountException }) + public async get(@Param() params: GetAccountQuery): Promise { + ok(params instanceof GetAccountQuery, 'expected a query') + return new AccountPayload(await this.queries.execute(params)) + } + /** * Check authentication. * diff --git a/src/subdomains/accounts/dto/__tests__/account.payload.spec.mts b/src/subdomains/accounts/dto/__tests__/account.payload.spec.mts new file mode 100644 index 00000000..1a506678 --- /dev/null +++ b/src/subdomains/accounts/dto/__tests__/account.payload.spec.mts @@ -0,0 +1,37 @@ +/** + * @file Unit Tests - AccountPayload + * @module sneusers/accounts/dto/tests/unit/AccountPayload + */ + +import TestSubject from '#accounts/dto/account.payload' +import Account from '#accounts/entities/account.entity' +import AccountFactory from '#tests/utils/account.factory' + +describe('unit:accounts/dto/AccountPayload', () => { + 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 #email', () => { + expect(subject).to.have.property('email', account.email) + }) + + it('should set #type', () => { + expect(subject).to.have.property('type', account.role) + }) + + it('should set #uid', () => { + expect(subject).to.have.property('uid', account.uid) + }) + }) +}) diff --git a/src/subdomains/accounts/dto/account-created.payload.mts b/src/subdomains/accounts/dto/account-created.payload.mts index e89bdb6a..d8ecd3d0 100644 --- a/src/subdomains/accounts/dto/account-created.payload.mts +++ b/src/subdomains/accounts/dto/account-created.payload.mts @@ -50,7 +50,7 @@ class AccountCreatedPayload { public uid: string /** - * Create a new account payload. + * Create a new account creation payload. * * @param {Account} account * The new account diff --git a/src/subdomains/accounts/dto/account.payload.mts b/src/subdomains/accounts/dto/account.payload.mts new file mode 100644 index 00000000..bf75e343 --- /dev/null +++ b/src/subdomains/accounts/dto/account.payload.mts @@ -0,0 +1,64 @@ +/** + * @file Data Transfer Objects - AccountPayload + * @module sneusers/accounts/dto/AccountPayload + */ + +import Role from '#accounts/enums/role' +import type { Account } from '@flex-development/sneusers/accounts' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * Successful account retrieval response. + * + * @class + */ +@ApiSchema() +class AccountPayload { + /** + * User email address. + * + * @public + * @instance + * @member {Account['email']} email + */ + @ApiProperty({ + description: 'primary email address', + format: 'email', + type: 'string' + }) + public email: Account['email'] + + /** + * Account type. + * + * @public + * @instance + * @member {Account['role']} role + */ + @ApiProperty({ description: 'account type', enum: Role }) + public type: Account['role'] + + /** + * Unique account id. + * + * @public + * @instance + * @member {Account['uid']} uid + */ + @ApiProperty({ description: 'unique account id', type: 'string' }) + public uid: Account['uid'] + + /** + * Create a new account payload. + * + * @param {Account} account + * The accessed account + */ + constructor(account: Account) { + this.uid = account.uid + this.type = account.role + this.email = account.email + } +} + +export default AccountPayload diff --git a/src/subdomains/accounts/filters/unauthorized.filter.mts b/src/subdomains/accounts/filters/unauthorized.filter.mts deleted file mode 100644 index 3e212bba..00000000 --- a/src/subdomains/accounts/filters/unauthorized.filter.mts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @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 index 63e0e206..d1af32d1 100644 --- a/src/subdomains/accounts/guards/existing-account.guard.mts +++ b/src/subdomains/accounts/guards/existing-account.guard.mts @@ -3,15 +3,13 @@ * @module sneusers/accounts/guards/ExistingAccount */ -import AccountsRepository from '#accounts/providers/accounts.repository' -import { - MissingAccountException -} from '@flex-development/sneusers/accounts/errors' +import GetAccountQuery from '#accounts/queries/get-account.query' import { Injectable, type CanActivate, type ExecutionContext } from '@nestjs/common' +import { QueryBus } from '@nestjs/cqrs' import type { FastifyRequest } from 'fastify' /** @@ -25,10 +23,10 @@ class ExistingAccountGuard implements CanActivate { /** * Create a new existing account guard. * - * @param {AccountsRepository} accounts - * User accounts repository + * @param {QueryBus} queries + * The query bus */ - constructor(protected accounts: AccountsRepository) {} + constructor(protected queries: QueryBus) {} /** * Check `request.params.uid` references an existing account. @@ -43,8 +41,6 @@ class ExistingAccountGuard implements CanActivate { * 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 { /** @@ -54,16 +50,9 @@ class ExistingAccountGuard implements CanActivate { */ 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) - } + // check for existing user account. + // the query handler will throw if an account is not found. + await this.queries.execute(new GetAccountQuery(String(req.params.uid))) return true } diff --git a/src/subdomains/accounts/guards/jwt.guard.mts b/src/subdomains/accounts/guards/jwt.guard.mts index ee40ca19..5f1c9b0e 100644 --- a/src/subdomains/accounts/guards/jwt.guard.mts +++ b/src/subdomains/accounts/guards/jwt.guard.mts @@ -4,6 +4,10 @@ */ import AuthStrategy from '#enums/auth-strategy' +import type { Account } from '@flex-development/sneusers/accounts' +import { + InvalidCredentialException +} from '@flex-development/sneusers/accounts/errors' import { Injectable } from '@nestjs/common' import { AuthGuard, type IAuthGuard } from '@nestjs/passport' @@ -14,6 +18,33 @@ import { AuthGuard, type IAuthGuard } from '@nestjs/passport' * @implements {IAuthGuard} */ @Injectable() -class JwtGuard extends AuthGuard(AuthStrategy.JWT) implements IAuthGuard {} +class JwtGuard extends AuthGuard(AuthStrategy.JWT) implements IAuthGuard { + /** + * Handle an authentication request. + * + * @public + * @instance + * @override + * + * @template {any} [T=Account] + * User data + * + * @param {Error | null} error + * Error thrown by authentication strategy + * @param {T | false | null | undefined} account + * The account of the authenticated user if authentication is successful + * @return {T} + * The account of the authenticated user + * @throws {Error | InvalidCredentialException} + * If authentication failed + */ + public override handleRequest( + error: Error | null, + account: T | false | null | undefined + ): T { + if (error || !account) throw error ?? new InvalidCredentialException() + return account + } +} export default JwtGuard diff --git a/src/subdomains/accounts/handlers/__tests__/get-account.handler.spec.mts b/src/subdomains/accounts/handlers/__tests__/get-account.handler.spec.mts new file mode 100644 index 00000000..f59b74a3 --- /dev/null +++ b/src/subdomains/accounts/handlers/__tests__/get-account.handler.spec.mts @@ -0,0 +1,67 @@ +/** + * @file Unit Tests - GetAccountHandler + * @module sneusers/accounts/handlers/tests/unit/GetAccount + */ + +import Account from '#accounts/entities/account.entity' +import MissingAccountException from '#accounts/errors/missing-account.exception' +import TestSubject from '#accounts/handlers/get-account.handler' +import AccountsRepository from '#accounts/providers/accounts.repository' +import GetAccountQuery from '#accounts/queries/get-account.query' +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 { Test, type TestingModule } from '@nestjs/testing' +import { ObjectId } from 'bson' + +describe('unit:accounts/handlers/GetAccountHandler', () => { + let ref: TestingModule + let seeder: Seeder + let subject: TestSubject + + afterAll(async () => { + await seeder.down() + }) + + beforeAll(async () => { + ref = await Test.createTestingModule({ + imports: [DatabaseModule.forFeature(Account)], + providers: [AccountsRepository, TestSubject] + }).compile() + + seeder = new Seeder(new AccountFactory(), ref.get(AccountsRepository)) + subject = ref.get(TestSubject) + + await seeder.up(1) + }) + + describe('#execute', () => { + it('should return user account referenced by `query.uid`', async () => { + // Arrange + const account: Account = new Account(seeder.seeds[0]!) + const query: GetAccountQuery = new GetAccountQuery(account.uid) + + // Act + const result = await subject.execute(query) + + // Expect + expect(result).to.be.instanceof(Account).and.eql(account) + }) + + it('should throw if an account is not found', async () => { + // Arrange + let error!: MissingAccountException + + // Act + try { + await subject.execute(new GetAccountQuery(new ObjectId())) + } catch (e: unknown) { + error = e as typeof error + } + + // Expect + expect(error).to.be.instanceof(MissingAccountException) + }) + }) +}) diff --git a/src/subdomains/accounts/handlers/get-account.handler.mts b/src/subdomains/accounts/handlers/get-account.handler.mts new file mode 100644 index 00000000..3ab40a20 --- /dev/null +++ b/src/subdomains/accounts/handlers/get-account.handler.mts @@ -0,0 +1,59 @@ +/** + * @file Handlers - GetAccountHandler + * @module sneusers/accounts/handlers/GetAccount + */ + +import Account from '#accounts/entities/account.entity' +import MissingAccountException from '#accounts/errors/missing-account.exception' +import AccountsRepository from '#accounts/providers/accounts.repository' +import GetAccountQuery from '#accounts/queries/get-account.query' +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs' + +/** + * Account query handler. + * + * @class + * @implements {IQueryHandler} + */ +@QueryHandler(GetAccountQuery) +class GetAccountHandler implements IQueryHandler { + /** + * Create a new account query handler. + * + * @param {AccountsRepository} accounts + * User accounts repository + */ + constructor(protected accounts: AccountsRepository) {} + + /** + * Get a user account by id. + * + * Fails if an account is not found. + * + * @public + * @instance + * @async + * + * @param {GetAccountQuery} query + * The query to execute + * @return {Promise} + * The user account referenced by {@linkcode query.uid} + * @throws {MissingAccountException} + * If an account is not found + */ + public async execute(query: GetAccountQuery): Promise { + /** + * The user account referenced by {@linkcode query.uid}. + * + * @const {Account | null} account + */ + const account: Account | null = await this.accounts.findById(query.uid) + + // throw on missing account. + if (!account) throw new MissingAccountException(query.uid) + + return account + } +} + +export default GetAccountHandler diff --git a/src/subdomains/accounts/queries/get-account.query.mts b/src/subdomains/accounts/queries/get-account.query.mts new file mode 100644 index 00000000..09815d30 --- /dev/null +++ b/src/subdomains/accounts/queries/get-account.query.mts @@ -0,0 +1,41 @@ +/** + * @file Queries - GetAccountQuery + * @module sneusers/accounts/queries/GetAccount + */ + +import type { Account } from '@flex-development/sneusers/accounts' +import { Query } from '@nestjs/cqrs' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' +import type { ObjectId } from 'bson' + +/** + * Get account query. + * + * @class + * @extends {Query} + */ +@ApiSchema() +class GetAccountQuery extends Query { + /** + * The id of the account to retrieve. + * + * @public + * @instance + * @member {string} uid + */ + @ApiProperty({ description: 'id of account to retrieve', type: 'string' }) + public uid!: string + + /** + * Create a new account query. + * + * @param {ObjectId | string | null | undefined} [uid] + * The id of the account to retrieve + */ + constructor(uid?: ObjectId | string | null | undefined) { + super() + if (uid !== null && uid !== undefined) this.uid = String(uid) + } +} + +export default GetAccountQuery diff --git a/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts b/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts index f130bab1..0d909dd6 100644 --- a/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts +++ b/src/subdomains/accounts/strategies/__tests__/jwt.strategy.spec.mts @@ -6,9 +6,10 @@ import Account from '#accounts/entities/account.entity' import AccessDeniedException from '#accounts/errors/access-denied.exception' import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' +import GetAccountHandler from '#accounts/handlers/get-account.handler' import AccountsRepository from '#accounts/providers/accounts.repository' import TestSubject from '#accounts/strategies/jwt.strategy' -import ConfigModule from '#modules/config.module' +import DependenciesModule from '#modules/dependencies.module' import AccountFactory from '#tests/utils/account.factory' import Seeder from '#tests/utils/seeder' import type { @@ -30,28 +31,24 @@ describe('unit:accounts/strategies/JwtStrategy', () => { beforeAll(async () => { ref = await Test.createTestingModule({ - imports: [ConfigModule, DatabaseModule.forFeature(Account)], - providers: [AccountsRepository, JwtOptionsFactory, TestSubject] + imports: [DatabaseModule.forFeature(Account), DependenciesModule], + providers: [ + AccountsRepository, + GetAccountHandler, + JwtOptionsFactory, + TestSubject + ] }).compile() factory = new AccountFactory() seeder = new Seeder(factory, ref.get(AccountsRepository)) subject = ref.get(TestSubject) + await ref.init() 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 account of authenticated user', async () => { // Arrange const account: Account = new Account(seeder.seeds[2]!) diff --git a/src/subdomains/accounts/strategies/jwt.strategy.mts b/src/subdomains/accounts/strategies/jwt.strategy.mts index cc949b2f..6df2e92c 100644 --- a/src/subdomains/accounts/strategies/jwt.strategy.mts +++ b/src/subdomains/accounts/strategies/jwt.strategy.mts @@ -5,9 +5,10 @@ import AccessDeniedException from '#accounts/errors/access-denied.exception' import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' -import AccountsRepository from '#accounts/providers/accounts.repository' +import GetAccountQuery from '#accounts/queries/get-account.query' import type { Account, TokenPayload } from '@flex-development/sneusers/accounts' import { Injectable } from '@nestjs/common' +import { QueryBus } from '@nestjs/cqrs' import { PassportStrategy } from '@nestjs/passport' import { ok } from 'devlop' import type { FastifyRequest } from 'fastify' @@ -23,15 +24,12 @@ class JwtStrategy extends PassportStrategy(Strategy) { /** * Create a new JWT authentication strategy. * - * @param {AccountsRepository} accounts - * User accounts repository + * @param {QueryBus} queries + * The query bus * @param {JwtOptionsFactory} options * JWT options factory */ - constructor( - protected accounts: AccountsRepository, - options: JwtOptionsFactory - ) { + constructor(protected queries: QueryBus, options: JwtOptionsFactory) { const { secret, signOptions } = options.createJwtOptions() ok(typeof secret === 'string', 'expected `secret` to be a string') @@ -58,15 +56,15 @@ class JwtStrategy extends PassportStrategy(Strategy) { * The incoming request object * @param {Pick} payload * Token payload - * @return {Account | null} - * The account of the authenticated user or `null` + * @return {Account} + * The account of the authenticated user * @throws {AccessDeniedException} * If `request.params.uid` and `payload.sub` do not match */ public async validate( request: Pick, payload: Pick - ): Promise { + ): Promise { const { sub } = payload const { uid = sub } = request.params @@ -76,7 +74,7 @@ class JwtStrategy extends PassportStrategy(Strategy) { // make sure token payload is for the current account. if (sub !== uid) throw new AccessDeniedException() - return this.accounts.findById(sub) + return this.queries.execute(new GetAccountQuery(sub)) } }