Skip to content

Commit

Permalink
feat(auth): tenant service (#3144)
Browse files Browse the repository at this point in the history
* feat(auth): tenant service

* chore(auth): format

* fix(auth): jest test warning about migration

* fix(auth): remove temporary code

* feat(auth): soft delete tenants

* fix(auth): return erroneously removed tests
  • Loading branch information
BlairCurrey authored Dec 3, 2024
1 parent ea7e660 commit 349b01e
Show file tree
Hide file tree
Showing 5 changed files with 262 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ exports.up = function (knex) {
* @returns { Promise<void> }
*/
exports.down = function (knex) {
knex.schema.dropTableIfExists('tenants')
return knex.schema.dropTableIfExists('tenants')
}
2 changes: 2 additions & 0 deletions packages/auth/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,6 +103,7 @@ export interface AppServices {
grantRoutes: Promise<GrantRoutes>
interactionRoutes: Promise<InteractionRoutes>
redis: Promise<Redis>
tenantService: Promise<TenantService>
}

export type AppContainer = IocContract<AppServices>
Expand Down
11 changes: 11 additions & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -209,6 +210,16 @@ export function initIocContainer(
return new Redis(config.redisUrl, { tls: config.redisTls })
})

container.singleton(
'tenantService',
async (deps: IocContract<AppServices>) => {
return createTenantService({
logger: await deps.use('logger'),
knex: await deps.use('knex')
})
}
)

return container
}

Expand Down
166 changes: 166 additions & 0 deletions packages/auth/src/tenant/service.test.ts
Original file line number Diff line number Diff line change
@@ -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<AppServices>
let appContainer: TestContainer
let tenantService: TenantService

beforeAll(async (): Promise<void> => {
deps = initIocContainer(Config)
appContainer = await createTestApp(deps)

tenantService = await deps.use('tenantService')
})

afterEach(async (): Promise<void> => {
await truncateTables(appContainer.knex)
})

afterAll(async (): Promise<void> => {
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<void> => {
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<void> => {
const tenantData = createTenantData()
await tenantService.create(tenantData)

await expect(tenantService.create(tenantData)).rejects.toThrow()
})
})

describe('get', (): void => {
test('retrieves an existing tenant', async (): Promise<void> => {
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<void> => {
const tenant = await tenantService.get(faker.string.uuid())
expect(tenant).toBeUndefined()
})

test('returns undefined for soft deleted tenant', async (): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
const updated = await tenantService.update(faker.string.uuid(), {
idpConsentUrl: faker.internet.url()
})
expect(updated).toBeUndefined()
})

test('returns undefined for soft-deleted tenant', async (): Promise<void> => {
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<void> => {
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<void> => {
const result = await tenantService.delete(faker.string.uuid())
expect(result).toBe(false)
})

test('returns false for already deleted tenant', async (): Promise<void> => {
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)
})
})
})
82 changes: 82 additions & 0 deletions packages/auth/src/tenant/service.ts
Original file line number Diff line number Diff line change
@@ -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<Tenant>
get(id: string): Promise<Tenant | undefined>
update(
id: string,
input: Partial<Omit<CreateOptions, 'id'>>
): Promise<Tenant | undefined>
delete(id: string): Promise<boolean>
}

interface ServiceDependencies extends BaseService {
knex: TransactionOrKnex
}

export async function createTenantService({
logger,
knex
}: ServiceDependencies): Promise<TenantService> {
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<Omit<CreateOptions, 'id'>>) =>
updateTenant(deps, id, input),
delete: (id: string) => deleteTenant(deps, id)
}
}

async function createTenant(
deps: ServiceDependencies,
input: CreateOptions
): Promise<Tenant> {
return await Tenant.query(deps.knex).insert(input)
}

async function getTenant(
deps: ServiceDependencies,
id: string
): Promise<Tenant | undefined> {
return await Tenant.query(deps.knex)
.findById(id)
.whereNull('deletedAt')
.first()
}

async function updateTenant(
deps: ServiceDependencies,
id: string,
input: Partial<Omit<CreateOptions, 'id'>>
): Promise<Tenant | undefined> {
return await Tenant.query(deps.knex)
.whereNull('deletedAt')
.patchAndFetchById(id, input)
}

async function deleteTenant(
deps: ServiceDependencies,
id: string
): Promise<boolean> {
const deleted = await Tenant.query(deps.knex)
.patch({ deletedAt: new Date() })
.whereNull('deletedAt')
.where('id', id)
return deleted > 0
}

0 comments on commit 349b01e

Please sign in to comment.