From 07630c1004fb22bd5da413d009a6610822ed694c Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Mon, 9 Dec 2024 09:32:55 -0800 Subject: [PATCH] feat(backend): tenants service (#3123) (#3140) * feat(backend): tenant service * fix: integration tests * feat: use soft delete * refactor: compare whole object in test * fix: better gql errors in tests * feat: add idp columns to tenant model * feat: pagination tests, push deletedAt to auth api call * feat: add cache * fix: update localenv environment variables * feat: make some tenants fields optional, small refactors --- localenv/cloud-nine-wallet/docker-compose.yml | 2 + localenv/happy-life-bank/docker-compose.yml | 2 + packages/backend/jest.config.js | 2 + .../20241125224212_create_tenants_table.js | 10 +- packages/backend/package.json | 2 +- packages/backend/src/config/app.ts | 3 + packages/backend/src/index.ts | 98 ++++ packages/backend/src/tenants/model.ts | 13 + packages/backend/src/tenants/service.test.ts | 463 ++++++++++++++++++ packages/backend/src/tenants/service.ts | 204 ++++++++ packages/backend/src/tests/tenant.ts | 41 ++ pnpm-lock.yaml | 6 +- .../cloud-nine-wallet/docker-compose.yml | 2 + .../happy-life-bank/docker-compose.yml | 2 + 14 files changed, 844 insertions(+), 6 deletions(-) create mode 100644 packages/backend/src/tenants/service.test.ts create mode 100644 packages/backend/src/tenants/service.ts create mode 100644 packages/backend/src/tests/tenant.ts diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index b0a75933d8..a493be4b71 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -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= diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index e72e1654bd..93475143f1 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -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= diff --git a/packages/backend/jest.config.js b/packages/backend/jest.config.js index 492a6e5e30..c0245a4d3b 100644 --- a/packages/backend/jest.config.js +++ b/packages/backend/jest.config.js @@ -12,6 +12,8 @@ process.env.ILP_CONNECTOR_URL = 'http://127.0.0.1:3002' process.env.ILP_ADDRESS = 'test.rafiki' process.env.AUTH_SERVER_GRANT_URL = 'http://127.0.0.1:3006' process.env.AUTH_SERVER_INTROSPECTION_URL = 'http://127.0.0.1:3007/' +process.env.AUTH_ADMIN_API_URL = 'http://127.0.0.1:3003/graphql' +process.env.AUTH_ADMIN_API_SECRET = 'test-secret' process.env.WEBHOOK_URL = 'http://127.0.0.1:4001/webhook' process.env.STREAM_SECRET = '2/PxuRFV9PAp0yJlnAifJ+1OxujjjI16lN+DBnLNRLA=' process.env.USE_TIGERBEETLE = false diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js index 2b00eb981d..e6fc77e934 100644 --- a/packages/backend/migrations/20241125224212_create_tenants_table.js +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -5,9 +5,15 @@ exports.up = function (knex) { return knex.schema.createTable('tenants', function (table) { table.uuid('id').notNullable().primary() - table.string('email').notNullable() + table.string('email') + table.string('apiSecret').notNullable() + table.string('idpConsentUrl') + table.string('idpSecret') table.string('publicName') - table.string('apiSecret') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) + table.timestamp('deletedAt') }) } diff --git a/packages/backend/package.json b/packages/backend/package.json index 9c515931dd..5e0d1edc6a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -16,7 +16,6 @@ "dev": "ts-node-dev --inspect=0.0.0.0:9229 --respawn --transpile-only --require ./src/telemetry/index.ts src/index.ts" }, "devDependencies": { - "@apollo/client": "^3.11.8", "@graphql-codegen/cli": "5.0.2", "@graphql-codegen/introspection": "4.0.3", "@graphql-codegen/typescript": "4.0.6", @@ -46,6 +45,7 @@ }, "dependencies": { "@adonisjs/fold": "^8.2.0", + "@apollo/client": "^3.11.8", "@apollo/server": "^4.11.2", "@as-integrations/koa": "^1.1.1", "@escape.tech/graphql-armor": "^2.4.0", diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index b266c062ac..2f70ab2164 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -126,6 +126,9 @@ export const Config = { authServerGrantUrl: envString('AUTH_SERVER_GRANT_URL'), authServerIntrospectionUrl: envString('AUTH_SERVER_INTROSPECTION_URL'), + authAdminApiUrl: envString('AUTH_ADMIN_API_URL'), + authAdminApiSecret: envString('AUTH_ADMIN_API_SECRET'), + authAdminApiSignatureVersion: envInt('AUTH_ADMIN_API_SIGNATURE_VERSION', 1), outgoingPaymentWorkers: envInt('OUTGOING_PAYMENT_WORKERS', 1), outgoingPaymentWorkerIdle: envInt('OUTGOING_PAYMENT_WORKER_IDLE', 10), // milliseconds diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 91db346566..b167410756 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -7,6 +7,7 @@ import { createClient } from 'tigerbeetle-node' import { createClient as createIntrospectionClient } from 'token-introspection' import net from 'net' import dns from 'dns' +import { createHmac } from 'crypto' import { createAuthenticatedClient as createOpenPaymentsClient, @@ -15,6 +16,17 @@ import { } from '@interledger/open-payments' import { StreamServer } from '@interledger/stream-receiver' import axios from 'axios' +import { + ApolloClient, + ApolloLink, + createHttpLink, + InMemoryCache +} from '@apollo/client' +import { onError } from '@apollo/client/link/error' +import { setContext } from '@apollo/client/link/context' +import { canonicalize } from 'json-canonicalize' +import { print } from 'graphql/language/printer' + import { createAccountingService as createPsqlAccountingService } from './accounting/psql/service' import { createAccountingService as createTigerbeetleAccountingService } from './accounting/tigerbeetle/service' import { App, AppServices } from './app' @@ -61,6 +73,7 @@ import { } from './telemetry/service' import { createWebhookService } from './webhook/service' import { createInMemoryDataStore } from './middleware/cache/data-stores/in-memory' +import { createTenantService } from './tenants/service' BigInt.prototype.toJSON = function () { return this.toString() @@ -131,6 +144,91 @@ export function initIocContainer( }) }) + container.singleton('apolloClient', async (deps) => { + const [logger, config] = await Promise.all([ + deps.use('logger'), + deps.use('config') + ]) + + const httpLink = createHttpLink({ + uri: config.authAdminApiUrl + }) + + const errorLink = onError(({ graphQLErrors }) => { + if (graphQLErrors) { + logger.error(graphQLErrors) + graphQLErrors.map(({ extensions }) => { + if (extensions && extensions.code === 'UNAUTHENTICATED') { + logger.error('UNAUTHENTICATED') + } + + if (extensions && extensions.code === 'FORBIDDEN') { + logger.error('FORBIDDEN') + } + }) + } + }) + + const authLink = setContext((request, { headers }) => { + if (!config.authAdminApiSecret || !config.authAdminApiSignatureVersion) + return { headers } + const timestamp = Math.round(new Date().getTime() / 1000) + const version = config.authAdminApiSignatureVersion + + const { query, variables, operationName } = request + const formattedRequest = { + variables, + operationName, + query: print(query) + } + + const payload = `${timestamp}.${canonicalize(formattedRequest)}` + const hmac = createHmac('sha256', config.authAdminApiSecret) + hmac.update(payload) + const digest = hmac.digest('hex') + + return { + headers: { + ...headers, + signature: `t=${timestamp}, v${version}=${digest}` + } + } + }) + + const link = ApolloLink.from([errorLink, authLink, httpLink]) + + const client = new ApolloClient({ + cache: new InMemoryCache({}), + link: link, + defaultOptions: { + query: { + fetchPolicy: 'no-cache' + }, + mutate: { + fetchPolicy: 'no-cache' + }, + watchQuery: { + fetchPolicy: 'no-cache' + } + } + }) + + return client + }) + + container.singleton('tenantCache', async () => { + return createInMemoryDataStore(config.localCacheDuration) + }) + + container.singleton('tenantService', async (deps) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + apolloClient: await deps.use('apolloClient'), + tenantCache: await deps.use('tenantCache') + }) + }) + container.singleton('ratesService', async (deps) => { const config = await deps.use('config') return createRatesService({ diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts index e1347fff7f..78b7a16139 100644 --- a/packages/backend/src/tenants/model.ts +++ b/packages/backend/src/tenants/model.ts @@ -1,4 +1,5 @@ import { BaseModel } from '../shared/baseModel' +import { Pojo } from 'objection' export class Tenant extends BaseModel { public static get tableName(): string { @@ -7,5 +8,17 @@ export class Tenant extends BaseModel { public email!: string public apiSecret!: string + public idpConsentUrl!: string + public idpSecret!: string public publicName?: string + + public deletedAt?: Date + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + deletedAt: json.deletedAt.toISOString() + } + } } diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts new file mode 100644 index 0000000000..84fad049ca --- /dev/null +++ b/packages/backend/src/tenants/service.test.ts @@ -0,0 +1,463 @@ +import assert from 'assert' +import { faker } from '@faker-js/faker' +import { IocContract } from '@adonisjs/fold' +import nock from 'nock' +import { Knex } from 'knex' +import { AppServices } from '../app' +import { initIocContainer } from '..' +import { createTestApp, TestContainer } from '../tests/app' +import { TenantService } from './service' +import { Config, IAppConfig } from '../config/app' +import { truncateTables } from '../tests/tableManager' +import { ApolloClient, NormalizedCacheObject } from '@apollo/client' +import { Tenant } from './model' +import { getPageTests } from '../shared/baseModel.test' +import { Pagination, SortOrder } from '../shared/baseModel' +import { createTenant } from '../tests/tenant' +import { CacheDataStore } from '../middleware/cache/data-stores' + +const generateMutateGqlError = (path: string = 'createTenant') => ({ + errors: [ + { + message: 'invalid input syntax', + locations: [ + { + line: 1, + column: 1 + } + ], + path: [path], + extensions: { + code: 'INTERNAl_SERVER_ERROR' + } + } + ], + data: null +}) + +describe('Tenant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantService: TenantService + let config: IAppConfig + let apolloClient: ApolloClient + let knex: Knex + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + config = await deps.use('config') + apolloClient = await deps.use('apolloClient') + knex = await deps.use('knex') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + nock.cleanAll() + await appContainer.shutdown() + }) + + describe('Tenant pangination', (): void => { + describe('getPage', (): void => { + getPageTests({ + createModel: () => createTenant(deps), + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => + tenantService.getPage(pagination, sortOrder) + }) + }) + }) + + describe('get', (): void => { + test('can get a tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const createdTenant = + await Tenant.query(knex).insertAndFetch(createOptions) + + const tenant = await tenantService.get(createdTenant.id) + assert.ok(tenant) + expect(tenant).toEqual(createdTenant) + }) + + test('returns undefined if tenant is deleted', async (): Promise => { + const dbTenant = await Tenant.query(knex).insertAndFetch({ + apiSecret: 'test-secret', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret', + deletedAt: new Date() + }) + + const tenant = await tenantService.get(dbTenant.id) + expect(tenant).toBeUndefined() + }) + }) + + describe('create', (): void => { + test('can create a tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const tenant = await tenantService.create(createOptions) + + expect(tenant).toEqual(expect.objectContaining(createOptions)) + + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpSecret: createOptions.idpSecret, + idpConsentUrl: createOptions.idpConsentUrl + } + } + }) + ) + + scope.done() + }) + + test('tenant creation rolls back if auth tenant create fails', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('createTenant')) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + let tenant + try { + tenant = await tenantService.create(createOptions) + } catch (err) { + expect(tenant).toBeUndefined() + + const tenants = await Tenant.query() + expect(tenants.length).toEqual(0) + + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: expect.any(String), + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret + } + } + }) + ) + } + scope.done() + }) + }) + + describe('update', (): void => { + test('can update a tenant', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + .persist() + const tenant = await tenantService.create(originalTenantInfo) + + const updatedTenantInfo = { + id: tenant.id, + apiSecret: 'test-api-secret-two', + email: faker.internet.url(), + publicName: 'second test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-two' + } + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const updatedTenant = await tenantService.update(updatedTenantInfo) + + expect(updatedTenant).toEqual(expect.objectContaining(updatedTenantInfo)) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + } + } + }) + ) + scope.done() + }) + + test('rolls back tenant if auth tenant update fails', async (): Promise => { + const originalTenantInfo = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + const tenant = await tenantService.create(originalTenantInfo) + const updatedTenantInfo = { + id: tenant.id, + apiSecret: 'test-api-secret-two', + email: faker.internet.url(), + publicName: 'second test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret-two' + } + + nock.cleanAll() + + nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('updateTenant')) + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + let updatedTenant + try { + updatedTenant = await tenantService.update(updatedTenantInfo) + } catch (err) { + expect(updatedTenant).toBeUndefined() + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant) + expect(dbTenant).toEqual(expect.objectContaining(originalTenantInfo)) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + } + } + }) + ) + } + + nock.cleanAll() + }) + + test('Cannot update deleted tenant', async (): Promise => { + const originalSecret = 'test-secret' + const dbTenant = await Tenant.query(knex).insertAndFetch({ + email: faker.internet.url(), + apiSecret: originalSecret, + idpSecret: 'test-idp-secret', + idpConsentUrl: faker.internet.url(), + deletedAt: new Date() + }) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + try { + await tenantService.update({ + id: dbTenant.id, + apiSecret: 'test-secret-2' + }) + } catch (err) { + const dbTenantAfterUpdate = await Tenant.query(knex).findById( + dbTenant.id + ) + + assert.ok(dbTenantAfterUpdate) + expect(dbTenantAfterUpdate.apiSecret).toEqual(originalSecret) + expect(apolloSpy).toHaveBeenCalledTimes(0) + } + }) + }) + + describe('Delete Tenant', (): void => { + test('Can delete tenant', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + .persist() + const tenant = await tenantService.create(createOptions) + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + await tenantService.delete(tenant.id) + + const dbTenant = await Tenant.query().findById(tenant.id) + expect(dbTenant?.deletedAt?.getTime()).toBeLessThanOrEqual( + new Date(Date.now()).getTime() + ) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { id: tenant.id, deletedAt: dbTenant?.deletedAt } + } + }) + ) + + scope.done() + }) + + test('Reverts deletion if auth tenant delete fails', async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + email: faker.internet.url(), + publicName: 'test name', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + const tenant = await tenantService.create(createOptions) + + nock.cleanAll() + + const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const deleteScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('deleteTenant')) + try { + await tenantService.delete(tenant.id) + } catch (err) { + const dbTenant = await Tenant.query().findById(tenant.id) + assert.ok(dbTenant) + expect(dbTenant.id).toEqual(tenant.id) + expect(dbTenant.deletedAt).toBeNull() + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + deletedAt: expect.any(Date) + } + } + }) + ) + } + + deleteScope.done() + }) + }) + + describe('Tenant Service using cache', (): void => { + let deps: IocContract + let appContainer: TestContainer + let config: IAppConfig + let tenantService: TenantService + let tenantCache: CacheDataStore + + beforeAll(async (): Promise => { + deps = initIocContainer({ + ...Config, + localCacheDuration: 5_000 // 5-second default. + }) + appContainer = await createTestApp(deps) + config = await deps.use('config') + tenantService = await deps.use('tenantService') + tenantCache = await deps.use('tenantCache') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('create, update, and retrieve tenant using cache', (): void => { + test('Tenant can be created, updated, and fetched', async (): Promise => { + const createOptions = { + email: faker.internet.email(), + publicName: faker.company.name(), + apiSecret: 'test-api-secret', + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { tenant: { id: 1234 } } } }) + .persist() + + const spyCacheSet = jest.spyOn(tenantCache, 'set') + const tenant = await tenantService.create(createOptions) + expect(tenant).toMatchObject({ + ...createOptions, + id: tenant.id + }) + + // Ensure that the cache was set for create + expect(spyCacheSet).toHaveBeenCalledTimes(1) + + const spyCacheGet = jest.spyOn(tenantCache, 'get') + await expect(tenantService.get(tenant.id)).resolves.toEqual(tenant) + + expect(spyCacheGet).toHaveBeenCalledTimes(1) + expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) + + const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') + const updatedTenant = await tenantService.update({ + id: tenant.id, + apiSecret: 'test-api-secret-2' + }) + + await expect(tenantService.get(tenant.id)).resolves.toEqual( + updatedTenant + ) + + // Ensure that cache was set for update + expect(spyCacheUpdateSet).toHaveBeenCalledTimes(2) + expect(spyCacheUpdateSet).toHaveBeenCalledWith(tenant.id, updatedTenant) + + const spyCacheDelete = jest.spyOn(tenantCache, 'delete') + await tenantService.delete(tenant.id) + + await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() + + // Ensure that cache was set for deletion + expect(spyCacheDelete).toHaveBeenCalledTimes(1) + expect(spyCacheDelete).toHaveBeenCalledWith(tenant.id) + + scope.done() + }) + }) + }) +}) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts new file mode 100644 index 0000000000..d1973471eb --- /dev/null +++ b/packages/backend/src/tenants/service.ts @@ -0,0 +1,204 @@ +import { Tenant } from './model' +import { BaseService } from '../shared/baseService' +import { gql, NormalizedCacheObject } from '@apollo/client' +import { ApolloClient } from '@apollo/client' +import { TransactionOrKnex } from 'objection' +import { Pagination, SortOrder } from '../shared/baseModel' +import { CacheDataStore } from '../middleware/cache/data-stores' + +export interface TenantService { + get: (id: string) => Promise + create: (options: CreateTenantOptions) => Promise + update: (options: UpdateTenantOptions) => Promise + delete: (id: string) => Promise + getPage: (pagination?: Pagination, sortOrder?: SortOrder) => Promise +} + +export interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex + apolloClient: ApolloClient + tenantCache: CacheDataStore +} + +export async function createTenantService( + deps_: ServiceDependencies +): Promise { + const deps: ServiceDependencies = { + ...deps_, + logger: deps_.logger.child({ service: 'TenantService' }) + } + + return { + get: (id: string) => getTenant(deps, id), + create: (options) => createTenant(deps, options), + update: (options) => updateTenant(deps, options), + delete: (id) => deleteTenant(deps, id), + getPage: (pagination, sortOrder) => + getTenantPage(deps, pagination, sortOrder) + } +} + +async function getTenant( + deps: ServiceDependencies, + id: string +): Promise { + const inMem = await deps.tenantCache.get(id) + if (inMem) return inMem + const tenant = await Tenant.query(deps.knex) + .findById(id) + .whereNull('deletedAt') + if (tenant) await deps.tenantCache.set(tenant.id, tenant) + + return tenant +} + +async function getTenantPage( + deps: ServiceDependencies, + pagination?: Pagination, + sortOrder?: SortOrder +): Promise { + return await Tenant.query(deps.knex).getPage(pagination, sortOrder) +} + +interface CreateTenantOptions { + email: string + apiSecret: string + idpSecret: string + idpConsentUrl: string + publicName?: string +} + +async function createTenant( + deps: ServiceDependencies, + options: CreateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + try { + const { email, apiSecret, publicName, idpSecret, idpConsentUrl } = options + const tenant = await Tenant.query(trx).insertAndFetch({ + email, + publicName, + apiSecret, + idpSecret, + idpConsentUrl + }) + + const mutation = gql` + mutation CreateAuthTenant($input: CreateTenantInput!) { + createTenant(input: $input) { + tenant { + id + } + } + } + ` + + const variables = { + input: { + id: tenant.id, + idpSecret, + idpConsentUrl + } + } + + // TODO: add type to this in https://github.com/interledger/rafiki/issues/3125 + await deps.apolloClient.mutate({ mutation, variables }) + await trx.commit() + + await deps.tenantCache.set(tenant.id, tenant) + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +interface UpdateTenantOptions { + id: string + email?: string + publicName?: string + apiSecret?: string + idpConsentUrl?: string + idpSecret?: string +} + +async function updateTenant( + deps: ServiceDependencies, + options: UpdateTenantOptions +): Promise { + const trx = await deps.knex.transaction() + + try { + const { id, apiSecret, email, publicName, idpConsentUrl, idpSecret } = + options + const tenant = await Tenant.query(trx) + .patchAndFetchById(options.id, { + email, + publicName, + apiSecret, + idpConsentUrl, + idpSecret + }) + .whereNull('deletedAt') + .throwIfNotFound() + + if (idpConsentUrl || idpSecret) { + const mutation = gql` + mutation UpdateAuthTenant($input: UpdateTenantInput!) { + updateTenant(input: $input) { + tenant { + id + } + } + } + ` + + const variables = { + input: { + id, + idpConsentUrl, + idpSecret + } + } + + // TODO: add types to this in https://github.com/interledger/rafiki/issues/3125 + await deps.apolloClient.mutate({ mutation, variables }) + } + + await trx.commit() + await deps.tenantCache.set(tenant.id, tenant) + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string +): Promise { + const trx = await deps.knex.transaction() + + await deps.tenantCache.delete(id) + try { + const deletedAt = new Date() + await Tenant.query(trx).patchAndFetchById(id, { + deletedAt + }) + const mutation = gql` + mutation DeleteAuthTenantMutation($input: DeleteTenantInput!) { + deleteTenant(input: $input) { + sucess + } + } + ` + const variables = { input: { id, deletedAt } } + // TODO: add types to this in https://github.com/interledger/rafiki/issues/3125 + await deps.apolloClient.mutate({ mutation, variables }) + await trx.commit() + } catch (err) { + await trx.rollback() + throw err + } +} diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts new file mode 100644 index 0000000000..4ac1488b84 --- /dev/null +++ b/packages/backend/src/tests/tenant.ts @@ -0,0 +1,41 @@ +import { IocContract } from '@adonisjs/fold' +import { faker } from '@faker-js/faker' +import { AppServices } from '../app' +import { Tenant } from '../tenants/model' + +interface CreateOptions { + email: string + publicName?: string + apiSecret: string + idpConsentUrl: string + idpSecret: string +} + +const nock = (global as unknown as { nock: typeof import('nock') }).nock + +export async function createTenant( + deps: IocContract, + options?: CreateOptions +): Promise { + const tenantService = await deps.use('tenantService') + const config = await deps.use('config') + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + const tenant = await tenantService.create( + options || { + email: faker.internet.email(), + apiSecret: 'test-api-secret', + publicName: faker.company.name(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + ) + scope.done() + + if (!tenant) { + throw Error('Failed to create test tenant') + } + + return tenant +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 641900d859..3c8c80cad1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: '@adonisjs/fold': specifier: ^8.2.0 version: 8.2.0 + '@apollo/client': + specifier: ^3.11.8 + version: 3.11.8(@types/react@18.2.73)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@apollo/server': specifier: ^4.11.2 version: 4.11.2(graphql@16.8.1) @@ -472,9 +475,6 @@ importers: specifier: ^9.0.1 version: 9.0.1 devDependencies: - '@apollo/client': - specifier: ^3.11.8 - version: 3.11.8(@types/react@18.2.73)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@graphql-codegen/cli': specifier: 5.0.2 version: 5.0.2(@babel/core@7.26.0)(@types/node@18.19.64)(graphql@16.8.1) diff --git a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml index e0cf08b12b..e205c052cc 100644 --- a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml @@ -31,6 +31,8 @@ services: PRIVATE_KEY_FILE: /workspace/private-key.pem AUTH_SERVER_INTROSPECTION_URL: http://cloud-nine-wallet-test-auth:3107 AUTH_SERVER_GRANT_URL: http://cloud-nine-wallet-test-auth:3106 + AUTH_ADMIN_API_URL: 'http://cloud-nine-wallet-test-auth:3003/graphql' + AUTH_ADMIN_API_SECRET: 'test-secret' ILP_ADDRESS: test.cloud-nine-wallet-test ILP_CONNECTOR_URL: http://cloud-nine-wallet-test-backend:3102 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= diff --git a/test/integration/testenv/happy-life-bank/docker-compose.yml b/test/integration/testenv/happy-life-bank/docker-compose.yml index 9cba1bc0c7..6fcb2e6f39 100644 --- a/test/integration/testenv/happy-life-bank/docker-compose.yml +++ b/test/integration/testenv/happy-life-bank/docker-compose.yml @@ -26,6 +26,8 @@ services: DATABASE_URL: postgresql://happy_life_bank_test_backend:happy_life_bank_test_backend@shared-database/happy_life_bank_test_backend AUTH_SERVER_GRANT_URL: http://happy-life-bank-test-auth:4106 AUTH_SERVER_INTROSPECTION_URL: http://happy-life-bank-test-auth:4107 + AUTH_ADMIN_API_URL: 'http://happy-life-bank-test-auth:4003/graphql' + AUTH_ADMIN_API_SECRET: 'test-secret' # matches pfry key id KEY_ID: keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 PRIVATE_KEY_FILE: /workspace/private-key.pem