diff --git a/__fixtures__/.gitkeep b/__fixtures__/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/__fixtures__/account-password.mts b/__fixtures__/account-password.mts new file mode 100644 index 00000000..9bfaa13a --- /dev/null +++ b/__fixtures__/account-password.mts @@ -0,0 +1,13 @@ +/** + * @file Fixtures - ACCOUNT_PASSWORD + * @module fixtures/ACCOUNT_PASSWORD + */ + +/** + * User account password. + * + * @const {string} ACCOUNT_PASSWORD + */ +const ACCOUNT_PASSWORD: string = 'password' + +export default ACCOUNT_PASSWORD diff --git a/__fixtures__/date.mts b/__fixtures__/date.mts new file mode 100644 index 00000000..8f9f9109 --- /dev/null +++ b/__fixtures__/date.mts @@ -0,0 +1,13 @@ +/** + * @file Fixtures - date + * @module fixtures/date + */ + +/** + * Fixture date. + * + * @const {Date} date + */ +const date: Date = new Date(2025, 2, 13) + +export default date diff --git a/__tests__/utils/account.factory.mts b/__tests__/utils/account.factory.mts new file mode 100644 index 00000000..aeb06dc6 --- /dev/null +++ b/__tests__/utils/account.factory.mts @@ -0,0 +1,55 @@ +/** + * @file Test Utilities - AccountFactory + * @module tests/utils/AccountFactory + */ + +import Role from '#accounts/enums/role' +import ACCOUNT_PASSWORD from '#fixtures/account-password' +import SeedFactory from '#tests/utils/seed.factory' +import type { AccountDocument } from '@flex-development/sneusers/accounts' +import bcrypt from 'bcrypt' +import { ObjectId } from 'bson' +import random from 'random-item' + +/** + * Account document factory. + * + * @class + * @extends {SeedFactory} + */ +class AccountFactory extends SeedFactory { + /** + * Get a random user account role. + * + * @public + * @static + * + * @return {Role} + * Random user account role + */ + public static get role(): Role { + return random(Object.values(Role)) + } + + /** + * Create a random account collection document. + * + * @public + * @instance + * + * @return {AccountDocument} + * Account collection document + */ + public makeOne(): AccountDocument { + return { + _id: new ObjectId(), + created_at: Date.now(), + email: this.faker.internet.email({ provider: 'test.sneusers.app' }), + password: bcrypt.hashSync(ACCOUNT_PASSWORD, 10), + role: AccountFactory.role, + updated_at: null + } + } +} + +export default AccountFactory diff --git a/__tests__/utils/error-payload-keys.mts b/__tests__/utils/error-payload-keys.mts new file mode 100644 index 00000000..5925b7c4 --- /dev/null +++ b/__tests__/utils/error-payload-keys.mts @@ -0,0 +1,13 @@ +/** + * @file Test Utilities - ERROR_PAYLOAD_KEYS + * @module tests/utils/ERROR_PAYLOAD_KEYS + */ + +/** + * List of expected error payload keys. + * + * @const {string[]} ERROR_PAYLOAD_KEYS + */ +const ERROR_PAYLOAD_KEYS: string[] = ['code', 'id', 'message', 'reason'] + +export default ERROR_PAYLOAD_KEYS diff --git a/package.json b/package.json index 61dfd813..164f999e 100644 --- a/package.json +++ b/package.json @@ -29,12 +29,17 @@ "src" ], "exports": { + "./accounts": "./src/subdomains/accounts/index.mts", + "./accounts/errors": "./src/subdomains/accounts/errors/index.mts", "./database": "./src/database/index.mts", "./errors": "./src/errors/index.mts", "./package.json": "./package.json", "./types": "./src/types.mts" }, "imports": { + "#accounts/index": null, + "#accounts/*/index": null, + "#accounts/*": "./src/subdomains/accounts/*.mts", "#app": "./src/app.mts", "#database/index": null, "#database/*": "./src/database/*.mts", @@ -42,11 +47,13 @@ "#enums/*": "./src/enums/*.mts", "#errors/index": null, "#errors/*": "./src/errors/*.mts", + "#filters/*": "./src/filters/*.mts", "#fixtures/*": "./__fixtures__/*.mts", "#hooks/*": "./src/hooks/*.mts", "#interfaces/*": "./src/interfaces/*.mts", "#models/*": "./src/models/*.mts", "#modules/*": "./src/modules/*.mts", + "#pipes/*": "./src/pipes/*.mts", "#tests/*": "./__tests__/*.mts", "#types/*": "./src/types/*.mts" }, diff --git a/src/__snapshots__/app.e2e.snap b/src/__snapshots__/app.e2e.snap index 10012afc..c3e35f7a 100644 --- a/src/__snapshots__/app.e2e.snap +++ b/src/__snapshots__/app.e2e.snap @@ -11,9 +11,341 @@ exports[`e2e:app > GET / > should respond with api documentation (json) 1`] = ` }, "servers": [], "tags": [], - "paths": {}, + "paths": { + "/accounts": { + "post": { + "operationId": "accounts-create", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAccountCommand" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreatedPayload" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationException" + } + } + } + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailConflictException" + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerException" + } + } + } + } + }, + "tags": [ + "accounts" + ] + } + }, + "/accounts/whoami": { + "get": { + "operationId": "accounts-whoami", + "parameters": [ + { + "name": "authorization", + "in": "header", + "required": false, + "examples": { + "bearer": { + "value": "bearer " + } + }, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiPayload" + } + } + } + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WhoamiPayload" + } + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InternalServerException" + } + } + } + } + }, + "tags": [ + "accounts" + ] + } + } + }, "components": { - "schemas": {} + "schemas": { + "AccountCreatedPayload": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "description": "token for authenticating requests" + }, + "refresh_token": { + "type": "string", + "description": "token for renewing access tokens" + }, + "uid": { + "type": "string", + "description": "unique account id" + } + }, + "required": [ + "access_token", + "refresh_token", + "uid" + ] + }, + "CreateAccountCommand": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "email address of new user", + "format": "email" + }, + "password": { + "type": "string", + "description": "password for account", + "format": "password", + "minLength": 6 + }, + "type": { + "type": "string", + "description": "type of account to create", + "enum": [ + "developer", + "user" + ] + } + }, + "required": [ + "email", + "password", + "type" + ] + }, + "EmailConflict": { + "type": "object", + "properties": { + "email": { + "type": "string", + "description": "the conflicting email address" + } + }, + "required": [ + "email" + ] + }, + "EmailConflictException": { + "type": "object", + "properties": { + "code": { + "type": "number", + "description": "http response status code", + "enum": [ + 409 + ] + }, + "id": { + "type": "string", + "description": "unique id representing the exception", + "enum": [ + "accounts/email-conflict" + ] + }, + "message": { + "type": "string", + "description": "human-readable description of the exception" + }, + "reason": { + "$ref": "#/components/schemas/EmailConflict" + } + }, + "required": [ + "code", + "id", + "message", + "reason" + ] + }, + "InternalServerException": { + "type": "object", + "properties": { + "code": { + "type": "number", + "description": "http response status code", + "enum": [ + 500 + ] + }, + "id": { + "type": "string", + "description": "unique id representing the exception", + "enum": [ + "sneusers/internal-error" + ] + }, + "message": { + "type": "string", + "description": "human-readable description of the exception" + }, + "reason": { + "type": "null" + } + }, + "required": [ + "code", + "id", + "message", + "reason" + ] + }, + "ValidationException": { + "type": "object", + "properties": { + "code": { + "type": "number", + "description": "http response status code", + "enum": [ + 400 + ] + }, + "id": { + "type": "string", + "description": "unique id representing the exception", + "enum": [ + "sneusers/validation-failure" + ] + }, + "message": { + "type": "string", + "description": "human-readable description of the exception" + }, + "reason": { + "$ref": "#/components/schemas/ValidationFailure" + } + }, + "required": [ + "code", + "id", + "message", + "reason" + ] + }, + "ValidationFailure": { + "type": "object", + "properties": { + "constraints": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "violated validation constraints" + }, + "property": { + "type": "string", + "description": "the name of the property that caused the validation failure" + }, + "value": { + "description": "the property value that caused the validation failure", + "oneOf": [ + { + "type": "array" + }, + { + "type": "boolean" + }, + { + "type": "null" + }, + { + "type": "number" + }, + { + "type": "object" + }, + { + "type": "string" + } + ] + } + }, + "required": [ + "constraints", + "property" + ] + }, + "WhoamiPayload": { + "type": "object", + "properties": { + "uid": { + "description": "unique account id", + "oneOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "uid" + ] + } + } } } `; diff --git a/src/enums/auth-strategy.mts b/src/enums/auth-strategy.mts new file mode 100644 index 00000000..266762de --- /dev/null +++ b/src/enums/auth-strategy.mts @@ -0,0 +1,15 @@ +/** + * @file Enums - AuthStrategy + * @module sneusers/enums/AuthStrategy + */ + +/** + * Authentication strategies. + * + * @enum {Lowercase} + */ +const enum AuthStrategy { + JWT = 'jwt' +} + +export default AuthStrategy diff --git a/src/enums/routes.mts b/src/enums/routes.mts index 506ce882..c9d9eefb 100644 --- a/src/enums/routes.mts +++ b/src/enums/routes.mts @@ -9,6 +9,7 @@ * @enum {string} */ enum routes { + ACCOUNTS = '/accounts', APP = '/' } diff --git a/src/enums/subroutes.mts b/src/enums/subroutes.mts new file mode 100644 index 00000000..666f0061 --- /dev/null +++ b/src/enums/subroutes.mts @@ -0,0 +1,16 @@ +/** + * @file Enums - subroutes + * @module sneusers/enums/subroutes + */ + +/** + * API sub routes. + * + * @enum {string} + */ +const enum subroutes { + ACCOUNTS_CREATE = '', + ACCOUNTS_WHOAMI = '/whoami' +} + +export default subroutes diff --git a/src/errors/enums/exception-id.mts b/src/errors/enums/exception-id.mts index a6008296..8a286fc4 100644 --- a/src/errors/enums/exception-id.mts +++ b/src/errors/enums/exception-id.mts @@ -9,6 +9,7 @@ * @enum {Lowercase} */ enum ExceptionId { + EMAIL_CONFLICT = 'accounts/email-conflict', INTERNAL_SERVER_ERROR = 'sneusers/internal-error', VALIDATION_FAILURE = 'sneusers/validation-failure' } diff --git a/src/errors/models/__snapshots__/base.exception.snap b/src/errors/models/__snapshots__/base.exception.snap index 0c13922c..4a545276 100644 --- a/src/errors/models/__snapshots__/base.exception.snap +++ b/src/errors/models/__snapshots__/base.exception.snap @@ -3,7 +3,7 @@ exports[`unit:errors/models/Exception > #toJSON > should return error as json object 1`] = ` { "code": 409, - "id": "sneusers/email-conflict", + "id": "accounts/email-conflict", "message": "Email address must be unique", "reason": { "email": "unicornware@sneusers.app", diff --git a/src/errors/models/__tests__/base.exception.spec.mts b/src/errors/models/__tests__/base.exception.spec.mts index 8ac20be3..5d8d7fd5 100644 --- a/src/errors/models/__tests__/base.exception.spec.mts +++ b/src/errors/models/__tests__/base.exception.spec.mts @@ -4,7 +4,7 @@ */ import ExceptionCode from '#errors/enums/exception-code' -import type ExceptionId from '#errors/enums/exception-id' +import ExceptionId from '#errors/enums/exception-id' import TestSubject from '#errors/models/base.exception' import type { ExceptionInfo, Reason } from '@flex-development/sneusers/errors' import type { JsonObject } from '@flex-development/sneusers/types' @@ -21,7 +21,7 @@ describe('unit:errors/models/Exception', () => { beforeAll(() => { cause = { email: 'unicornware@sneusers.app' } code = ExceptionCode.CONFLICT - id = 'sneusers/email-conflict' as ExceptionId + id = ExceptionId.EMAIL_CONFLICT message = 'Email address must be unique' reason = { toJSON: constant(cause) } diff --git a/src/filters/exception.filter.mts b/src/filters/exception.filter.mts new file mode 100644 index 00000000..a6da00c4 --- /dev/null +++ b/src/filters/exception.filter.mts @@ -0,0 +1,43 @@ +/** + * @file Filters - ExceptionFilter + * @module sneusers/filters/ExceptionFilter + */ + +import { Exception } from '@flex-development/sneusers/errors' +import { + Catch, + type ArgumentsHost, + type ExceptionFilter as IExceptionFilter +} from '@nestjs/common' +import type { FastifyReply } from 'fastify' + +/** + * API exception filter. + * + * @class + * @implements {IExceptionFilter} + */ +@Catch(Exception) +class ExceptionFilter implements IExceptionFilter { + /** + * Send an exception response. + * + * @public + * @instance + * + * @param {Exception} e + * The exception to handle + * @param {ArgumentsHost} host + * Methods for retrieving arguments passed to execution context handler + * @return {undefined} + */ + public catch(e: Exception, host: ArgumentsHost): undefined { + return void host.switchToHttp() + .getResponse() + .header('content-type', 'application/json') + .status(e.code) + .send(e.toJSON()) + } +} + +export default ExceptionFilter diff --git a/src/filters/unhandled.filter.mts b/src/filters/unhandled.filter.mts new file mode 100644 index 00000000..0f3bb6bf --- /dev/null +++ b/src/filters/unhandled.filter.mts @@ -0,0 +1,46 @@ +/** + * @file Filters - UnhandledExceptionFilter + * @module sneusers/filters/UnhandledExceptionFilter + */ + +import { + ExceptionCode, + InternalServerException +} from '@flex-development/sneusers/errors' +import { + Catch, + type ArgumentsHost, + type ExceptionFilter as IExceptionFilter +} from '@nestjs/common' +import type { FastifyReply } from 'fastify' + +/** + * Unhandled error filter. + * + * @class + * @implements {IExceptionFilter} + */ +@Catch() +class UnhandledExceptionFilter implements IExceptionFilter { + /** + * Send an internal server exception response. + * + * @public + * @instance + * + * @param {Error} e + * The error to handle + * @param {ArgumentsHost} host + * Methods for retrieving arguments passed to execution context handler + * @return {undefined} + */ + public catch(e: Error, host: ArgumentsHost): undefined { + return void host.switchToHttp() + .getResponse() + .header('content-type', 'application/json') + .status(ExceptionCode.INTERNAL_SERVER_ERROR) + .send(new InternalServerException(e).toJSON()) + } +} + +export default UnhandledExceptionFilter diff --git a/src/hooks/use-swagger.hook.mts b/src/hooks/use-swagger.hook.mts index 566aff10..3e20186a 100644 --- a/src/hooks/use-swagger.hook.mts +++ b/src/hooks/use-swagger.hook.mts @@ -67,8 +67,6 @@ function useSwagger(this: void, app: INestApplication): undefined { ) } -/* v8 ignore start */ - /** * Generate an `operationId` based on controller and method name. * @@ -91,8 +89,6 @@ function operationIdFactory( return lowercase(controller.replace('Controller', '') + '-' + method) } -/* v8 ignore stop */ - /** * Modify the generated API `documentation` on request. * diff --git a/src/modules/app.module.mts b/src/modules/app.module.mts index 0b8dcd36..0ec63a16 100644 --- a/src/modules/app.module.mts +++ b/src/modules/app.module.mts @@ -4,6 +4,7 @@ */ import DependenciesModule from '#modules/dependencies.module' +import AccountsModule from '@flex-development/sneusers/accounts' import { Module } from '@nestjs/common' /** @@ -11,7 +12,7 @@ import { Module } from '@nestjs/common' * * @class */ -@Module({ imports: [DependenciesModule] }) +@Module({ imports: [AccountsModule, DependenciesModule] }) class AppModule {} export default AppModule diff --git a/src/pipes/transform.pipe.mts b/src/pipes/transform.pipe.mts new file mode 100644 index 00000000..7dfa3550 --- /dev/null +++ b/src/pipes/transform.pipe.mts @@ -0,0 +1,63 @@ +/** + * @file Pipes - TransformPipe + * @module sneusers/pipes/Transform + */ + +import { constant } from '@flex-development/tutils' +import { + Injectable, + Optional, + ValidationPipe +} from '@nestjs/common' +import type { ValidatorPackage } from '@nestjs/common/interfaces/external/validator-package.interface' +import type { ClassTransformOptions } from 'class-transformer' +import * as transformerPackage from 'class-transformer' + +/** + * Transform plain objects into class instances. + * + * @see https://github.com/typestack/class-transformer + * + * @class + * @extends {ValidationPipe} + */ +@Injectable() +class TransformPipe extends ValidationPipe { + /** + * Transformation options. + * + * @protected + * @override + * @instance + * @member {ClassTransformOptions} transformOptions + */ + declare protected transformOptions: ClassTransformOptions + + /** + * Create a new transform pipe. + * + * @param {ClassTransformOptions | null | undefined} [options] + * Transform options + */ + constructor(@Optional() options?: ClassTransformOptions | null | undefined) { + super({ + transform: true, + transformOptions: { ...options }, + transformerPackage + }) + } + + /** + * @protected + * @instance + * @override + * + * @return {ValidatorPackage} + * Dummy validator package + */ + protected override loadValidator(): ValidatorPackage { + return { validate: constant([]) } + } +} + +export default TransformPipe diff --git a/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts b/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts new file mode 100644 index 00000000..7fa2fafe --- /dev/null +++ b/src/subdomains/accounts/__tests__/accounts.module.e2e.spec.mts @@ -0,0 +1,324 @@ +/** + * @file E2E Tests - AccountsModule + * @module sneusers/accounts/tests/e2e/AccountsModule + */ + +import TestSubject from '#accounts/accounts.module' +import Account from '#accounts/entities/account.entity' +import CreateAccountHandler from '#accounts/handlers/create-account.handler' +import AccountsRepository from '#accounts/providers/accounts.repository' +import AuthService from '#accounts/services/auth.service' +import routes from '#enums/routes' +import subroutes from '#enums/subroutes' +import ACCOUNT_PASSWORD from '#fixtures/account-password' +import DependenciesModule from '#modules/dependencies.module' +import AccountFactory from '#tests/utils/account.factory' +import createApp from '#tests/utils/create-app' +import ERROR_PAYLOAD_KEYS from '#tests/utils/error-payload-keys' +import Seeder from '#tests/utils/seeder' +import stub500 from '#tests/utils/stub500' +import type { AccountDocument } from '@flex-development/sneusers/accounts' +import { ExceptionId } from '@flex-development/sneusers/errors' +import type { + JsonObject, + JsonPrimitive +} from '@flex-development/sneusers/types' +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' + +describe('e2e:accounts/AccountsModule', () => { + let app: NestFastifyApplication + let metadata: ModuleMetadata + let ref: TestingModule + let repo: AccountsRepository + let seeder: Seeder + + afterAll(async () => { + await seeder.down() + await app.close() + }) + + beforeAll(async () => { + metadata = { imports: [DependenciesModule, TestSubject] } + + ref = await Test.createTestingModule(metadata).compile() + + repo = ref.get(AccountsRepository) + app = await createApp(ref) + + seeder = new Seeder(new AccountFactory(), repo) + await seeder.up() + }) + + describe('POST /accounts', () => { + let headers: Required['headers'] + let method: Required['method'] + let url: string + + beforeAll(() => { + headers = { 'content-type': 'application/json' } + method = 'post' + url = routes.ACCOUNTS + }) + + describe('201 (CREATED)', () => { + let result: Response + + beforeAll(async () => { + result = await app.inject({ + body: { + email: faker.internet.email(), + password: ACCOUNT_PASSWORD, + type: AccountFactory.role + }, + headers, + method, + url + }) + }) + + it('successful signup with email and password', () => { + // Act + const payload: JsonObject = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.CREATED) + expect(payload).to.have.keys(['access_token', 'refresh_token', 'uid']) + expect(payload['access_token']).to.be.a('string').and.not.empty + expect(payload['access_token']).to.be.a('string').and.not.empty + expect(payload['uid']).to.be.a('string').and.not.empty + }) + }) + + describe('400 (BAD_REQUEST)', () => { + let code: HttpStatus + let id: ExceptionId + let reasonKeys: string[] + + beforeAll(() => { + code = HttpStatus.BAD_REQUEST + id = ExceptionId.VALIDATION_FAILURE + reasonKeys = ['constraints', 'property', 'value'] + }) + + it('failed signup with invalid account type', async () => { + // Arrange + const body: JsonObject = { + email: faker.internet.email(), + password: ACCOUNT_PASSWORD, + type: AccountFactory.role + ',' + AccountFactory.role + } + + // Act + const result = await app.inject({ body, headers, method, url }) + 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').with.keys(reasonKeys) + expect(payload).to.have.nested.property('reason.property', 'role') + expect(payload).to.have.nested.property('reason.value', body['type']) + }) + + it('failed signup with invalid email', async () => { + // Arrange + const body: JsonObject = { + email: 'bad-email', + password: ACCOUNT_PASSWORD, + type: AccountFactory.role + } + + // Act + const result = await app.inject({ body, headers, method, url }) + 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').with.keys(reasonKeys) + expect(payload).to.have.nested.property('reason.property', 'email') + expect(payload).to.have.nested.property('reason.value', body['email']) + }) + + it('failed signup with invalid password', async () => { + // Arrange + const body: JsonObject = {} + const password: string = '' + + body['email'] = faker.internet.email() + body['password'] = password + body['type'] = AccountFactory.role + + // Act + const result = await app.inject({ body, headers, method, url }) + 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').with.keys(reasonKeys) + expect(payload).to.have.nested.property('reason.property', 'password') + expect(payload).to.have.nested.property('reason.value', password) + }) + }) + + describe.todo('408 (REQUEST_TIMEOUT)') + + describe('409 (CONFLICT)', () => { + let email: string + + beforeAll(async () => { + email = seeder.seeds[0]!.email + }) + + it.each([ + faker.internet.password(), + faker.internet.password({ length: 1 }) + ])('failed signup with conflicting email (%#)', async password => { + // Arrange + const body: JsonObject = { + email, + password, + type: AccountFactory.role + } + + // Act + const result = await app.inject({ body, headers, method, url }) + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.CONFLICT) + expect(payload).to.have.keys(ERROR_PAYLOAD_KEYS) + expect(payload).to.have.property('code', HttpStatus.CONFLICT) + expect(payload).to.have.property('id', ExceptionId.EMAIL_CONFLICT) + expect(payload).to.have.property('message').be.a('string').and.not.empty + expect(payload).to.have.nested.property('reason.email', body['email']) + }) + }) + + describe.todo('429 (TOO_MANY_REQUESTS)') + + describe('500 (INTERNAL_SERVER_ERROR)', () => { + let app: NestFastifyApplication + let result: Response + + afterAll(async () => { + await app.close() + }) + + beforeAll(async () => { + app = await createApp( + await Test.createTestingModule(metadata) + .overrideProvider(CreateAccountHandler) + .useValue(stub500(url, 'execute')) + .overrideProvider(AccountsRepository) + .useValue(repo) + .compile() + ) + + result = await app.inject({ + body: { + email: faker.internet.email(), + password: ACCOUNT_PASSWORD, + type: AccountFactory.role + }, + headers, + 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(() => { + method = 'get' + url = routes.ACCOUNTS + subroutes.ACCOUNTS_WHOAMI + }) + + describe('200 (OK)', () => { + let account: Account + let auth: AuthService + + beforeAll(() => { + account = new Account(seeder.seeds[3]!) + auth = ref.get(AuthService) + }) + + it.each<'accessToken' | 'refreshToken'>([ + 'accessToken', + 'refreshToken' + ])('authenticated user (%s)', async token => { + // Arrange + const request: InjectOptions = { + headers: { authorization: `bearer ${await auth[token](account)}` }, + method, + url + } + + // Act + const result = await app.inject(request) + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.OK) + expect(payload).to.have.keys(['uid']) + expect(payload).to.have.property('uid', account.uid) + }) + }) + + describe('401 (UNAUTHORIZED)', () => { + let result: Response + + beforeAll(async () => { + result = await app.inject({ + headers: { authorization: `bearer ${faker.internet.jwt()}` }, + method, + url + }) + }) + + it('unauthenticated user', () => { + // Act + const payload = result.json() + + // Expect + expect(result).to.be.json.with.status(HttpStatus.UNAUTHORIZED) + expect(payload).to.have.keys(['uid']) + expect(payload).to.have.property('uid', null) + }) + }) + }) +}) diff --git a/src/subdomains/accounts/accounts.module.mts b/src/subdomains/accounts/accounts.module.mts new file mode 100644 index 00000000..023bbefc --- /dev/null +++ b/src/subdomains/accounts/accounts.module.mts @@ -0,0 +1,38 @@ +/** + * @file AccountsModule + * @module sneusers/accounts/AccountsModule + */ + +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 AccountsRepository from '#accounts/providers/accounts.repository' +import AuthService from '#accounts/services/auth.service' +import JwtStrategy from '#accounts/strategies/jwt.strategy' +import DatabaseModule from '@flex-development/sneusers/database' +import { Module } from '@nestjs/common' +import { JwtModule } from '@nestjs/jwt' + +/** + * User accounts module. + * + * @class + */ +@Module({ + controllers: [AccountsController], + imports: [ + DatabaseModule.forFeature(Account), + JwtModule.registerAsync({ useClass: JwtOptionsFactory }) + ], + providers: [ + AccountsRepository, + AuthService, + CreateAccountHandler, + JwtOptionsFactory, + JwtStrategy + ] +}) +class AccountsModule {} + +export default AccountsModule diff --git a/src/subdomains/accounts/commands/create-account.command.mts b/src/subdomains/accounts/commands/create-account.command.mts new file mode 100644 index 00000000..97e8639e --- /dev/null +++ b/src/subdomains/accounts/commands/create-account.command.mts @@ -0,0 +1,59 @@ +/** + * @file Commands - CreateAccountCommand + * @module sneusers/accounts/commands/CreateAccount + */ + +import Role from '#accounts/enums/role' +import type { Account } from '@flex-development/sneusers/accounts' +import { Command } from '@nestjs/cqrs' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * Account creation command. + * + * @class + * @extends {Command} + */ +@ApiSchema() +class CreateAccountCommand extends Command { + /** + * Email address of new user. + * + * @public + * @instance + * @member {string} email + */ + @ApiProperty({ + description: 'email address of new user', + format: 'email', + type: 'string' + }) + public email!: string + + /** + * Account password. + * + * @public + * @instance + * @member {string} password + */ + @ApiProperty({ + description: 'password for account', + format: 'password', + minLength: 6, + type: 'string' + }) + public password!: string + + /** + * The type of account to create. + * + * @public + * @instance + * @member {Role} type + */ + @ApiProperty({ description: 'type of account to create', enum: Role }) + public type!: Role +} + +export default CreateAccountCommand diff --git a/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts b/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts new file mode 100644 index 00000000..c187eeb2 --- /dev/null +++ b/src/subdomains/accounts/controllers/__tests__/accounts.controller.spec.mts @@ -0,0 +1,58 @@ +/** + * @file Unit Tests - AccountsController + * @module sneusers/accounts/controllers/tests/unit/AccountsController + */ + +import CreateAccountCommand from '#accounts/commands/create-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 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 DatabaseModule from '@flex-development/sneusers/database' +import { JwtModule } from '@nestjs/jwt' +import { Test, type TestingModule } from '@nestjs/testing' + +describe('unit:accounts/controllers/AccountsController', () => { + let ref: TestingModule + let subject: TestSubject + + beforeAll(async () => { + ref = await Test.createTestingModule({ + controllers: [TestSubject], + imports: [ + DatabaseModule.forFeature(Account), + DependenciesModule, + JwtModule.registerAsync({ useClass: JwtOptionsFactory }) + ], + providers: [AccountsRepository, AuthService, CreateAccountHandler] + }).compile() + + subject = ref.get(TestSubject) + + await ref.init() + }) + + describe('#create', () => { + let body: CreateAccountCommand + + beforeAll(async () => { + body = new CreateAccountCommand() + body.email = faker.internet.email() + body.password = faker.internet.password({ length: 9 }) + body.type = AccountFactory.role + }) + + it('should return new account payload', async () => { + // Act + const result = await subject.create(body) + + // Expect + expect(result).to.be.instanceof(AccountCreatedPayload) + }) + }) +}) diff --git a/src/subdomains/accounts/controllers/accounts.controller.mts b/src/subdomains/accounts/controllers/accounts.controller.mts new file mode 100644 index 00000000..b7d2a0c4 --- /dev/null +++ b/src/subdomains/accounts/controllers/accounts.controller.mts @@ -0,0 +1,141 @@ +/** + * @file Controllers - AccountsController + * @module sneusers/accounts/controllers/AccountsController + */ + +import CreateAccountCommand from '#accounts/commands/create-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 WhoamiGuard from '#accounts/guards/whoami.guard' +import AuthService from '#accounts/services/auth.service' +import routes from '#enums/routes' +import subroutes from '#enums/subroutes' +import ExceptionFilter from '#filters/exception.filter' +import UnhandledExceptionFilter from '#filters/unhandled.filter' +import TransformPipe from '#pipes/transform.pipe' +import type { Account } from '@flex-development/sneusers/accounts' +import { + EmailConflictException +} from '@flex-development/sneusers/accounts/errors' +import { + InternalServerException, + ValidationException +} from '@flex-development/sneusers/errors' +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Res, + UseFilters, + UseGuards, + UsePipes +} from '@nestjs/common' +import { CommandBus } from '@nestjs/cqrs' +import { + ApiBadRequestResponse, + ApiConflictResponse, + ApiCreatedResponse, + ApiHeader, + ApiInternalServerErrorResponse, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse +} from '@nestjs/swagger' +import { ok } from 'devlop' +import type { FastifyReply } from 'fastify' + +/** + * User accounts controller. + * + * @class + */ +@Controller(routes.ACCOUNTS) +@ApiTags(routes.ACCOUNTS.slice(1)) +@UsePipes(TransformPipe) +@UseFilters(ExceptionFilter) +@UseFilters(UnhandledExceptionFilter) +@ApiInternalServerErrorResponse({ type: InternalServerException }) +class AccountsController { + /** + * Create a new user accounts controller. + * + * @param {CommandBus} commands + * The command bus + * @param {AuthService} auth + * Authentication and authorization service + */ + constructor(protected commands: CommandBus, protected auth: AuthService) {} + + /** + * Create a new account using email and password. + * + * @public + * @instance + * @async + * + * @param {CreateAccountCommand} body + * The request body containing the data to create a new account + * @return {Promise} + * New account payload + */ + @Post(subroutes.ACCOUNTS_CREATE) + @HttpCode(HttpStatus.CREATED) + @ApiCreatedResponse({ type: AccountCreatedPayload }) + @ApiConflictResponse({ type: EmailConflictException }) + @ApiBadRequestResponse({ type: ValidationException }) + public async create( + @Body() body: CreateAccountCommand + ): Promise { + ok(body instanceof CreateAccountCommand, 'expected a command') + + /** + * The new account. + * + * @const {Account} account + */ + const account: Account = await this.commands.execute(body) + + return new AccountCreatedPayload( + account, + await this.auth.accessToken(account), + await this.auth.refreshToken(account) + ) + } + + /** + * Check authentication. + * + * @public + * @instance + * + * @param {Account | null | undefined} account + * The account of the currently authenticated user + * @param {FastifyReply} res + * The server response object + * @return {undefined} + * Nothing; sends authentication check payload + */ + @Get(subroutes.ACCOUNTS_WHOAMI) + @UseGuards(WhoamiGuard) + @HttpCode(HttpStatus.OK) + @ApiHeader({ + examples: { bearer: { value: 'bearer ' } }, + name: 'authorization', + required: false + }) + @ApiOkResponse({ type: WhoamiPayload }) + @ApiUnauthorizedResponse({ type: WhoamiPayload }) + public whoami( + @User() account: Account | null | undefined, + @Res() res: FastifyReply + ): undefined { + res.status(account ? HttpStatus.OK : HttpStatus.UNAUTHORIZED) + return void res.send(new WhoamiPayload(account)) + } +} + +export default AccountsController diff --git a/src/subdomains/accounts/decorators/index.mts b/src/subdomains/accounts/decorators/index.mts new file mode 100644 index 00000000..57de119e --- /dev/null +++ b/src/subdomains/accounts/decorators/index.mts @@ -0,0 +1,6 @@ +/** + * @file Entry Point - Decorators + * @module sneusers/accounts/decorators + */ + +export { default as User } from '#accounts/decorators/user.decorator' diff --git a/src/subdomains/accounts/decorators/user.decorator.mts b/src/subdomains/accounts/decorators/user.decorator.mts new file mode 100644 index 00000000..d639d7b9 --- /dev/null +++ b/src/subdomains/accounts/decorators/user.decorator.mts @@ -0,0 +1,45 @@ +/** + * @file Decorators - User + * @module sneusers/accounts/decorators/User + */ + +import type { + Account, + AccountDocument +} from '@flex-development/sneusers/accounts' +import { + createParamDecorator, + type ExecutionContext +} from '@nestjs/common' +import type { FastifyRequest } from 'fastify' + +/** + * Get the account of the currently authenticated user. + * + * @decorator + * + * @const {() => ParameterDecorator} User + */ +const User: () => ParameterDecorator = createParamDecorator(user) + +export default User + +/** + * Get the account of the currently authenticated user from a request. + * + * @this {void} + * + * @param {keyof AccountDocument | null | undefined} field + * The name of the field to pluck + * @param {ExecutionContext} context + * Details about the current request pipeline + * @return {Account | null} + * The account of the authenticated user + */ +function user( + this: void, + field: keyof AccountDocument | null | undefined, + context: ExecutionContext +): Account | null { + return context.switchToHttp().getRequest().user ?? null +} diff --git a/src/subdomains/accounts/dto/__tests__/account-created.payload.spec.mts b/src/subdomains/accounts/dto/__tests__/account-created.payload.spec.mts new file mode 100644 index 00000000..1860c6a1 --- /dev/null +++ b/src/subdomains/accounts/dto/__tests__/account-created.payload.spec.mts @@ -0,0 +1,44 @@ +/** + * @file Unit Tests - AccountCreatedPayload + * @module sneusers/accounts/dto/tests/unit/AccountCreatedPayload + */ + +import TestSubject from '#accounts/dto/account-created.payload' +import Account from '#accounts/entities/account.entity' +import AccountFactory from '#tests/utils/account.factory' + +describe('unit:accounts/dto/AccountCreatedPayload', () => { + let factory: AccountFactory + + beforeAll(() => { + factory = new AccountFactory() + }) + + describe('constructor', () => { + let access_token: string + let account: Account + let refresh_token: string + let subject: TestSubject + + beforeAll(() => { + account = new Account(factory.makeOne()) + + access_token = faker.internet.jwt() + refresh_token = faker.internet.jwt() + + subject = new TestSubject(account, access_token, refresh_token) + }) + + it('should set #access_token', () => { + expect(subject).to.have.property('access_token', access_token) + }) + + it('should set #refresh_token', () => { + expect(subject).to.have.property('refresh_token', refresh_token) + }) + + it('should set #uid', () => { + expect(subject).to.have.property('uid', account.uid) + }) + }) +}) diff --git a/src/subdomains/accounts/dto/__tests__/whoami.payload.spec.mts b/src/subdomains/accounts/dto/__tests__/whoami.payload.spec.mts new file mode 100644 index 00000000..691d64a3 --- /dev/null +++ b/src/subdomains/accounts/dto/__tests__/whoami.payload.spec.mts @@ -0,0 +1,41 @@ +/** + * @file Unit Tests - WhoamiPayload + * @module sneusers/accounts/dto/tests/unit/WhoamiPayload + */ + +import TestSubject from '#accounts/dto/whoami.payload' +import Account from '#accounts/entities/account.entity' +import AccountFactory from '#tests/utils/account.factory' + +describe('unit:accounts/dto/WhoamiPayload', () => { + let factory: AccountFactory + + beforeAll(() => { + factory = new AccountFactory() + }) + + describe('constructor()', () => { + let subject: TestSubject + + beforeAll(() => { + subject = new TestSubject() + }) + + it('should set #uid', () => { + expect(subject).to.have.property('uid', null) + }) + }) + + describe('constructor(account)', () => { + let account: Account + let subject: TestSubject + + beforeAll(() => { + subject = new TestSubject(account = new Account(factory.makeOne())) + }) + + 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 new file mode 100644 index 00000000..e89bdb6a --- /dev/null +++ b/src/subdomains/accounts/dto/account-created.payload.mts @@ -0,0 +1,69 @@ +/** + * @file Data Transfer Objects - AccountCreatedPayload + * @module sneusers/accounts/dto/AccountCreatedPayload + */ + +import type { Account } from '@flex-development/sneusers/accounts' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * Successful account creation response. + * + * @class + */ +@ApiSchema() +class AccountCreatedPayload { + /** + * User access token. + * + * @public + * @instance + * @member {string} access_token + */ + @ApiProperty({ + description: 'token for authenticating requests', + type: 'string' + }) + public access_token: string + + /** + * User refresh token. + * + * @public + * @instance + * @member {string} refresh_token + */ + @ApiProperty({ + description: 'token for renewing access tokens', + type: 'string' + }) + public refresh_token: string + + /** + * Unique account id. + * + * @public + * @instance + * @member {string} uid + */ + @ApiProperty({ description: 'unique account id', type: 'string' }) + public uid: string + + /** + * Create a new account payload. + * + * @param {Account} account + * The new account + * @param {string} access_token + * User access token + * @param {string} refresh_token + * User refresh token + */ + constructor(account: Account, access_token: string, refresh_token: string) { + this.uid = account.uid + this.access_token = access_token + this.refresh_token = refresh_token + } +} + +export default AccountCreatedPayload diff --git a/src/subdomains/accounts/dto/whoami.payload.mts b/src/subdomains/accounts/dto/whoami.payload.mts new file mode 100644 index 00000000..5dfed7c3 --- /dev/null +++ b/src/subdomains/accounts/dto/whoami.payload.mts @@ -0,0 +1,40 @@ +/** + * @file Data Transfer Objects - WhoamiPayload + * @module sneusers/accounts/dto/WhoamiPayload + */ + +import type { Account } from '@flex-development/sneusers/accounts' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * Authentication check response. + * + * @class + */ +@ApiSchema() +class WhoamiPayload { + /** + * Unique account id. + * + * @public + * @instance + * @member {string | null} uid + */ + @ApiProperty({ + description: 'unique account id', + oneOf: [{ type: 'string' }, { type: 'null' }] + }) + public uid: string | null + + /** + * Create a new authentication check payload. + * + * @param {Account | null | undefined} [account] + * The account of the authenticated user + */ + constructor(account?: Account | null | undefined) { + this.uid = account?.uid ?? null + } +} + +export default WhoamiPayload diff --git a/src/subdomains/accounts/entities/__tests__/account.entity.functional.spec.mts b/src/subdomains/accounts/entities/__tests__/account.entity.functional.spec.mts new file mode 100644 index 00000000..59f2b469 --- /dev/null +++ b/src/subdomains/accounts/entities/__tests__/account.entity.functional.spec.mts @@ -0,0 +1,43 @@ +/** + * @file Functional Tests - Account + * @module sneusers/accounts/entities/tests/functional/Account + */ + +import TestSubject from '#accounts/entities/account.entity' +import AccountCreatedEvent from '#accounts/events/account-created.event' +import AccountFactory from '#tests/utils/account.factory' +import { omit } from '@flex-development/tutils' +import type { MockInstance } from 'vitest' + +describe('functional:accounts/entities/Account', () => { + let factory: AccountFactory + + beforeAll(() => { + factory = new AccountFactory() + }) + + describe('constructor', () => { + let apply: MockInstance + + beforeEach(() => { + apply = vi.spyOn(TestSubject.prototype, 'apply') + }) + + it('should apply account created event if account is new', () => { + // Act + new TestSubject(omit(factory.makeOne(), ['_id'])) + + // Expect + expect(apply).toHaveBeenCalledOnce() + expect(apply.mock.lastCall![0]).to.be.instanceof(AccountCreatedEvent) + }) + + it('should not apply account created event if account exists', () => { + // Act + new TestSubject(factory.makeOne()) + + // Expect + expect(apply).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/subdomains/accounts/entities/__tests__/account.entity.spec.mts b/src/subdomains/accounts/entities/__tests__/account.entity.spec.mts new file mode 100644 index 00000000..f6bc5a34 --- /dev/null +++ b/src/subdomains/accounts/entities/__tests__/account.entity.spec.mts @@ -0,0 +1,42 @@ +/** + * @file Unit Tests - Account + * @module sneusers/accounts/entities/tests/unit/Account + */ + +import TestSubject from '#accounts/entities/account.entity' +import AccountFactory from '#tests/utils/account.factory' +import type { AccountDocument } from '@flex-development/sneusers/accounts' + +describe('unit:accounts/entities/Account', () => { + let factory: AccountFactory + + beforeAll(() => { + factory = new AccountFactory() + }) + + describe('constructor', () => { + let props: AccountDocument + let subject: TestSubject + + beforeAll(() => { + props = factory.makeOne() + subject = new TestSubject(props) + }) + + it('should not automatically commit events', () => { + expect(subject).to.have.property('autoCommit', false) + }) + + it('should set #email', () => { + expect(subject).to.have.property('email', props.email) + }) + + it('should set #password', () => { + expect(subject).to.have.property('password', props.password) + }) + + it('should set #role', () => { + expect(subject).to.have.property('role', props.role) + }) + }) +}) diff --git a/src/subdomains/accounts/entities/account.entity.mts b/src/subdomains/accounts/entities/account.entity.mts new file mode 100644 index 00000000..b979b005 --- /dev/null +++ b/src/subdomains/accounts/entities/account.entity.mts @@ -0,0 +1,74 @@ +/** + * @file Entities - Account + * @module sneusers/accounts/entities/Account + */ + +import Role from '#accounts/enums/role' +import AccountCreatedEvent from '#accounts/events/account-created.event' +import type { AccountDocument } from '@flex-development/sneusers/accounts' +import { Entity, type EntityData } from '@flex-development/sneusers/database' +import { + IsEmail, + IsEnum, + IsString, + MinLength +} from 'class-validator' + +/** + * User account entity. + * + * @class + * @extends {Entity} + * @implements {AccountDocument} + */ +class Account extends Entity implements AccountDocument { + /** + * User email address. + * + * @public + * @instance + * @member {string} email + */ + @IsEmail() + public email: string + + /** + * Hashed password. + * + * @public + * @instance + * @member {string} password + */ + @IsString() + @MinLength(6) + public password: string + + /** + * The role of the user. + * + * @public + * @instance + * @member {Role} role + */ + @IsEnum(Role) + public role: Role + + /** + * Create a new user account. + * + * @param {EntityData} props + * Account entity props + */ + constructor(props: EntityData) { + super(props) + + this.email = props.email + this.password = props.password + this.role = props.role + + // apply new account event. + !props._id && this.apply(new AccountCreatedEvent(this)) + } +} + +export default Account diff --git a/src/subdomains/accounts/enums/role.mts b/src/subdomains/accounts/enums/role.mts new file mode 100644 index 00000000..904c47b0 --- /dev/null +++ b/src/subdomains/accounts/enums/role.mts @@ -0,0 +1,16 @@ +/** + * @file Enums - Role + * @module sneusers/accounts/enums/Role + */ + +/** + * User roles. + * + * @enum {Lowercase} + */ +enum Role { + DEVELOPER = 'developer', + USER = 'user' +} + +export default Role diff --git a/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts b/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts new file mode 100644 index 00000000..1a5cc826 --- /dev/null +++ b/src/subdomains/accounts/errors/__tests__/email-conflict.exception.spec.mts @@ -0,0 +1,63 @@ +/** + * @file Unit Tests - EmailConflictException + * @module sneusers/accounts/errors/tests/unit/EmailConflictException + */ + +import TestSubject from '#accounts/errors/email-conflict.exception' +import Reason from '#accounts/errors/email-conflict.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' + +describe('unit:accounts/errors/EmailConflictException', () => { + describe('constructor', () => { + let email: string + let subject: TestSubject + + beforeAll(() => { + email = faker.internet.email() + subject = new TestSubject(email) + }) + + 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(['email']) + expect(subject.cause).to.have.property('email', email) + }) + + it('should set #code', () => { + expect(subject).to.have.property('code', ExceptionCode.CONFLICT) + }) + + it('should set #id', () => { + expect(subject).to.have.property('id', ExceptionId.EMAIL_CONFLICT) + }) + + it('should set #message', () => { + // Arrange + const message: string = 'Email address must be unique' + + // Expect + expect(subject).to.have.property('message', message) + }) + + 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(['email']) + expect(subject.reason).to.have.property('email', email) + }) + + 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 new file mode 100644 index 00000000..714c223b --- /dev/null +++ b/src/subdomains/accounts/errors/email-conflict.exception.mts @@ -0,0 +1,67 @@ +/** + * @file Errors - EmailConflictException + * @module sneusers/accounts/errors/EmailConflictException + */ + +import Reason from '#accounts/errors/email-conflict.reason' +import { Exception, ExceptionCode, + ExceptionId } from '@flex-development/sneusers/errors' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * An email conflict exception. + * + * @class + * @extends {Exception} + */ +@ApiSchema() +class EmailConflictException extends Exception { + /** + * HTTP response status code. + * + * @public + * @instance + * @member {ExceptionCode.CONFLICT} code + */ + @ApiProperty({ enum: [ExceptionCode.CONFLICT] }) + declare public code: (typeof ExceptionCode)['CONFLICT'] + + /** + * Unique id representing the exception. + * + * @public + * @instance + * @member {ExceptionId.EMAIL_CONFLICT} code + */ + @ApiProperty({ enum: [ExceptionId.EMAIL_CONFLICT] }) + declare public id: (typeof ExceptionId)['EMAIL_CONFLICT'] + + /** + * The reason for the exception. + * + * @public + * @instance + * @member {Reason} reason + */ + @ApiProperty({ type: Reason }) + declare public reason: Reason + + /** + * Create a new email conflict exception. + * + * @param {string} email + * The conflicting email address + */ + constructor(email: string) { + super({ + code: ExceptionCode.CONFLICT, + id: ExceptionId.EMAIL_CONFLICT, + message: 'Email address must be unique', + reason: new Reason(email) + }) + + this.name = 'EmailConflictException' + } +} + +export default EmailConflictException diff --git a/src/subdomains/accounts/errors/email-conflict.reason.mts b/src/subdomains/accounts/errors/email-conflict.reason.mts new file mode 100644 index 00000000..4aae9150 --- /dev/null +++ b/src/subdomains/accounts/errors/email-conflict.reason.mts @@ -0,0 +1,53 @@ +/** + * @file Errors - EmailConflict + * @module sneusers/accounts/errors/EmailConflict + */ + +import { Reason } from '@flex-development/sneusers/errors' +import type { JsonObject } from '@flex-development/sneusers/types' +import { ApiProperty, ApiSchema } from '@nestjs/swagger' + +/** + * The reason for an email conflict exception. + * + * @class + * @extends {Reason} + */ +@ApiSchema() +class EmailConflict extends Reason { + /** + * The conflicting email address. + * + * @public + * @instance + * @member {string} email + */ + @ApiProperty({ description: 'the conflicting email address', type: 'string' }) + public email: string + + /** + * Create a an email conflict exception info object. + * + * @param {string} email + * The conflicting email address + */ + constructor(email: string) { + super() + this.email = email + } + + /** + * Get a JSON representation of the exception info. + * + * @public + * @instance + * + * @return {JsonObject} + * JSON representation of `this` exception info + */ + public toJSON(): JsonObject { + return { email: this.email } + } +} + +export default EmailConflict diff --git a/src/subdomains/accounts/errors/index.mts b/src/subdomains/accounts/errors/index.mts new file mode 100644 index 00000000..2f81c289 --- /dev/null +++ b/src/subdomains/accounts/errors/index.mts @@ -0,0 +1,8 @@ +/** + * @file Entry Point - Errors + * @module sneusers/accounts/errors + */ + +export { + default as EmailConflictException +} from '#accounts/errors/email-conflict.exception' diff --git a/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts b/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts new file mode 100644 index 00000000..99b81b8d --- /dev/null +++ b/src/subdomains/accounts/events/__tests__/account-created.event.spec.mts @@ -0,0 +1,29 @@ +/** + * @file Unit Tests - AccountCreatedEvent + * @module sneusers/accounts/events/tests/unit/AccountCreatedEvent + */ + +import Account from '#accounts/entities/account.entity' +import TestSubject from '#accounts/events/account-created.event' +import AccountFactory from '#tests/utils/account.factory' + +describe('unit:accounts/events/AccountCreatedEvent', () => { + 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 new file mode 100644 index 00000000..c9a973c0 --- /dev/null +++ b/src/subdomains/accounts/events/account-created.event.mts @@ -0,0 +1,23 @@ +/** + * @file Events - AccountCreatedEvent + * @module sneusers/accounts/events/AccountCreated + */ + +import type { Account } from '@flex-development/sneusers/accounts' + +/** + * User account created event. + * + * @class + */ +class AccountCreatedEvent { + /** + * Create a new user account created event. + * + * @param {Account} account + * The new account + */ + constructor(public account: Account) {} +} + +export default AccountCreatedEvent diff --git a/src/subdomains/accounts/factories/__tests__/jwt-options.factory.spec.mts b/src/subdomains/accounts/factories/__tests__/jwt-options.factory.spec.mts new file mode 100644 index 00000000..66617708 --- /dev/null +++ b/src/subdomains/accounts/factories/__tests__/jwt-options.factory.spec.mts @@ -0,0 +1,54 @@ +/** + * @file Unit Tests - JwtOptionsFactory + * @module sneusers/accounts/factories/tests/unit/JwtOptionsFactory + */ + +import TestSubject from '#accounts/factories/jwt-options.factory' +import ConfigModule from '#modules/config.module' +import type { Config } from '@flex-development/sneusers/types' +import { isObjectPlain } from '@flex-development/tutils' +import { ConfigService } from '@nestjs/config' +import { type JwtModuleOptions } from '@nestjs/jwt' +import { Test, type TestingModule } from '@nestjs/testing' + +describe('unit:accounts/factories/JwtOptionsFactory', () => { + let config: ConfigService + let ref: TestingModule + let subject: TestSubject + + beforeAll(async () => { + ref = await Test.createTestingModule({ + imports: [ConfigModule], + providers: [TestSubject] + }).compile() + + config = ref.get(ConfigService) + subject = ref.get(TestSubject) + }) + + describe('#createJwtOptions', () => { + let keys: string[] + let result: JwtModuleOptions + let signOptionKeys: string[] + let url: URL + + beforeAll(() => { + keys = ['secret', 'signOptions'] + signOptionKeys = ['audience', 'issuer'] + url = config.get('URL') + }) + + beforeEach(() => { + result = subject.createJwtOptions() + }) + + it('should return jwt module options', () => { + expect(result).to.have.keys(keys) + expect(result).to.have.property('secret', config.get('JWT_SECRET')) + expect(result.signOptions).to.satisfy(isObjectPlain) + expect(result.signOptions).to.have.keys(signOptionKeys) + expect(result.signOptions).to.have.property('audience', url.host) + expect(result.signOptions).to.have.property('issuer', url.host) + }) + }) +}) diff --git a/src/subdomains/accounts/factories/jwt-options.factory.mts b/src/subdomains/accounts/factories/jwt-options.factory.mts new file mode 100644 index 00000000..fd43f824 --- /dev/null +++ b/src/subdomains/accounts/factories/jwt-options.factory.mts @@ -0,0 +1,55 @@ +/** + * @file Factories - JwtOptionsFactory + * @module sneusers/accounts/factories/JwtOptions + */ + +import type { Config } from '@flex-development/sneusers/types' +import { Inject } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import type { + JwtOptionsFactory as IJwtOptionsFactory, + JwtModuleOptions +} from '@nestjs/jwt' + +/** + * JWT options factory. + * + * @class + * @implements {IJwtOptionsFactory} + */ +class JwtOptionsFactory implements IJwtOptionsFactory { + /** + * Create a JWT options factory. + * + * @param {ConfigService} config + * Application config service + */ + constructor( + @Inject(ConfigService) protected config: ConfigService + ) {} + + /** + * Create an JWT options object. + * + * @public + * @instance + * + * @return {JwtModuleOptions} + * JWT module options + */ + public createJwtOptions(): JwtModuleOptions { + /** + * The url the application will listen for incoming connections on. + * + * @const {URL} url + */ + const url: URL = this.config.get('URL') + + return { + secret: this.config.get('JWT_SECRET'), + signOptions: { audience: url.host, issuer: url.host } + } + } +} + +export default JwtOptionsFactory diff --git a/src/subdomains/accounts/guards/jwt.guard.mts b/src/subdomains/accounts/guards/jwt.guard.mts new file mode 100644 index 00000000..ee40ca19 --- /dev/null +++ b/src/subdomains/accounts/guards/jwt.guard.mts @@ -0,0 +1,19 @@ +/** + * @file Guards - JwtGuard + * @module sneusers/accounts/guards/Jwt + */ + +import AuthStrategy from '#enums/auth-strategy' +import { Injectable } from '@nestjs/common' +import { AuthGuard, type IAuthGuard } from '@nestjs/passport' + +/** + * JWT authentication guard. + * + * @class + * @implements {IAuthGuard} + */ +@Injectable() +class JwtGuard extends AuthGuard(AuthStrategy.JWT) implements IAuthGuard {} + +export default JwtGuard diff --git a/src/subdomains/accounts/guards/whoami.guard.mts b/src/subdomains/accounts/guards/whoami.guard.mts new file mode 100644 index 00000000..a6c8d8d8 --- /dev/null +++ b/src/subdomains/accounts/guards/whoami.guard.mts @@ -0,0 +1,41 @@ +/** + * @file Guards - WhoamiGuard + * @module sneusers/accounts/guards/Whoami + */ + +import JwtGuard from '#accounts/guards/jwt.guard' +import { Injectable, type ExecutionContext } from '@nestjs/common' +import type { IAuthGuard } from '@nestjs/passport' + +/** + * Optional JWT authentication guard. + * + * @class + * @extends {JwtGuard} + * @implements {IAuthGuard} + */ +@Injectable() +class WhoamiGuard extends JwtGuard implements IAuthGuard { + /** + * Authenticate a user using a JWT, and fail silently if the user cannot be + * authenticated. + * + * @public + * @instance + * @override + * + * @param {ExecutionContext} context + * Details about the current request pipeline + * @return {true} + * `true` + */ + public override async canActivate(context: ExecutionContext): Promise { + try { + return await super.canActivate(context) as true + } catch { + return true + } + } +} + +export default WhoamiGuard 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 new file mode 100644 index 00000000..3282044f --- /dev/null +++ b/src/subdomains/accounts/handlers/__tests__/create-account.handler.functional.spec.mts @@ -0,0 +1,86 @@ +/** + * @file Functional Tests - CreateAccountHandler + * @module sneusers/accounts/handlers/tests/functional/CreateAccountHandler + */ + +import CreateAccountCommand from '#accounts/commands/create-account.command' +import Account from '#accounts/entities/account.entity' +import AccountCreatedEvent from '#accounts/events/account-created.event' +import TestSubject from '#accounts/handlers/create-account.handler' +import AccountsRepository from '#accounts/providers/accounts.repository' +import AccountFactory from '#tests/utils/account.factory' +import DatabaseModule from '@flex-development/sneusers/database' +import { CqrsModule, EventsHandler, type IEventHandler } from '@nestjs/cqrs' +import { Test, type TestingModule } from '@nestjs/testing' +import type { Mock, MockInstance } from 'vitest' + +describe('functional:accounts/handlers/CreateAccountHandler', () => { + let handler: IEventHandler + let ref: TestingModule + let repository: AccountsRepository + let subject: TestSubject + + beforeAll(async () => { + @EventsHandler(AccountCreatedEvent) + class AccountCreatedHandler implements IEventHandler { + /** + * Handle an account creation event. + * + * @public + * @instance + * + * @param {AccountCreatedEvent} event + * The account creation event + * @return {undefined} + */ + public handle: Mock = vi.fn().mockName('AccountCreatedHandler#handle') + } + + ref = await Test.createTestingModule({ + imports: [CqrsModule.forRoot(), DatabaseModule.forFeature(Account)], + providers: [AccountCreatedHandler, AccountsRepository, TestSubject] + }).compile() + + handler = ref.get(AccountCreatedHandler) + repository = ref.get(AccountsRepository) + subject = ref.get(TestSubject) + + await ref.init() + }) + + describe('#execute', () => { + let command: CreateAccountCommand + let commit: MockInstance + let handle: MockInstance['handle']> + let insert: MockInstance + + beforeAll(() => { + command = new CreateAccountCommand() + command.password = faker.internet.password({ length: 7 }) + command.type = AccountFactory.role + }) + + beforeEach(async () => { + commit = vi.spyOn(Account.prototype, 'commit') + handle = vi.spyOn(handler, 'handle') + insert = vi.spyOn(repository, 'insert') + + command.email = faker.internet.email() + await subject.execute(command) + }) + + it('should add account to database', () => { + expect(insert).toHaveBeenCalledOnce() + expect(insert.mock.lastCall).to.be.an('array').of.length(1) + expect(insert.mock.lastCall![0]).to.be.instanceof(Account) + }) + + it('should publish account created event', () => { + expect(commit).toHaveBeenCalledOnce() + expect(commit.mock.lastCall).to.be.an('array').of.length(0) + expect(handle).toHaveBeenCalledOnce() + expect(handle.mock.lastCall).to.be.an('array').of.length(1) + expect(handle.mock.lastCall![0]).to.be.instanceof(AccountCreatedEvent) + }) + }) +}) diff --git a/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts b/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts new file mode 100644 index 00000000..cfb4064c --- /dev/null +++ b/src/subdomains/accounts/handlers/__tests__/create-account.handler.spec.mts @@ -0,0 +1,91 @@ +/** + * @file Unit Tests - CreateAccountHandler + * @module sneusers/accounts/handlers/tests/unit/CreateAccountHandler + */ + +import CreateAccountCommand from '#accounts/commands/create-account.command' +import Account from '#accounts/entities/account.entity' +import TestSubject from '#accounts/handlers/create-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 { Exception, ExceptionId } from '@flex-development/sneusers/errors' +import { HttpStatus } from '@nestjs/common' +import { CqrsModule } from '@nestjs/cqrs' +import { Test, type TestingModule } from '@nestjs/testing' + +describe('unit:accounts/handlers/CreateAccountHandler', () => { + 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() + + subject = ref.get(TestSubject) + }) + + describe('#execute', () => { + let conflict: CreateAccountCommand + let success: CreateAccountCommand + + beforeAll(() => { + conflict = new CreateAccountCommand() + success = new CreateAccountCommand() + + conflict.email = seeder.seeds[0]!.email + conflict.password = faker.internet.password({ length: 8 }) + conflict.type = AccountFactory.role + + success.email = faker.internet.email() + success.password = faker.internet.password({ length: 9 }) + success.type = AccountFactory.role + }) + + it('should return new user account', async () => { + // Act + const result = await subject.execute(success) + + // Expect + expect(result).to.be.instanceof(Account) + expect(result).to.have.property('email', success.email) + expect(result).to.have.property('password').be.a('string') + expect(result).to.have.property('password').not.eq(success.password) + expect(result).to.have.property('role', success.type) + expect(result).to.have.property('updated_at', null) + }) + + it('should throw on email conflict', async () => { + // Arrange + let error!: Exception + + // Act + try { + await subject.execute(conflict) + } catch (e: unknown) { + error = e as typeof error + } + + // Expect + expect(error).to.be.instanceof(Exception) + expect(error).to.have.property('cause').with.keys(['email']) + expect(error).to.have.nested.property('cause.email', conflict.email) + expect(error).to.have.property('code', HttpStatus.CONFLICT) + expect(error).to.have.property('id', ExceptionId.EMAIL_CONFLICT) + expect(error).to.have.property('message', 'Email address must be unique') + }) + }) +}) diff --git a/src/subdomains/accounts/handlers/create-account.handler.mts b/src/subdomains/accounts/handlers/create-account.handler.mts new file mode 100644 index 00000000..4ed7b830 --- /dev/null +++ b/src/subdomains/accounts/handlers/create-account.handler.mts @@ -0,0 +1,122 @@ +/** + * @file Handlers - CreateAccountHandler + * @module sneusers/accounts/handlers/CreateAccount + */ + +import CreateAccountCommand from '#accounts/commands/create-account.command' +import Account from '#accounts/entities/account.entity' +import AccountsRepository from '#accounts/providers/accounts.repository' +import { + EmailConflictException +} from '@flex-development/sneusers/accounts/errors' +import { InjectMapper, Mapper } from '@flex-development/sneusers/database' +import { + CommandHandler, + EventPublisher, + type ICommandHandler +} from '@nestjs/cqrs' +import bcrypt from 'bcrypt' + +/** + * User account creation handler. + * + * @class + * @implements {ICommandHandler} + */ +@CommandHandler(CreateAccountCommand) +class CreateAccountHandler implements ICommandHandler { + /** + * Create a new user account creation handler. + * + * @param {AccountsRepository} accounts + * User accounts repository + * @param {Mapper} mapper + * User account data mapper + * @param {EventPublisher} publisher + * Event publisher + */ + constructor( + protected accounts: AccountsRepository, + @InjectMapper(Account) protected mapper: Mapper, + protected publisher: EventPublisher + ) {} + + /** + * Create a new user account. + * + * @public + * @instance + * @async + * + * @param {CreateAccountCommand} command + * The command to execute + * @return {Promise} + * The new user account + * @throws {EmailConflictException} + * If user account with email `command.email` already exists + */ + public async execute(command: CreateAccountCommand): Promise { + if (await this.accounts.findByEmail(command.email)) { + throw new EmailConflictException(command.email) + } + + /** + * New user account. + * + * @const {Account} account + */ + const account: Account = this.mapper.toDomain({ + created_at: Date.now(), + email: command.email, + password: command.password, + role: command.type, + updated_at: null + }) + + // validate acccount. + account.validate() + + // hash password. + account.password = this.hash(account.password) + + // add account to database. + await this.accounts.insert(account) + + // publish domain event. + this.publisher.mergeObjectContext(account) + account.commit() + + return account + } + + /** + * Hash a `password`. + * + * @protected + * @instance + * + * @param {string} password + * The password to hash + * @return {string} + * Hashed password + */ + protected hash(password: string): string + + /** + * Hash a `password`. + * + * @protected + * @instance + * + * @param {string | null} password + * The password to hash + * @return {string | null} + * Hashed password, or `null` if password is `null` + */ + protected hash(password: string | null): string | null { + if (typeof password === 'string') password = bcrypt.hashSync(password, 10) + return password + } +} + +export default CreateAccountHandler diff --git a/src/subdomains/accounts/index.mts b/src/subdomains/accounts/index.mts new file mode 100644 index 00000000..3dc86a11 --- /dev/null +++ b/src/subdomains/accounts/index.mts @@ -0,0 +1,11 @@ +/** + * @file Entry Point - Accounts + * @module sneusers/accounts + */ + +export { default } from '#accounts/accounts.module' +export type { default as Account } from '#accounts/entities/account.entity' +export type { default as Role } from '#accounts/enums/role' +export type { + default as AccountDocument +} from '#accounts/interfaces/account.document' diff --git a/src/subdomains/accounts/interfaces/__tests__/account.document.spec-d.mts b/src/subdomains/accounts/interfaces/__tests__/account.document.spec-d.mts new file mode 100644 index 00000000..ddd252f4 --- /dev/null +++ b/src/subdomains/accounts/interfaces/__tests__/account.document.spec-d.mts @@ -0,0 +1,28 @@ +/** + * @file Type Tests - AccountDocument + * @module sneusers/accounts/interfaces/tests/unit-d/AccountDocument + */ + +import type TestSubject from '#accounts/interfaces/account.document' +import type { Role } from '@flex-development/sneusers/accounts' +import type { IDocument } from '@flex-development/sneusers/database' + +describe('unit-d:accounts/interfaces/AccountDocument', () => { + it('should extend IDocument', () => { + expectTypeOf().toExtend() + }) + + it('should match [email: string]', () => { + expectTypeOf().toHaveProperty('email').toEqualTypeOf() + }) + + it('should match [password: string]', () => { + expectTypeOf() + .toHaveProperty('password') + .toEqualTypeOf() + }) + + it('should match [role: Role]', () => { + expectTypeOf().toHaveProperty('role').toEqualTypeOf() + }) +}) diff --git a/src/subdomains/accounts/interfaces/account.document.mts b/src/subdomains/accounts/interfaces/account.document.mts new file mode 100644 index 00000000..d0b112fe --- /dev/null +++ b/src/subdomains/accounts/interfaces/account.document.mts @@ -0,0 +1,33 @@ +/** + * @file Interfaces - AccountDocument + * @module sneusers/accounts/interfaces/AccountDocument + */ + +import type { Role } from '@flex-development/sneusers/accounts' +import type { IDocument } from '@flex-development/sneusers/database' + +/** + * A collection document representing a user account. + * + * @see {@linkcode IDocument} + * + * @extends {IDocument} + */ +interface AccountDocument extends IDocument { + /** + * The email address associated with the account. + */ + email: string + + /** + * The password associated with the account. + */ + password: string + + /** + * The role of the user. + */ + role: Role +} + +export type { AccountDocument as default } diff --git a/src/subdomains/accounts/providers/__tests__/accounts.repository.spec.mts b/src/subdomains/accounts/providers/__tests__/accounts.repository.spec.mts new file mode 100644 index 00000000..1078c441 --- /dev/null +++ b/src/subdomains/accounts/providers/__tests__/accounts.repository.spec.mts @@ -0,0 +1,58 @@ +/** + * @file Unit Tests - AccountsRepository + * @module sneusers/accounts/providers/tests/unit/AccountsRepository + */ + +import Account from '#accounts/entities/account.entity' +import TestSubject 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 { Test, type TestingModule } from '@nestjs/testing' +import { ok } from 'devlop' + +describe('unit:accounts/providers/AccountsRepository', () => { + let factory: AccountFactory + 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: [TestSubject] + }).compile() + + factory = new AccountFactory() + subject = ref.get(TestSubject) + seeder = await new Seeder(factory, subject).up() + }) + + describe('#findByEmail', () => { + it('should return `null` if account is not found', async () => { + // Arrange + const email: string = faker.internet.email({ provider: 'test.app' }) + + // Act + Expect + expect(await subject.findByEmail(email)).to.be.null + }) + + it('should return `Account` instance if account is found', async () => { + // Arrange + const email: string = seeder.seeds[seeder.seeds.length - 3]!.email + + // Act + ok(typeof email === 'string', 'expected `email`') + const result = await subject.findByEmail(email) + + // Expect + expect(result).to.be.instanceof(Account) + expect(result).to.have.property('email', email) + }) + }) +}) diff --git a/src/subdomains/accounts/providers/accounts.repository.mts b/src/subdomains/accounts/providers/accounts.repository.mts new file mode 100644 index 00000000..479ce6d5 --- /dev/null +++ b/src/subdomains/accounts/providers/accounts.repository.mts @@ -0,0 +1,65 @@ +/** + * @file Providers - AccountsRepository + * @module sneusers/accounts/providers/AccountsRepository + */ + +import Account from '#accounts/entities/account.entity' +import { + InjectMapper, + Mapper, + Repository +} from '@flex-development/sneusers/database' +import { Injectable } from '@nestjs/common' + +/** + * User accounts repository. + * + * @class + * @extends {Repository} + */ +@Injectable() +class AccountsRepository extends Repository { + /** + * Create a new user accounts repository. + * + * @param {Mapper} mapper + * User account data mapper + */ + constructor(@InjectMapper(Account) mapper: Mapper) { + super(mapper) + } + + /** + * Find a user account by email address. + * + * @public + * @instance + * @async + * + * @param {string} email + * The email address to search by + * @return {Account | null} + * User account entity or `null` + */ + public async findByEmail(email: string): Promise { + return new Promise(resolve => { + /** + * The user account associated with {@linkcode email}. + * + * @var {Account | null} account + */ + let account: Account | null = null + + for (const x of this.entities) { + if (email === x.email) { + account = x + break + } + } + + return void resolve(account) + }) + } +} + +export default AccountsRepository diff --git a/src/subdomains/accounts/services/__tests__/auth.service.functional.spec.mts b/src/subdomains/accounts/services/__tests__/auth.service.functional.spec.mts new file mode 100644 index 00000000..ffff4626 --- /dev/null +++ b/src/subdomains/accounts/services/__tests__/auth.service.functional.spec.mts @@ -0,0 +1,95 @@ +/** + * @file Functional Tests - AuthService + * @module sneusers/accounts/services/tests/functional/AuthService + */ + +import Account from '#accounts/entities/account.entity' +import JwtOptionsFactory from '#accounts/factories/jwt-options.factory' +import TestSubject from '#accounts/services/auth.service' +import date from '#fixtures/date' +import ConfigModule from '#modules/config.module' +import AccountFactory from '#tests/utils/account.factory' +import type { Config, JsonObject } from '@flex-development/sneusers/types' +import { ConfigService } from '@nestjs/config' +import { JwtModule, JwtService, type JwtSignOptions } from '@nestjs/jwt' +import { Test, type TestingModule } from '@nestjs/testing' +import type { MockInstance } from 'vitest' + +describe('functional:accounts/services/AuthService', () => { + let account: Account + let config: ConfigService + let jwt: JwtService + let payload: JsonObject + let ref: TestingModule + let subject: TestSubject + + beforeAll(async () => { + vi.setSystemTime(date) + + ref = await Test.createTestingModule({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ useClass: JwtOptionsFactory }) + ], + providers: [TestSubject] + }).compile() + + account = new Account(new AccountFactory().makeOne()) + config = ref.get(ConfigService) + jwt = ref.get(JwtService) + subject = ref.get(TestSubject) + + payload = { email: account.email, iat: Date.now(), role: account.role } + }) + + describe('#accessToken', () => { + let options: JwtSignOptions + let signAsync: MockInstance + + beforeAll(() => { + options = { expiresIn: config.get('JWT_EXPIRY'), subject: account.uid } + }) + + beforeEach(() => { + signAsync = vi.spyOn(jwt, 'signAsync') + }) + + it('should create access token for `account`', async () => { + // Act + await subject.accessToken(account) + + // Expect + expect(signAsync).toHaveBeenCalledOnce() + expect(signAsync.mock.lastCall).to.be.an('array').of.length(2) + expect(signAsync.mock.lastCall![0]).to.eql(payload) + expect(signAsync.mock.lastCall![1]).to.eql(options) + }) + }) + + describe('#refreshToken', () => { + let options: JwtSignOptions + let signAsync: MockInstance + + beforeAll(() => { + options = { + expiresIn: config.get('JWT_EXPIRY_REFRESH'), + subject: account.uid + } + }) + + beforeEach(() => { + signAsync = vi.spyOn(jwt, 'signAsync') + }) + + it('should create refresh token for `account`', async () => { + // Act + await subject.refreshToken(account) + + // Expect + expect(signAsync).toHaveBeenCalledOnce() + expect(signAsync.mock.lastCall).to.be.an('array').of.length(2) + expect(signAsync.mock.lastCall![0]).to.eql(payload) + expect(signAsync.mock.lastCall![1]).to.eql(options) + }) + }) +}) diff --git a/src/subdomains/accounts/services/auth.service.mts b/src/subdomains/accounts/services/auth.service.mts new file mode 100644 index 00000000..09b09ae6 --- /dev/null +++ b/src/subdomains/accounts/services/auth.service.mts @@ -0,0 +1,79 @@ +/** + * @file Services - AuthService + * @module sneusers/accounts/services/Auth + */ + +import type { Account } from '@flex-development/sneusers/accounts' +import type { Config } from '@flex-development/sneusers/types' +import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { JwtService } from '@nestjs/jwt' + +/** + * Authentication and authorization service. + * + * @class + */ +@Injectable() +class AuthService { + /** + * Create a new auth service. + * + * @param {JwtService} jwt + * JSON web token service + * @param {ConfigService} config + * Application config service + */ + constructor( + protected jwt: JwtService, + protected config: ConfigService + ) {} + + /** + * Create an access token. + * + * @public + * @instance + * @async + * + * @param {Account} account + * The user account to create token for + * @return {Promise} + * User account access token + */ + public async accessToken(account: Account): Promise { + return this.jwt.signAsync({ + email: account.email, + iat: Date.now(), + role: account.role + }, { + expiresIn: this.config.get('JWT_EXPIRY'), + subject: account.uid + }) + } + + /** + * Create a refresh token. + * + * @public + * @instance + * @async + * + * @param {Account} account + * The user account to create token for + * @return {Promise} + * User account access refresh token + */ + public async refreshToken(account: Account): Promise { + return this.jwt.signAsync({ + email: account.email, + iat: Date.now(), + role: account.role + }, { + expiresIn: this.config.get('JWT_EXPIRY_REFRESH'), + subject: account.uid + }) + } +} + +export default AuthService diff --git a/src/subdomains/accounts/strategies/jwt.strategy.mts b/src/subdomains/accounts/strategies/jwt.strategy.mts new file mode 100644 index 00000000..9ed411b8 --- /dev/null +++ b/src/subdomains/accounts/strategies/jwt.strategy.mts @@ -0,0 +1,77 @@ +/** + * @file Strategies - JwtStrategy + * @module sneusers/accounts/strategies/Jwt + */ + +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 { Injectable } from '@nestjs/common' +import { PassportStrategy } from '@nestjs/passport' +import { ok } from 'devlop' +import { ExtractJwt, Strategy } from 'passport-jwt' + +/** + * JWT authentication strategy. + * + * @class + */ +@Injectable() +class JwtStrategy extends PassportStrategy(Strategy) { + /** + * Create a new JWT authentication strategy. + * + * @param {AccountsRepository} accounts + * User accounts repository + * @param {JwtOptionsFactory} options + * JWT options factory + */ + constructor( + protected accounts: AccountsRepository, + options: JwtOptionsFactory + ) { + const { secret, signOptions } = options.createJwtOptions() + + ok(typeof secret === 'string', 'expected `secret` to be a string') + ok(signOptions, 'expected jwt signing options') + + super({ + audience: signOptions.audience, + ignoreExpiration: false, + issuer: signOptions.issuer, + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + passReqToCallback: false, + secretOrKey: secret + }) + } + + /** + * Get the user account associated with `payload`. + * + * @public + * @instance + * @async + * + * @param {JsonObject} payload + * Token payload + * @return {Account | null} + * The account of the authenticated user or `null` + */ + public async validate(payload: JsonObject): Promise { + /** + * The account of the authenticated user. + * + * @var {Account | null} account + */ + let account: Account | null = null + + if ('email' in payload && typeof payload['email'] === 'string') { + account = await this.accounts.findByEmail(payload['email']) + } + + return account + } +} + +export default JwtStrategy diff --git a/typings/fastify/types/request.d.ts b/typings/fastify/types/request.d.ts index b46a98a0..b4f401f1 100644 --- a/typings/fastify/types/request.d.ts +++ b/typings/fastify/types/request.d.ts @@ -1,5 +1,13 @@ +import type { Account } from '@flex-development/sneusers/accounts' import type {} from 'fastify' declare module 'fastify' { - interface FastifyRequest {} + interface FastifyRequest { + /** + * The account of the currently authenticated user. + * + * @see {@linkcode Account} + */ + user?: Account | null | undefined + } }