Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(backend): full test of multi-tenancy-v1 #3176

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions localenv/cloud-nine-wallet/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ services:
TIGERBEETLE_REPLICA_ADDRESSES: ${TIGERBEETLE_REPLICA_ADDRESSES-''}
AUTH_SERVER_GRANT_URL: ${CLOUD_NINE_AUTH_SERVER_DOMAIN:-http://cloud-nine-wallet-auth:3006}
AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-auth:3007
AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-auth:3003/graphql'
AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4='
ILP_ADDRESS: ${ILP_ADDRESS:-test.cloud-nine-wallet}
STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU=
API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964=
Expand All @@ -76,6 +78,7 @@ services:
ILP_CONNECTOR_URL: ${CLOUD_NINE_CONNECTOR_URL:-http://cloud-nine-wallet-backend:3002}
ENABLE_TELEMETRY: true
KEY_ID: 7097F83B-CB84-469E-96C6-2141C72E22C0
OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787
depends_on:
- shared-database
- shared-redis
Expand Down Expand Up @@ -115,6 +118,7 @@ services:
IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37
ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=
OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787
depends_on:
- shared-database
- shared-redis
Expand Down
4 changes: 4 additions & 0 deletions localenv/happy-life-bank/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ services:
USE_TIGERBEETLE: false
AUTH_SERVER_GRANT_URL: ${HAPPY_LIFE_BANK_AUTH_SERVER_DOMAIN:-http://happy-life-bank-auth:3006}
AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-auth:3007
AUTH_ADMIN_API_URL: 'http://happy-life-bank-auth:4003/graphql'
AUTH_ADMIN_API_SECRET: 'rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4='
ILP_ADDRESS: test.happy-life-bank
ILP_CONNECTOR_URL: http://happy-life-bank-backend:4002
STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU=
Expand All @@ -69,6 +71,7 @@ services:
WALLET_ADDRESS_URL: ${HAPPY_LIFE_BANK_WALLET_ADDRESS_URL:-https://happy-life-bank-backend/.well-known/pay}
ENABLE_TELEMETRY: true
KEY_ID: 53f2d913-e98a-40b9-b270-372d0547f23d
OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d
depends_on:
- cloud-nine-backend
healthcheck:
Expand Down Expand Up @@ -104,6 +107,7 @@ services:
IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE=
COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37
ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4=
OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d
depends_on:
- cloud-nine-auth
happy-life-admin:
Expand Down
1 change: 1 addition & 0 deletions packages/auth/jest.env.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ process.env.IDENTITY_SERVER_SECRET =
'2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE='
process.env.AUTH_SERVER_URL = 'http://localhost:3006'
process.env.IDENTITY_SERVER_URL = 'http://localhost:3030/mock-idp/'
process.env.OPERATOR_TENANT_ID = 'cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d'
1 change: 1 addition & 0 deletions packages/auth/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
const { knex } = require('knex')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { GenericContainer, Wait } = require('testcontainers')
require('./jest.env') // set environment variables

const POSTGRES_PORT = 5432
const REDIS_PORT = 6379
Expand Down
23 changes: 23 additions & 0 deletions packages/auth/migrations/20241125233415_create_tenants_table.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function (knex) {
return knex.schema.createTable('tenants', function (table) {
table.uuid('id').notNullable().primary()
table.string('idpConsentUrl').notNullable()
table.string('idpSecret').notNullable()

table.timestamp('createdAt').defaultTo(knex.fn.now())
table.timestamp('updatedAt').defaultTo(knex.fn.now())
table.timestamp('deletedAt')
})
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.schema.dropTableIfExists('tenants')
}
47 changes: 47 additions & 0 deletions packages/auth/migrations/20241205153036_seed_operator_tenant.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/

const OPERATOR_TENANT_ID = process.env['OPERATOR_TENANT_ID']
const IDENTITY_SERVER_URL = process.env['IDENTITY_SERVER_URL']
const IDENTITY_SERVER_SECRET = process.env['IDENTITY_SERVER_SECRET']

exports.up = function (knex) {
if (!OPERATOR_TENANT_ID) {
throw new Error(
'Could not seed operator tenant. Please configure OPERATOR_TENANT_ID environment variables'
)
}

const seed = {
id: OPERATOR_TENANT_ID
}

if (IDENTITY_SERVER_URL) {
seed['idpConsentUrl'] = IDENTITY_SERVER_URL
}

if (IDENTITY_SERVER_SECRET) {
seed['idpSecret'] = IDENTITY_SERVER_SECRET
}

return knex.raw(`
INSERT INTO "tenants" (${Object.keys(seed)
.map((key) => `"${key}"`)
.join(', ')})
VALUES (${Object.values(seed)
.map((key) => `'${key}'`)
.join(', ')})
`)
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function (knex) {
return knex.raw(`
TRUNCATE "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
3 changes: 2 additions & 1 deletion packages/auth/src/config/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ export const Config = {
process.env.REDIS_TLS_CA_FILE_PATH,
process.env.REDIS_TLS_KEY_FILE_PATH,
process.env.REDIS_TLS_CERT_FILE_PATH
)
),
operatorTenantId: envString('OPERATOR_TENANT_ID')
}

function parseRedisTlsConfig(
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
12 changes: 12 additions & 0 deletions packages/auth/src/tenant/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { BaseModel } from '../shared/baseModel'

export class Tenant extends BaseModel {
public static get tableName(): string {
return 'tenants'
}

public idpConsentUrl!: string
public idpSecret!: string

public deletedAt?: Date
}
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)
})
})
})
Loading