From 6d7775cdde7ab394d8af887d477ecb0ed5b1bd91 Mon Sep 17 00:00:00 2001 From: Varga Zsolt Date: Sat, 20 Nov 2021 00:20:18 +0100 Subject: [PATCH] Authentication E2E tests --- changelog.md | 5 + jest.config.js | 8 +- src/app/app.e2e-test.ts | 62 ---- .../{http-client.ts => http-client.tsx} | 0 .../authentication/authentication.e2e-test.ts | 283 ++++++++++++++++++ src/modules/rest/rest.gateway.ts | 79 ++++- src/modules/rest/rest.service.ts | 224 +++++--------- storage/seed/schema/system.database.json | 12 +- 8 files changed, 438 insertions(+), 235 deletions(-) rename src/modules/admin/library/{http-client.ts => http-client.tsx} (100%) create mode 100644 src/modules/authentication/authentication.e2e-test.ts diff --git a/changelog.md b/changelog.md index dfe5d0ac..0067b54e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,8 @@ +### Version 0.0.10 + +- REST functionality tested and fixed +- Authentication with header and access key query param tested + ### Version 0.0.9 - Reorganized base structure diff --git a/jest.config.js b/jest.config.js index b2de833b..96006318 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,10 +16,10 @@ const config = { collectCoverageFrom: ['./src/**/*.ts', '!./src/**/index.ts'], coverageThreshold: { global: { - branches: 40, - functions: 40, - lines: 40, - statements: -1000, + statements: 70, + functions: 70, + lines: 70, + branches: 45, }, }, }; diff --git a/src/app/app.e2e-test.ts b/src/app/app.e2e-test.ts index 0aa2914f..4e32e988 100644 --- a/src/app/app.e2e-test.ts +++ b/src/app/app.e2e-test.ts @@ -78,68 +78,6 @@ describe('Application (e2e)', () => { expect(response.body).toMatch(/swagger/); }); - describe('Authentication', () => { - test('should respond unauthorized', async () => { - const srv = await getServer(); - - const response = await srv.inject({ - url: '/api/rest/system/database/system', - }); - - expect(response.statusCode).toBe(401); - }); - - test('should fail with 400', async () => { - const srv = await getServer(); - - const response = await srv.inject({ - method: 'POST', - url: '/api/authentication/jwt/sign-in', - payload: { - email: 'asd', - }, - }); - - expect(response.statusCode).toBe(400); - expect(response.json()).toHaveProperty('message'); - expect(response.json().message).toBe( - 'Request does not match the expected input data', - ); - }); - - test('should fail with bad password', async () => { - const srv = await getServer(); - - const response = await srv.inject({ - method: 'POST', - url: '/api/authentication/jwt/sign-in', - payload: { - email: 'demo@artgen.io', - password: 'almostdemo', - }, - }); - - expect(response.statusCode).toBe(400); - }); - - test('should pass with the right credentials', async () => { - const srv = await getServer(); - - const response = await srv.inject({ - method: 'POST', - url: '/api/authentication/jwt/sign-in', - payload: { - email: 'demo@artgen.io', - password: 'demo', - }, - }); - - expect(response.statusCode).toBe(200); - expect(response.json()).toHaveProperty('accessToken'); - expect(response.json().accessToken).toBeTruthy(); - }); - }); - describe('Rest', () => { let authHeader: { authorization: string }; diff --git a/src/modules/admin/library/http-client.ts b/src/modules/admin/library/http-client.tsx similarity index 100% rename from src/modules/admin/library/http-client.ts rename to src/modules/admin/library/http-client.tsx diff --git a/src/modules/authentication/authentication.e2e-test.ts b/src/modules/authentication/authentication.e2e-test.ts new file mode 100644 index 00000000..d11b01f8 --- /dev/null +++ b/src/modules/authentication/authentication.e2e-test.ts @@ -0,0 +1,283 @@ +import { FastifyInstance } from 'fastify'; +import { decode } from 'jsonwebtoken'; +import { AppModule } from '../../app/app.module'; +import { IKernel, Kernel } from '../../app/kernel'; +import { IJwtPayload } from './interface/jwt-payload.interface'; + +describe('Authentication (e2e)', () => { + let app: IKernel; + + const getServer = (): Promise => + app.context.get('providers.HttpServerProvider'); + + beforeAll(async () => { + process.env.NODE_ENV = 'test'; + process.env.ARTGEN_DATABASE_DSN = 'sqlite::memory:'; + + app = new Kernel(); + app.bootstrap([AppModule]); + + await app.start(); + }); + + afterAll(async () => await app.stop()); + + describe('JWT', () => { + let authHeader: { authorization: string }; + + beforeAll(async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'POST', + url: '/api/authentication/jwt/sign-in', + payload: { + email: 'demo@artgen.io', + password: 'demo', + }, + }); + + authHeader = { + authorization: 'Bearer ' + response.json().accessToken, + }; + }); + + test('should respond unauthorized', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + url: '/api/rest/system/database/system', + }); + + expect(response.statusCode).toBe(401); + }); + + test('should fail with 400', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'POST', + url: '/api/authentication/jwt/sign-in', + payload: { + email: 'asd', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toHaveProperty('message'); + expect(response.json().message).toBe( + 'Request does not match the expected input data', + ); + }); + + test('should fail with bad password', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'POST', + url: '/api/authentication/jwt/sign-in', + payload: { + email: 'demo@artgen.io', + password: 'almostdemo', + }, + }); + + expect(response.statusCode).toBe(400); + }); + + test('should pass with the right credentials', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'POST', + url: '/api/authentication/jwt/sign-in', + payload: { + email: 'demo@artgen.io', + password: 'demo', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveProperty('accessToken'); + expect(response.json().accessToken).toBeTruthy(); + }); + + test('should generate an access token', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'POST', + url: '/api/authentication/jwt/sign-in', + payload: { + email: 'demo@artgen.io', + password: 'demo', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveProperty('accessToken'); + expect(response.json().accessToken).toBeTruthy(); + }); + + test('should accept the access token', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/database/system', + headers: authHeader, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveProperty('name'); + expect(response.json().name).toBe('system'); + }); + }); + + describe('Access Key', () => { + let validAccessKey: string; + + test('should respond without key', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/database/system', + }); + + expect(response.statusCode).toBe(401); + }); + + test('should respond with invalid key format', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/database/system?access-key=x', + }); + + expect(response.statusCode).toBe(401); + }); + + test('should respond with invalid key uuid query', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/database/system?access-key=336373a4-f61a-4b08-b7b0-43d3147f6e58', + }); + + expect(response.statusCode).toBe(401); + }); + + test('should create an access key', async () => { + const srv = await getServer(); + + const signin = await srv.inject({ + method: 'POST', + url: '/api/authentication/jwt/sign-in', + payload: { + email: 'demo@artgen.io', + password: 'demo', + }, + }); + + const aid = (decode(signin.json().accessToken) as IJwtPayload).aid; + expect(aid).toBeTruthy(); + + const create = await srv.inject({ + method: 'POST', + url: '/api/rest/system/access-key', + headers: { + Authorization: 'Bearer ' + signin.json().accessToken, + }, + payload: { + accountId: aid, + }, + }); + + const createResp = create.json(); + + expect(create.statusCode).toBe(201); + expect(createResp).toHaveProperty('key'); + expect(createResp).toHaveProperty('issuedAt'); + expect(createResp).toHaveProperty('accountId'); + expect(createResp.accountId).toBe(aid); + + validAccessKey = create.json().key; + }); + + test('should accept the access key (query)', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/database/system?access-key=' + validAccessKey, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveProperty('name'); + expect(response.json().name).toBe('system'); + }); + + test('should accept the access key (header)', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/database/system', + headers: { + 'X-Access-Key': validAccessKey, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveProperty('name'); + expect(response.json().name).toBe('system'); + }); + + test('should be able to read the access key', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'GET', + url: '/api/rest/system/access-key/' + validAccessKey, + headers: { + 'X-Access-Key': validAccessKey, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toHaveProperty('key'); + expect(response.json().key).toBe(validAccessKey); + }); + + test('should be able to delete the access key', async () => { + const srv = await getServer(); + + const response = await srv.inject({ + method: 'DELETE', + url: '/api/rest/system/access-key/' + validAccessKey, + headers: { + 'X-Access-Key': validAccessKey, + }, + }); + + expect(response.statusCode).toBe(200); + }); + + test('should refuse deleted access key', async () => { + const srv = await getServer(); + + const delRes = await srv.inject({ + method: 'GET', + url: '/api/rest/system/access-key/' + validAccessKey, + headers: { + 'X-Access-Key': validAccessKey, + }, + }); + + expect(delRes.statusCode).toBe(401); + }); + }); +}); diff --git a/src/modules/rest/rest.gateway.ts b/src/modules/rest/rest.gateway.ts index 5f2fd6cb..4d8d79dc 100644 --- a/src/modules/rest/rest.gateway.ts +++ b/src/modules/rest/rest.gateway.ts @@ -8,7 +8,6 @@ import { Inject, Service } from '../../app/container'; import { AuthenticationHandlerProvider } from '../authentication/provider/authentication-handler.provider'; import { ContentAction } from '../content/interface/content-action.enum'; import { IHttpGateway } from '../http/interface/http-gateway.interface'; -import { IODataResult } from '../odata/interface/odata-result.interface'; import { SchemaService } from '../schema/service/schema.service'; import { RestService } from './rest.service'; @@ -50,7 +49,7 @@ export class RestGateway implements IHttpGateway { reply.statusCode = 201; - return response; + return JSON.parse(JSON.stringify(response)); // TODO: need to convert the date into string before responding } catch (error) { reply.statusCode = 400; @@ -83,14 +82,14 @@ export class RestGateway implements IHttpGateway { ); if (record) { - return record; + return JSON.parse(JSON.stringify(record)); } // Handle the 404 error reply.statusCode = 404; return { - error: 'Not found', + error: 'Not Found', statusCode: 404, }; }, @@ -109,13 +108,37 @@ export class RestGateway implements IHttpGateway { async ( req: FastifyRequest<{ Body: object; + Params: Record; }>, - ): Promise => { - return this.service.update( - schema.database, - schema.reference, - req.body as any[], - ); + reply: FastifyReply, + ): Promise => { + try { + const record = await this.service.update( + schema.database, + schema.reference, + req.params, + req.body as any, + ); + + if (record) { + return JSON.parse(JSON.stringify(record)); + } + + // Handle the 404 error + reply.statusCode = 404; + + return { + error: 'Not Found', + statusCode: 404, + }; + } catch (error) { + reply.statusCode = 400; + + return { + statusCode: 400, + error: 'Bad Request', + }; + } }, ); @@ -129,12 +152,36 @@ export class RestGateway implements IHttpGateway { ), preHandler, }, - async (req: FastifyRequest): Promise => { - return this.service.delete( - schema.database, - schema.reference, - req.query as Record, - ); + async ( + req: FastifyRequest<{ Params: Record }>, + reply: FastifyReply, + ): Promise => { + try { + const record = await this.service.delete( + schema.database, + schema.reference, + req.params, + ); + + if (record) { + return JSON.parse(JSON.stringify(record)); + } + + // Handle the 404 error + reply.statusCode = 404; + + return { + error: 'Not Found', + statusCode: 404, + }; + } catch (error) { + reply.statusCode = 400; + + return { + statusCode: 400, + error: 'Bad Request', + }; + } }, ); } diff --git a/src/modules/rest/rest.service.ts b/src/modules/rest/rest.service.ts index 7e491bb9..922402a1 100644 --- a/src/modules/rest/rest.service.ts +++ b/src/modules/rest/rest.service.ts @@ -1,16 +1,11 @@ import { EventEmitter2 } from 'eventemitter2'; import { FastifySchema } from 'fastify'; import { JSONSchema7Definition, JSONSchema7Object } from 'json-schema'; -import { kebabCase, merge } from 'lodash'; -import parseOData from 'odata-sequelize'; -import { stringify } from 'querystring'; -import { Op, WhereOptions } from 'sequelize'; +import { kebabCase } from 'lodash'; import { ILogger, Inject, Logger } from '../../app/container'; import { Exception } from '../../app/exceptions/exception'; -import { getErrorMessage } from '../../app/kernel'; import { ContentAction } from '../content/interface/content-action.enum'; import { schemaToJsonSchema } from '../content/util/schema-to-jsonschema'; -import { IODataResult } from '../odata/interface/odata-result.interface'; import { ISchema } from '../schema'; import { SchemaService } from '../schema/service/schema.service'; import { isManagedField, isPrimary } from '../schema/util/is-primary'; @@ -91,144 +86,60 @@ export class RestService { async update( database: string, reference: string, - inputs: SchemaInput[], - ): Promise { + idValues: Record, + input: object, + ): Promise { // Define the event key. const event = `crud.${database}.${reference}.updated`; // Load the model const model = this.schema.model(database, reference); // Load the data schema const schema = this.schema.findOne(database, reference); - const primaryKeys = schema.fields.filter(isPrimary).map(f => f.reference); - const result: IODataResult[] = []; - const queryFilters: WhereOptions = {}; - const validInputs = []; - - // Prebuidl the query filter with empty arrays - for (const pk of primaryKeys) { - queryFilters[pk] = { [Op.in]: [] }; - } - - for (const input of inputs) { - const startedAt = Date.now(); - const inputKeys = Object.keys(input); - const hasPrimaryKeys = primaryKeys.every(pk => inputKeys.includes(pk)); - - // Check if the input provided the required primary keys. - if (!hasPrimaryKeys) { - result.push({ - meta: { - status: 'error', - action: 'update', - executionTime: Date.now() - startedAt, - }, - data: { - message: 'Input does not have the required primary key(s) defined', - input, - }, - }); - - continue; - } - - for (const pk of primaryKeys) { - queryFilters[pk][Op.in].push(input[pk]); - } - - validInputs.push(input); - } - // Fetch the records subjected for the update - const records = await model.findAll({ - where: queryFilters, - limit: validInputs.length, + // Fetch the record + const record = await model.findOne({ + where: idValues, }); - for (const input of validInputs) { - const startedAt = Date.now(); + if (!record) { + return null; + } - // Find the recrod for the input - const record = records.find(r => { - for (const pk of primaryKeys) { - if (input[pk] !== r[pk]) { - return false; - } + for (const key in input) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + const value = input[key]; + const field = schema.fields.find(f => f.reference === key); + + // Strip extra fields + if (!field) { + this.logger.warn( + 'Field [%s] does not exists on the schema [%s]', + key, + schema.reference, + ); + continue; } - return true; - }); - - // Check if the input matches any query-d PK - if (!record) { - result.push({ - meta: { - status: 'error', - action: 'update', - executionTime: Date.now() - startedAt, - }, - data: { - message: 'Input does not match any database record', - input, - }, - }); - - continue; - } - - for (const key in input) { - if (Object.prototype.hasOwnProperty.call(input, key)) { - const value = input[key]; - const field = schema.fields.find(f => f.reference === key); - - // Strip extra fields - if (!field) { - this.logger.warn( - 'Field [%s] does not exists on the schema [%s]', - key, - schema.reference, - ); - continue; - } - - // Skip on generated fields. - if (isManagedField(field)) { - continue; - } - - record.set(key, value); + // Skip on generated fields. + if (isManagedField(field) || isPrimary(field)) { + continue; } - } - try { - // Commit the changes - await record.save(); - const object = record.get({ plain: true }); - - result.push({ - meta: { - action: 'update', - status: 'success', - executionTime: Date.now() - startedAt, - }, - data: object, - }); - - this.event.emit(event, object); - } catch (error) { - result.push({ - meta: { - action: 'update', - status: 'error', - executionTime: Date.now() - startedAt, - }, - data: { - message: getErrorMessage(error), - }, - }); + record.set(key, value); } } - return result; + try { + // Commit the changes + await record.save(); + const object = record.get({ plain: true }); + + this.event.emit(event, object); + + return object; + } catch (error) { + throw new Exception('Invalid input'); + } } /** @@ -237,35 +148,33 @@ export class RestService { async delete( database: string, reference: string, - filters: SchemaInput, - ): Promise { - const startedAt = Date.now(); + idValues: Record, + ): Promise { // Define the event key. const event = `crud.${database}.${reference}.deleted`; // Get the model const model = this.schema.model(database, reference); - // Merge with a safer skip 0 option - const options = merge(filters, { - $skip: 0, + + // Fetch the record + const record = await model.findOne({ + where: idValues, }); - const queryString = decodeURIComponent(stringify(options as any)); - const queryFilter = parseOData(queryString.toString(), model.sequelize); - const records = await model.findAll(queryFilter); - for (const record of records) { + if (!record) { + return null; + } + + try { + // Delete record + const object = record.get({ plain: true }); await record.destroy(); - this.event.emit(event, record.get({ plain: true })); - } + this.event.emit(event, object); - return { - meta: { - status: 'success', - action: 'delete', - executionTime: Date.now() - startedAt, - }, - data: records.length, - }; + return object; + } catch (error) { + throw new Exception('Invalid record'); + } } getResourceURL(schema: ISchema): string { @@ -302,6 +211,7 @@ export class RestService { switch (action) { case ContentAction.CREATE: + definition.response[400] = this.getBadRequestResponseSchema(); definition.response[201] = { description: 'Created', ...(schemaToJsonSchema( @@ -313,6 +223,7 @@ export class RestService { break; case ContentAction.READ: + definition.response[400] = this.getBadRequestResponseSchema(); definition.response[404] = this.getNotFoundResponseSchema(); definition.response[200] = { description: 'OK', @@ -325,6 +236,7 @@ export class RestService { break; case ContentAction.UPDATE: + definition.response[400] = this.getBadRequestResponseSchema(); definition.response[404] = this.getNotFoundResponseSchema(); definition.response[200] = { description: 'OK', @@ -338,6 +250,7 @@ export class RestService { break; case ContentAction.DELETE: + definition.response[400] = this.getBadRequestResponseSchema(); definition.response[404] = this.getNotFoundResponseSchema(); definition.response[200] = { description: 'OK', @@ -389,6 +302,23 @@ export class RestService { }; } + protected getBadRequestResponseSchema(): JSONSchema7Definition { + return { + description: 'Request input is not valid', + type: 'object', + properties: { + error: { + type: 'string', + default: 'Bad Request', + }, + statusCode: { + type: 'number', + default: 400, + }, + }, + }; + } + protected getNotFoundResponseSchema(): JSONSchema7Definition { return { description: 'Resource not found', diff --git a/storage/seed/schema/system.database.json b/storage/seed/schema/system.database.json index 8e09244b..90b16545 100644 --- a/storage/seed/schema/system.database.json +++ b/storage/seed/schema/system.database.json @@ -154,9 +154,9 @@ ] }, { - "reference": "isseudAt", - "label": "Isseud At", - "columnName": "isseud_at", + "reference": "issuedAt", + "label": "Issued At", + "columnName": "issued_at", "type": "timestamp", "typeParams": { "values": [] @@ -166,7 +166,7 @@ ] }, { - "reference": "accoundId", + "reference": "accountId", "label": "Accound ID", "columnName": "accound_id", "type": "uuid", @@ -183,7 +183,7 @@ "kind": "belongs-to-one", "name": "account", "target": "Account", - "localField": "accoundId", + "localField": "accountId", "remoteField": "id" } ], @@ -246,7 +246,7 @@ "name": "accessKeys", "target": "AccessKey", "localField": "id", - "remoteField": "accoundId" + "remoteField": "accountId" } ], "tags": [