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') } 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..fa553b7b18 --- /dev/null +++ b/packages/auth/src/tenant/service.test.ts @@ -0,0 +1,166 @@ +import { faker } from '@faker-js/faker' +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 './model' + +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') + }) + + 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 + }) + 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 => { + 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() + }) + + 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 => { + 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('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() + }) + expect(updated).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 updated = await tenantService.update(created.id, { + idpConsentUrl: faker.internet.url() + }) + expect(updated).toBeUndefined() + }) + }) + + describe('delete', (): void => { + test('soft 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() + + const deletedTenant = await Tenant.query() + .findById(created.id) + .whereNotNull('deletedAt') + 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 new file mode 100644 index 0000000000..d8d9f2a24c --- /dev/null +++ b/packages/auth/src/tenant/service.ts @@ -0,0 +1,82 @@ +import { BaseService } from '../shared/baseService' +import { TransactionOrKnex } from 'objection' +import { Tenant } from './model' + +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) + .whereNull('deletedAt') + .first() +} + +async function updateTenant( + deps: ServiceDependencies, + id: string, + input: Partial> +): Promise { + 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) + .patch({ deletedAt: new Date() }) + .whereNull('deletedAt') + .where('id', id) + return deleted > 0 +}