From 668da0ceeb2ff511e9f2ca952509ecd19a7d863b Mon Sep 17 00:00:00 2001 From: Keyvan Chamani Date: Wed, 27 Dec 2023 13:19:42 -0500 Subject: [PATCH] Topics/k1ch/Admin APIs - POST:/personas (#79) * feat: topics/k1ch/ introduce POST/personas * feat: topics/k1ch/POST-personas/error handling * chore: topics/k1ch/POST-personas/db-layer and API tests * chore: topics/k1ch/POST-personas/code style clean * chore: topics/k1ch/POST-personas/minor updates --- database/constant/PgErrorCodes.js | 55 +++++++++++++ database/knexfile.js | 1 + database/layer/admin-persona.js | 32 +++++-- database/layer/knex.js | 5 ++ database/package.json | 2 +- database/test/db-admin-persona.test.js | 30 ++++--- database/utils/pgErrorHandler.js | 82 ++++++++++++++++++ server/package.json | 2 +- server/src/api_endpoints/personas/persona.js | 16 ++++ server/test/endpoint_admin_personas.test.js | 67 +++++++++++++++ server/the-usher-openapi-spec.yaml | 87 ++++++++++++++++++++ 11 files changed, 357 insertions(+), 22 deletions(-) create mode 100644 database/constant/PgErrorCodes.js create mode 100644 database/layer/knex.js create mode 100644 database/utils/pgErrorHandler.js create mode 100644 server/src/api_endpoints/personas/persona.js create mode 100644 server/test/endpoint_admin_personas.test.js diff --git a/database/constant/PgErrorCodes.js b/database/constant/PgErrorCodes.js new file mode 100644 index 0000000..600d50f --- /dev/null +++ b/database/constant/PgErrorCodes.js @@ -0,0 +1,55 @@ +const PgErrorCodes = { + // Class 08 - Connection Exception + ConnectionException: '08000', + ConnectionDoesNotExist: '08003', + ConnectionFailure: '08006', + + // Class 22 - Data Exception + DataException: '22000', + NullValueNotAllowed: '22004', + NumericValueOutOfRange: '22003', + InvalidTextRepresentation: '22P02', + + // Class 23 - Integrity Constraint Violation + IntegrityConstraintViolation: '23000', + NotNullViolation: '23502', + ForeignKeyViolation: '23503', + UniqueViolation: '23505', + CheckViolation: '23514', + + // Class 25 - Invalid Transaction State + InvalidTransactionState: '25000', + ActiveSQLTransaction: '25001', + InFailedSQLTransaction: '25P02', + + // Class 28 - Invalid Authorization Specification + InvalidAuthorizationSpecification: '28000', + InvalidPassword: '28P01', + + // Class 40 - Transaction Rollback + TransactionRollback: '40000', + SerializationFailure: '40001', + DeadlockDetected: '40P01', + + // Class 42 - Syntax Error or Access Rule Violation + SyntaxErrorOrAccessRuleViolation: '42000', + SyntaxError: '42601', + UndefinedColumn: '42703', + UndefinedTable: '42P01', + DuplicateColumn: '42701', + DuplicateTable: '42P07', + + // Class 53 - Insufficient Resources + InsufficientResources: '53000', + DiskFull: '53100', + OutOfMemory: '53200', + TooManyConnections: '53300', + + // Class 54 - Program Limit Exceeded + ProgramLimitExceeded: '54000', + StatementTooComplex: '54001', +} + +module.exports = { + PgErrorCodes, +} diff --git a/database/knexfile.js b/database/knexfile.js index 2ee3b46..c213af2 100644 --- a/database/knexfile.js +++ b/database/knexfile.js @@ -3,6 +3,7 @@ const env = require('./database-env') module.exports = { client: 'pg', connection: env.PGURI, + searchPath: [env.PGSCHEMA, 'public'], migrations: { tableName: 'knex_migrations', schemaName: env.PGSCHEMA diff --git a/database/layer/admin-persona.js b/database/layer/admin-persona.js index 43c6bdb..da8aaaa 100644 --- a/database/layer/admin-persona.js +++ b/database/layer/admin-persona.js @@ -1,13 +1,9 @@ const { PGPool } = require('./pg_pool') const pool = new PGPool() +const { usherDb } = require('./knex') +const { pgErrorHandler } = require('../utils/pgErrorHandler') -module.exports = { - insertPersona, - deletePersona, - updatePersona -} - -async function insertPersona (tenantName, issClaim, subClaim, userContext) { +const insertPersona = async (tenantName, issClaim, subClaim, userContext) => { const sql = `INSERT INTO usher.personas (tenantkey, sub_claim, user_context) SELECT key, $3, $4 FROM usher.tenants @@ -30,7 +26,7 @@ async function insertPersona (tenantName, issClaim, subClaim, userContext) { } } -async function deletePersona (tenantName, issClaim, subClaim, userContext) { +const deletePersona = async (tenantName, issClaim, subClaim, userContext) => { const sql = `DELETE FROM usher.personas p WHERE EXISTS (SELECT 1 FROM usher.tenants t WHERE t.KEY = p.tenantkey AND t.name = $1 and t.iss_claim = $2) AND p.sub_claim = $3 AND p.user_context = $4` @@ -48,7 +44,7 @@ async function deletePersona (tenantName, issClaim, subClaim, userContext) { } } -async function updatePersona (tenantName, issClaim, oldSubClaim, newSubClaim, oldUserContext, newUserContext) { +const updatePersona = async (tenantName, issClaim, oldSubClaim, newSubClaim, oldUserContext, newUserContext) => { const sql = `UPDATE usher.personas p SET sub_claim = $4, user_context = $6 WHERE EXISTS (SELECT 1 FROM usher.tenants t WHERE t.KEY = p.tenantkey AND t.name = $1 and t.iss_claim = $2) AND p.sub_claim = $3 AND p.user_context = $5` @@ -65,3 +61,21 @@ async function updatePersona (tenantName, issClaim, oldSubClaim, newSubClaim, ol return `Update failed: ${error.message}` } } + +const insertPersonaByTenantKey = async (tenantKey, subClaim, userContext = '') => { + try { + const [persona] = await usherDb('personas') + .insert({ tenantkey: tenantKey, sub_claim: subClaim, user_context: userContext }) + .returning(['key', 'sub_claim', 'tenantkey', 'user_context', 'created_at']) + return persona + } catch (err) { + throw pgErrorHandler(err) + } +} + +module.exports = { + insertPersona, + deletePersona, + updatePersona, + insertPersonaByTenantKey, +} diff --git a/database/layer/knex.js b/database/layer/knex.js new file mode 100644 index 0000000..2c742b2 --- /dev/null +++ b/database/layer/knex.js @@ -0,0 +1,5 @@ +const knex = require('knex'); +const knexDbConfig = require('../knexfile'); +const usherDb = knex(knexDbConfig); + +module.exports = { usherDb } diff --git a/database/package.json b/database/package.json index e459ec3..6d2bf69 100644 --- a/database/package.json +++ b/database/package.json @@ -3,7 +3,7 @@ "version": "1.6.1", "description": "Database layer for TheUsher", "scripts": { - "test": "mocha", + "test": "mocha --exit", "db-reset-test-data": "node ./init/reset_testdata.js", "db-drop-create-schema": "node ./init/drop-create-schema.js", "db-security-test-data": "node ./init/load_security_test_data.js", diff --git a/database/test/db-admin-persona.test.js b/database/test/db-admin-persona.test.js index d6a1315..fd83344 100644 --- a/database/test/db-admin-persona.test.js +++ b/database/test/db-admin-persona.test.js @@ -1,45 +1,53 @@ const { describe, it } = require('mocha') const assert = require('assert') const postPersonas = require('../layer/admin-persona.js') +const { usherDb } = require('../layer/knex') -describe('Admin persona view', function () { - describe('Test INSERT personas', function () { - it('Should insert persona without an exception', async function () { +describe('Admin persona view', () => { + describe('Test INSERT personas', () => { + it('Should insert persona without an exception', async () => { const insertResult = await postPersonas.insertPersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-1@dmgtoocto.com', '') assert.strictEqual(insertResult, 'Insert successful') await postPersonas.deletePersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-1@dmgtoocto.com', '') }) - it('Should fail to insert for a nonexistent tenant', async function () { + it('Should fail to insert for a nonexistent tenant', async () => { const insertResult = await postPersonas.insertPersona('test-tenant1 Non-existent', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-3@dmgtoocto.com', '') assert.strictEqual(insertResult, 'Insert failed: Tenant does not exist matching tenantname test-tenant1 Non-existent iss_claim http://idp.dmgt.com.mock.localhost:3002/') }) - it('Should fail to insert duplicate tenant/persona combination - check tenantname', async function () { + it('Should fail to insert duplicate tenant/persona combination - check tenantname', async () => { await postPersonas.insertPersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-x@dmgtoocto.com', '') const result = await postPersonas.insertPersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-x@dmgtoocto.com', '') assert.strictEqual(result, 'Insert failed: A persona (sub_claim = test-dmgt-oocto-x@dmgtoocto.com; user_context = ) already exists on tenantname test-tenant1 iss_claim http://idp.dmgt.com.mock.localhost:3002/') await postPersonas.deletePersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-x@dmgtoocto.com', '') }) + it('Should insert persona by tenant key without an exception', async () => { + const subClaim = 'test-user@the-usher.com' + const [tenant] = await usherDb('tenants').select('*').limit(1) + const persona = await postPersonas.insertPersonaByTenantKey(tenant.key, subClaim) + assert.strictEqual(persona.sub_claim, subClaim) + await usherDb('personas').where({ key: persona.key }).del() + }) }) - describe('Test UPDATE personas', function () { - it('Should update persona without an exception by tenantname', async function () { + describe('Test UPDATE personas', () => { + it('Should update persona without an exception by tenantname', async () => { await postPersonas.insertPersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-5@dmgtoocto.com', '') const resultTenantname = await postPersonas.updatePersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-5@dmgtoocto.com', 'test-dmgt-oocto-7@dmgtoocto.com', '', '') assert.strictEqual(resultTenantname, 'Update successful') await postPersonas.deletePersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'test-dmgt-oocto-7@dmgtoocto.com', '') }) - it('Should fail to update for a nonexistent tenant', async function () { + it('Should fail to update for a nonexistent tenant', async () => { const resultTenantname = await postPersonas.updatePersona('test-tenant1 Non-existent', 'http://idp.dmgt.com.mock.localhost:3002/', 'auth0|test-persona2-REPLACE', 'should_not_replace_sub_claim', '', '') assert.strictEqual(resultTenantname, 'Update failed: A persona (sub_claim = auth0|test-persona2-REPLACE; user_context = ) does not exist on tenantname test-tenant1 Non-existent iss_claim http://idp.dmgt.com.mock.localhost:3002/') }) - it('Should fail to update for a nonexistent persona', async function () { + it('Should fail to update for a nonexistent persona', async () => { const resultTenantname = await postPersonas.updatePersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'does-not-exist@dmgtoocto.com', 'should_not_replace_sub_claim', '', '') assert.strictEqual(resultTenantname, 'Update failed: A persona (sub_claim = does-not-exist@dmgtoocto.com; user_context = ) does not exist on tenantname test-tenant1 iss_claim http://idp.dmgt.com.mock.localhost:3002/') }) }) - describe('Test DELETE personas', function () { - it('Should fail to delete a persona not linked to a tenant', async function () { + describe('Test DELETE personas', () => { + it('Should fail to delete a persona not linked to a tenant', async () => { const resultDelete = await postPersonas.deletePersona('test-tenant1', 'http://idp.dmgt.com.mock.localhost:3002/', 'no-persona@dmgtoocto.com', '') assert.strictEqual(resultDelete, 'Delete failed: A persona (sub_claim = no-persona@dmgtoocto.com; user_context = ) does not exist on tenantname test-tenant1 iss_claim http://idp.dmgt.com.mock.localhost:3002/') }) diff --git a/database/utils/pgErrorHandler.js b/database/utils/pgErrorHandler.js new file mode 100644 index 0000000..6da9afd --- /dev/null +++ b/database/utils/pgErrorHandler.js @@ -0,0 +1,82 @@ +const { PgErrorCodes } = require('../constant/PgErrorCodes') + +/** + * Handles database errors into a generic message and appropriate http status code + * @param pgDbError The error thrown by the database. + * @returns {message: text, httpStatusCode: number} +*/ +const pgErrorHandler = (pgDbError) => { + const error = {} + switch (pgDbError.code) { + case PgErrorCodes.UniqueViolation: + error.message = 'The operation would result in duplicate resources!' + error.httpStatusCode = 409 + break + + case PgErrorCodes.CheckViolation: + error.message = 'The operation would violate a check constraint!' + error.httpStatusCode = 400 + break + + case PgErrorCodes.NotNullViolation: + error.message = 'A required value is missing!' + error.httpStatusCode = 400 + break + + case PgErrorCodes.ForeignKeyViolation: + error.message = 'Referenced resource is invalid!' + error.httpStatusCode = 400 + break + + case PgErrorCodes.InvalidTextRepresentation: + error.message = 'The provided data format is invalid!' + error.httpStatusCode = 400 + break + + case PgErrorCodes.SerializationFailure: + error.message = 'Internal DB Error: A transaction serialization error occurred!' + error.httpStatusCode = 500 + break + + case PgErrorCodes.DeadlockDetected: + error.message = 'Internal DB Error: The operation was halted due to a potential deadlock!' + error.httpStatusCode = 500 + break + + case PgErrorCodes.SyntaxError: + error.message = 'Internal DB Error: There is a syntax error in the provided SQL or data!' + error.httpStatusCode = 500 + break + + case PgErrorCodes.UndefinedTable: + error.message = 'Internal DB Error: The table or view you are trying to access does not exist!' + error.httpStatusCode = 500 + break + + case PgErrorCodes.DiskFull: + error.message = 'Internal DB Error: The operation failed due to insufficient disk space!' + error.httpStatusCode = 500 + break + + case PgErrorCodes.OutOfMemory: + error.message = 'Internal DB Error: The system ran out of memory!' + error.httpStatusCode = 500 + break + + case PgErrorCodes.TooManyConnections: + error.message = 'Internal DB Error: There are too many connections to the database!' + error.httpStatusCode = 500 + break + + default: + error.message = `Unexpected Error: ${error?.message}!` + error.httpStatusCode = 500 + break + } + + return error +} + +module.exports = { + pgErrorHandler, +} diff --git a/server/package.json b/server/package.json index 6a627b2..9019fea 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,7 @@ }, "main": "the-usher.js", "scripts": { - "test": "mocha --file test/* --timeout 10000", + "test": "mocha --exit --file test/*", "start": "nodemon -L --watch the-usher-openapi-spec.yaml --watch ./ --watch ../database/layer the-usher.js", "debug": "nodemon -L --watch the-usher-openapi-spec.yaml --watch ./src --watch ./the-usher.js --watch ../database/layer --inspect=0.0.0.0:9229 the-usher.js" }, diff --git a/server/src/api_endpoints/personas/persona.js b/server/src/api_endpoints/personas/persona.js new file mode 100644 index 0000000..3a00914 --- /dev/null +++ b/server/src/api_endpoints/personas/persona.js @@ -0,0 +1,16 @@ +const createError = require('http-errors') +const dbAdminPersona = require('database/layer/admin-persona') + +const createPersona = async (req, res, next) => { + try { + const { tenant_key, sub_claim, user_context } = req.body + const persona = await dbAdminPersona.insertPersonaByTenantKey(tenant_key, sub_claim, user_context) + res.status(201).send(persona) + } catch ({ httpStatusCode = 500, message }) { + return next(createError(httpStatusCode, { message })) + } +} + +module.exports = { + createPersona, +} diff --git a/server/test/endpoint_admin_personas.test.js b/server/test/endpoint_admin_personas.test.js new file mode 100644 index 0000000..ac788cf --- /dev/null +++ b/server/test/endpoint_admin_personas.test.js @@ -0,0 +1,67 @@ +const { describe, it, before, afterEach } = require('mocha') +const fetch = require('node-fetch') +const assert = require('assert') + +const { getAdmin1IdPToken } = require('./lib/tokens') +const { getServerUrl } = require('./lib/urls') +const { usherDb } = require('../../database/layer/knex') + +describe('Admin Personas', () => { + let requestHeaders + const url = `${getServerUrl()}` + + before(async () => { + const userAccessToken = await getAdmin1IdPToken() + requestHeaders = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${userAccessToken}`, + } + }) + + describe('POST:/personas', () => { + const validPersonaPayload = { + 'sub_claim': 'test-persona@the-usher-server.com', + 'tenant_key': 1, + } + + it('should return 201 - create a persona', async () => { + const response = await fetch(`${url}/personas`, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(validPersonaPayload) + }) + assert.strictEqual(response.status, 201) + const persona = await response.json() + assert.strictEqual(persona.sub_claim, validPersonaPayload.sub_claim) + }) + + it('should return 400 - fail to create a persona due to invalid tenant', async () => { + const response = await fetch(`${url}/personas`, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify({ + ...validPersonaPayload, + 'tenant_key': 1000, + }) + }) + assert.strictEqual(response.status, 400) + }) + + it('should return 409 - fail to create persona due to conflict', async () => { + const { sub_claim, tenant_key: tenantkey } = validPersonaPayload + await usherDb('personas').insert({ sub_claim, tenantkey }) + const response = await fetch(`${url}/personas`, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(validPersonaPayload) + }) + assert.strictEqual(response.status, 409) + }) + + afterEach(async () => { + try { + await usherDb('personas').where({ sub_claim: validPersonaPayload.sub_claim }).del() + } catch { } + }) + }) +}) diff --git a/server/the-usher-openapi-spec.yaml b/server/the-usher-openapi-spec.yaml index ef49dbc..14f947b 100644 --- a/server/the-usher-openapi-spec.yaml +++ b/server/the-usher-openapi-spec.yaml @@ -431,6 +431,47 @@ paths: 404: $ref: '#/components/responses/NotFound' + /personas: + post: + 'x-swagger-router-controller': 'personas/persona' + summary: Create a single persona + operationId: createPersona + tags: + - Admin APIs + security: + - bearerAdminAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tenant_key: + type: number + minimum: 1 + format: int32 + sub_claim: + type: string + user_context: + type: string + required: + - tenant_key + - sub_claim + responses: + 201: + description: Returns the created persona + content: + application/json: + schema: + $ref: "#/components/schemas/Persona" + 400: + $ref: '#/components/responses/BadRequest' + 409: + $ref: '#/components/responses/Conflict' + 500: + $ref: '#/components/responses/InternalError' + /clients/{client_id}: get: @@ -758,6 +799,31 @@ components: - client_id: client-app2 clientname: Client Application 2 + Persona: + type: object + properties: + key: + type: integer + minimum: 1 + format: int32 + tenantkey: + type: integer + minimum: 1 + format: int32 + user_context: + type: string + nullable: true + sub_claim: + type: string + created_at: + type: string + updated_at: + type: string + required: + - key + - tenantkey + - sub_claim + # SCOPE #--------------------- ArrayOfScope: @@ -988,6 +1054,16 @@ components: code: 404 message: Not Found + Conflict: + description: Resource already exists + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 409 + message: Resource already exists! + UnsupportedMediaType: description: The request entity has a media type which the server or resource does not support content: @@ -1007,6 +1083,17 @@ components: example: code: 429 message: Too Many Requests + + InternalError: + description: Internal Server Error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + example: + code: 500 + message: Internal Error! + Default: description: Unexpected error content: