From 343762cd6fdb2ce2f41484b86078cdebc6dcecaf Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:17:47 -0500 Subject: [PATCH 1/6] feat(auth): tenant service --- packages/auth/src/app.ts | 2 + packages/auth/src/index.ts | 11 ++ packages/auth/src/tenant/service.test.ts | 139 +++++++++++++++++++++++ packages/auth/src/tenant/service.ts | 82 +++++++++++++ 4 files changed, 234 insertions(+) create mode 100644 packages/auth/src/tenant/service.test.ts create mode 100644 packages/auth/src/tenant/service.ts diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index b8bd12a938..dba8efebda 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -54,6 +54,7 @@ import { Redis } from 'ioredis' import { LoggingPlugin } from './graphql/plugin' import { gnapServerErrorMiddleware } from './shared/gnapErrors' import { verifyApiSignature } from './shared/utils' +import { TenantService } from './tenant/service' export interface AppContextData extends DefaultContext { logger: Logger @@ -102,6 +103,7 @@ export interface AppServices { grantRoutes: Promise interactionRoutes: Promise redis: Promise + tenantService: Promise } export type AppContainer = IocContract diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 2315fe083b..356b321cf1 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -21,6 +21,7 @@ import { import { createInteractionService } from './interaction/service' import { getTokenIntrospectionOpenAPI } from 'token-introspection' import { Redis } from 'ioredis' +import { createTenantService } from './tenant/service' const container = initIocContainer(Config) const app = new App(container) @@ -209,6 +210,16 @@ export function initIocContainer( return new Redis(config.redisUrl, { tls: config.redisTls }) }) + container.singleton( + 'tenantService', + async (deps: IocContract) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex') + }) + } + ) + return container } diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts new file mode 100644 index 0000000000..f9aa6a9f81 --- /dev/null +++ b/packages/auth/src/tenant/service.test.ts @@ -0,0 +1,139 @@ +import { faker } from '@faker-js/faker' +import { Knex } from 'knex' +import { createTestApp, TestContainer } from '../tests/app' +import { truncateTables } from '../tests/tableManager' +import { Config } from '../config/app' +import { IocContract } from '@adonisjs/fold' +import { initIocContainer } from '../' +import { AppServices } from '../app' +import { TenantService } from './service' +import { Tenant } from './service' + +describe('Tenant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantService: TenantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + + tenantService = await deps.use('tenantService') + + // TODO: remove. temporary implementation while other issue is completed + const knex = await deps.use('knex') + await knex.schema.createTable('tenants', (table) => { + table.string('id').primary() + table.string('idpConsentUrl').notNullable() + table.string('idpSecret').notNullable() + }) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + const createTenantData = () => ({ + id: faker.string.uuid(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + }) + + describe('create', (): void => { + test('creates a tenant', async (): Promise => { + const tenantData = createTenantData() + const tenant = await tenantService.create(tenantData) + + expect(tenant).toMatchObject({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret + }) + }) + + test('fails to create tenant with duplicate id', async (): Promise => { + const tenantData = createTenantData() + await tenantService.create(tenantData) + + await expect(tenantService.create(tenantData)).rejects.toThrow() + }) + }) + + describe('get', (): void => { + test('retrieves an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const tenant = await tenantService.get(created.id) + expect(tenant).toMatchObject(tenantData) + }) + + test('returns undefined for non-existent tenant', async (): Promise => { + const tenant = await tenantService.get(faker.string.uuid()) + expect(tenant).toBeUndefined() + }) + }) + + describe('update', (): void => { + test('updates an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toMatchObject({ + id: created.id, + ...updateData + }) + }) + + test('returns undefined for non-existent tenant', async (): Promise => { + const updated = await tenantService.update(faker.string.uuid(), { + idpConsentUrl: faker.internet.url() + }) + expect(updated).toBeUndefined() + }) + + test('can update partial fields', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url() + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toMatchObject({ + id: created.id, + idpConsentUrl: updateData.idpConsentUrl, + idpSecret: created.idpSecret + }) + }) + }) + + describe('delete', (): void => { + test('deletes an existing tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const result = await tenantService.delete(created.id) + expect(result).toBe(true) + + const tenant = await tenantService.get(created.id) + expect(tenant).toBeUndefined() + }) + + test('returns false for non-existent tenant', async (): Promise => { + const result = await tenantService.delete(faker.string.uuid()) + expect(result).toBe(false) + }) + }) +}) diff --git a/packages/auth/src/tenant/service.ts b/packages/auth/src/tenant/service.ts new file mode 100644 index 0000000000..28c3b4f535 --- /dev/null +++ b/packages/auth/src/tenant/service.ts @@ -0,0 +1,82 @@ +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex, Model } from 'objection' + +// TODO: remove. temporary implementation while other issue is completed +export class Tenant extends Model { + static tableName = 'tenants' + + id!: string + idpConsentUrl!: string + idpSecret!: string +} + +export interface CreateOptions { + id: string + idpConsentUrl: string + idpSecret: string +} + +export interface TenantService { + create(input: CreateOptions): Promise + get(id: string): Promise + update( + id: string, + input: Partial> + ): Promise + delete(id: string): Promise +} + +interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex +} + +export async function createTenantService({ + logger, + knex +}: ServiceDependencies): Promise { + const log = logger.child({ + service: 'TenantService' + }) + const deps: ServiceDependencies = { + logger: log, + knex + } + + return { + create: (input: CreateOptions) => createTenant(deps, input), + get: (id: string) => getTenant(deps, id), + update: (id: string, input: Partial>) => + updateTenant(deps, id, input), + delete: (id: string) => deleteTenant(deps, id) + } +} + +async function createTenant( + deps: ServiceDependencies, + input: CreateOptions +): Promise { + return await Tenant.query(deps.knex).insert(input) +} + +async function getTenant( + deps: ServiceDependencies, + id: string +): Promise { + return await Tenant.query(deps.knex).findById(id) +} + +async function updateTenant( + deps: ServiceDependencies, + id: string, + input: Partial> +): Promise { + return await Tenant.query(deps.knex).patchAndFetchById(id, input) +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string +): Promise { + const deleted = await Tenant.query(deps.knex).deleteById(id) + return deleted > 0 +} From e52442146b47a7baeb2537f0965a0247049cbc14 Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 2 Dec 2024 13:24:13 -0500 Subject: [PATCH 2/6] chore(auth): format --- packages/auth/src/tenant/service.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts index f9aa6a9f81..3bd4b52eab 100644 --- a/packages/auth/src/tenant/service.test.ts +++ b/packages/auth/src/tenant/service.test.ts @@ -1,5 +1,4 @@ import { faker } from '@faker-js/faker' -import { Knex } from 'knex' import { createTestApp, TestContainer } from '../tests/app' import { truncateTables } from '../tests/tableManager' import { Config } from '../config/app' @@ -7,7 +6,6 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { TenantService } from './service' -import { Tenant } from './service' describe('Tenant Service', (): void => { let deps: IocContract From 292d6e34716c4e831eecedcd056bf7712a163c10 Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:22:57 -0500 Subject: [PATCH 3/6] fix(auth): jest test warning about migration --- packages/auth/migrations/20241125233415_create_tenants_table.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth/migrations/20241125233415_create_tenants_table.js b/packages/auth/migrations/20241125233415_create_tenants_table.js index 765b6ea9f8..9112108977 100644 --- a/packages/auth/migrations/20241125233415_create_tenants_table.js +++ b/packages/auth/migrations/20241125233415_create_tenants_table.js @@ -19,5 +19,5 @@ exports.up = function (knex) { * @returns { Promise } */ exports.down = function (knex) { - knex.schema.dropTableIfExists('tenants') + return knex.schema.dropTableIfExists('tenants') } From bcb4ab2a55fc91eb11c6817a0a95e5836cfa8ac5 Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:23:27 -0500 Subject: [PATCH 4/6] fix(auth): remove temporary code --- packages/auth/src/tenant/service.test.ts | 8 -------- packages/auth/src/tenant/service.ts | 12 ++---------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts index 3bd4b52eab..f8d1f1387b 100644 --- a/packages/auth/src/tenant/service.test.ts +++ b/packages/auth/src/tenant/service.test.ts @@ -17,14 +17,6 @@ describe('Tenant Service', (): void => { appContainer = await createTestApp(deps) tenantService = await deps.use('tenantService') - - // TODO: remove. temporary implementation while other issue is completed - const knex = await deps.use('knex') - await knex.schema.createTable('tenants', (table) => { - table.string('id').primary() - table.string('idpConsentUrl').notNullable() - table.string('idpSecret').notNullable() - }) }) afterEach(async (): Promise => { diff --git a/packages/auth/src/tenant/service.ts b/packages/auth/src/tenant/service.ts index 28c3b4f535..228d8a5c73 100644 --- a/packages/auth/src/tenant/service.ts +++ b/packages/auth/src/tenant/service.ts @@ -1,14 +1,6 @@ import { BaseService } from '../shared/baseService' -import { TransactionOrKnex, Model } from 'objection' - -// TODO: remove. temporary implementation while other issue is completed -export class Tenant extends Model { - static tableName = 'tenants' - - id!: string - idpConsentUrl!: string - idpSecret!: string -} +import { TransactionOrKnex } from 'objection' +import { Tenant } from './model' export interface CreateOptions { id: string From 28616c3fb5f60c0c686e8feb98b1fd0e2123d51a Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 2 Dec 2024 21:50:02 -0500 Subject: [PATCH 5/6] feat(auth): soft delete tenants --- packages/auth/src/tenant/service.test.ts | 49 ++++++++++++++++-------- packages/auth/src/tenant/service.ts | 14 +++++-- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts index f8d1f1387b..3bcab39056 100644 --- a/packages/auth/src/tenant/service.test.ts +++ b/packages/auth/src/tenant/service.test.ts @@ -6,6 +6,7 @@ import { IocContract } from '@adonisjs/fold' import { initIocContainer } from '../' import { AppServices } from '../app' import { TenantService } from './service' +import { Tenant } from './model' describe('Tenant Service', (): void => { let deps: IocContract @@ -43,13 +44,7 @@ describe('Tenant Service', (): void => { idpConsentUrl: tenantData.idpConsentUrl, idpSecret: tenantData.idpSecret }) - }) - - test('fails to create tenant with duplicate id', async (): Promise => { - const tenantData = createTenantData() - await tenantService.create(tenantData) - - await expect(tenantService.create(tenantData)).rejects.toThrow() + expect(tenant.deletedAt).toBe(undefined) }) }) @@ -66,6 +61,15 @@ describe('Tenant Service', (): void => { const tenant = await tenantService.get(faker.string.uuid()) expect(tenant).toBeUndefined() }) + + test('returns undefined for soft deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + await tenantService.delete(created.id) + + const tenant = await tenantService.get(created.id) + expect(tenant).toBeUndefined() + }) }) describe('update', (): void => { @@ -92,25 +96,20 @@ describe('Tenant Service', (): void => { expect(updated).toBeUndefined() }) - test('can update partial fields', async (): Promise => { + test('returns undefined for soft-deleted tenant', async (): Promise => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) + await tenantService.delete(created.id) - const updateData = { + const updated = await tenantService.update(created.id, { idpConsentUrl: faker.internet.url() - } - - const updated = await tenantService.update(created.id, updateData) - expect(updated).toMatchObject({ - id: created.id, - idpConsentUrl: updateData.idpConsentUrl, - idpSecret: created.idpSecret }) + expect(updated).toBeUndefined() }) }) describe('delete', (): void => { - test('deletes an existing tenant', async (): Promise => { + test('soft deletes an existing tenant', async (): Promise => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) @@ -119,11 +118,27 @@ describe('Tenant Service', (): void => { const tenant = await tenantService.get(created.id) expect(tenant).toBeUndefined() + + const deletedTenant = await Tenant.query() + .findById(created.id) + .whereNotNull('deletedAt') + console.log({ deletedTenant }) + expect(deletedTenant).toBeDefined() + expect(deletedTenant?.deletedAt).toBeDefined() }) test('returns false for non-existent tenant', async (): Promise => { const result = await tenantService.delete(faker.string.uuid()) expect(result).toBe(false) }) + + test('returns false for already deleted tenant', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + await tenantService.delete(created.id) + const secondDelete = await tenantService.delete(created.id) + expect(secondDelete).toBe(false) + }) }) }) diff --git a/packages/auth/src/tenant/service.ts b/packages/auth/src/tenant/service.ts index 228d8a5c73..d8d9f2a24c 100644 --- a/packages/auth/src/tenant/service.ts +++ b/packages/auth/src/tenant/service.ts @@ -54,7 +54,10 @@ async function getTenant( deps: ServiceDependencies, id: string ): Promise { - return await Tenant.query(deps.knex).findById(id) + return await Tenant.query(deps.knex) + .findById(id) + .whereNull('deletedAt') + .first() } async function updateTenant( @@ -62,13 +65,18 @@ async function updateTenant( id: string, input: Partial> ): Promise { - return await Tenant.query(deps.knex).patchAndFetchById(id, input) + return await Tenant.query(deps.knex) + .whereNull('deletedAt') + .patchAndFetchById(id, input) } async function deleteTenant( deps: ServiceDependencies, id: string ): Promise { - const deleted = await Tenant.query(deps.knex).deleteById(id) + const deleted = await Tenant.query(deps.knex) + .patch({ deletedAt: new Date() }) + .whereNull('deletedAt') + .where('id', id) return deleted > 0 } From 4b6ce80d37a544d1d4258a13b6afeb42dcab9e66 Mon Sep 17 00:00:00 2001 From: Blair Currey <12960453+BlairCurrey@users.noreply.github.com> Date: Mon, 2 Dec 2024 21:57:24 -0500 Subject: [PATCH 6/6] fix(auth): return erroneously removed tests --- packages/auth/src/tenant/service.test.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts index 3bcab39056..fa553b7b18 100644 --- a/packages/auth/src/tenant/service.test.ts +++ b/packages/auth/src/tenant/service.test.ts @@ -46,6 +46,13 @@ describe('Tenant Service', (): void => { }) expect(tenant.deletedAt).toBe(undefined) }) + + test('fails to create tenant with duplicate id', async (): Promise => { + const tenantData = createTenantData() + await tenantService.create(tenantData) + + await expect(tenantService.create(tenantData)).rejects.toThrow() + }) }) describe('get', (): void => { @@ -89,6 +96,22 @@ describe('Tenant Service', (): void => { }) }) + test('can update partial fields', async (): Promise => { + const tenantData = createTenantData() + const created = await tenantService.create(tenantData) + + const updateData = { + idpConsentUrl: faker.internet.url() + } + + const updated = await tenantService.update(created.id, updateData) + expect(updated).toMatchObject({ + id: created.id, + idpConsentUrl: updateData.idpConsentUrl, + idpSecret: created.idpSecret + }) + }) + test('returns undefined for non-existent tenant', async (): Promise => { const updated = await tenantService.update(faker.string.uuid(), { idpConsentUrl: faker.internet.url() @@ -122,7 +145,6 @@ describe('Tenant Service', (): void => { const deletedTenant = await Tenant.query() .findById(created.id) .whereNotNull('deletedAt') - console.log({ deletedTenant }) expect(deletedTenant).toBeDefined() expect(deletedTenant?.deletedAt).toBeDefined() })