From 2f74c4ee152bde06ef41e54de23ef9f6248a45cf Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Wed, 27 Nov 2024 10:20:34 -0800 Subject: [PATCH 01/10] feat(backend): tenant service --- packages/backend/jest.config.js | 2 + .../20241125224212_create_tenants_table.js | 3 + packages/backend/src/config/app.ts | 3 + packages/backend/src/index.ts | 93 +++++ packages/backend/src/tenants/model.ts | 8 + packages/backend/src/tenants/service.test.ts | 366 ++++++++++++++++++ packages/backend/src/tenants/service.ts | 202 ++++++++++ 7 files changed, 677 insertions(+) create mode 100644 packages/backend/src/tenants/service.test.ts create mode 100644 packages/backend/src/tenants/service.ts 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..df567d29af 100644 --- a/packages/backend/migrations/20241125224212_create_tenants_table.js +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -8,6 +8,9 @@ exports.up = function (knex) { table.string('email').notNullable() table.string('publicName') table.string('apiSecret') + + table.timestamp('createdAt').defaultTo(knex.fn.now()) + table.timestamp('updatedAt').defaultTo(knex.fn.now()) }) } 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..4734ace48e 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,86 @@ 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('tenantService', async (deps) => { + return createTenantService({ + logger: await deps.use('logger'), + knex: await deps.use('knex'), + apolloClient: await deps.use('apolloClient') + }) + }) + 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..961de413f9 100644 --- a/packages/backend/src/tenants/model.ts +++ b/packages/backend/src/tenants/model.ts @@ -9,3 +9,11 @@ export class Tenant extends BaseModel { public apiSecret!: string public publicName?: string } + +export type TenantWithIdpConfig = Pick< + Tenant, + 'id' | 'email' | 'apiSecret' | 'publicName' | 'createdAt' | 'updatedAt' +> & { + idpConsentUrl: string + idpSecret: string +} diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts new file mode 100644 index 0000000000..f2159b40bf --- /dev/null +++ b/packages/backend/src/tenants/service.test.ts @@ -0,0 +1,366 @@ +import assert from 'assert' +import { faker } from '@faker-js/faker' +import { IocContract } from '@adonisjs/fold' +import nock from 'nock' +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' + +describe('Tenant Service', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantService: TenantService + let config: IAppConfig + let apolloClient: ApolloClient + + 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') + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + nock.cleanAll() + await appContainer.shutdown() + }) + + 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 createScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + + const createdTenant = await tenantService.create(createOptions) + createScope.done() + + const getScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { + data: { + getTenant: { + tenant: { + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret + } + } + } + }) + const apolloSpy = jest.spyOn(apolloClient, 'query') + const tenant = await tenantService.get(createdTenant.id) + assert.ok(tenant) + expect(tenant.id).toEqual(createdTenant.id) + expect(tenant.email).toEqual(createOptions.email) + expect(tenant.publicName).toEqual(createOptions.publicName) + expect(tenant.apiSecret).toEqual(createOptions.apiSecret) + expect(tenant.idpConsentUrl).toEqual(createOptions.idpConsentUrl) + expect(tenant.idpSecret).toEqual(createOptions.idpSecret) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id + } + } + }) + ) + getScope.done() + }) + + test("returns undefined if auth tenant doesn't exist", async (): Promise => { + const createOptions = { + apiSecret: 'test-api-secret', + publicName: 'test tenant', + email: faker.internet.email(), + idpConsentUrl: faker.internet.url(), + idpSecret: 'test-idp-secret' + } + + const createScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, { data: { createTenant: { id: 1234 } } }) + + const createdTenant = await tenantService.create(createOptions) + createScope.done() + + const getScope = nock(config.authAdminApiUrl).post('').reply(400) + const apolloSpy = jest.spyOn(apolloClient, 'query') + let tenant + try { + tenant = await tenantService.get(createdTenant.id) + } catch (err) { + expect(tenant).toBeUndefined() + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: createdTenant.id + } + } + }) + ) + } + getScope.done() + }) + }) + + 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.email).toEqual(createOptions.email) + expect(tenant.publicName).toEqual(createOptions.publicName) + expect(tenant.apiSecret).toEqual(createOptions.apiSecret) + + 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(400) + + 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.apiSecret).toEqual(updatedTenantInfo.apiSecret) + expect(updatedTenant.email).toEqual(updatedTenantInfo.email) + expect(updatedTenant.publicName).toEqual(updatedTenantInfo.publicName) + 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(400) + 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.apiSecret).toEqual(originalTenantInfo.apiSecret) + expect(dbTenant.email).toEqual(originalTenantInfo.email) + expect(dbTenant.publicName).toEqual(originalTenantInfo.publicName) + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id, + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + } + } + }) + ) + } + + nock.cleanAll() + }) + }) + + 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).toBeUndefined() + expect(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { id: tenant.id } + } + }) + ) + + 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(400) + 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(apolloSpy).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { + input: { + id: tenant.id + } + } + }) + ) + } + + deleteScope.done() + }) + }) +}) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts new file mode 100644 index 0000000000..be4a6b67f8 --- /dev/null +++ b/packages/backend/src/tenants/service.ts @@ -0,0 +1,202 @@ +import { Tenant, TenantWithIdpConfig } from './model' +import { BaseService } from '../shared/baseService' +import { gql, NormalizedCacheObject } from '@apollo/client' +import { ApolloClient } from '@apollo/client' +import { TransactionOrKnex } from 'objection' + +export interface TenantService { + get: (id: string) => Promise + create: (options: CreateTenantOptions) => Promise + update: (options: UpdateTenantOptions) => Promise + delete: (id: string) => Promise +} + +export interface ServiceDependencies extends BaseService { + knex: TransactionOrKnex + apolloClient: ApolloClient +} + +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) + } +} + +async function getTenant( + deps: ServiceDependencies, + id: string +): Promise { + const tenant = await Tenant.query(deps.knex).findById(id) + if (!tenant) return undefined + + const query = gql` + query GetAuthTenant($input: GetTenantInput!) { + getTenant(input: $input) { + tenant { + id + idpConsentUrl + idpSecret + } + } + } + ` + const variables = { input: { id } } + // TODO: add type to this in https://github.com/interledger/rafiki/issues/3125 + const authTenantResponse = await deps.apolloClient.query({ query, variables }) + const authTenant = authTenantResponse.data.getTenant.tenant + if (!authTenant) { + deps.logger.error( + { tenantId: id }, + 'could not find auth tenant entry for existing backend entry' + ) + return undefined + } + + const { idpConsentUrl, idpSecret } = authTenant + return { + ...tenant, + idpConsentUrl, + idpSecret + } +} + +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 + }) + + 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() + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +interface UpdateTenantOptions { + id: string + email?: string + publicName?: string + apiSecret?: string + idpConsentUrl: string | null + idpSecret: string | null +} + +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 + }) + .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() + return tenant + } catch (err) { + await trx.rollback() + throw err + } +} + +async function deleteTenant( + deps: ServiceDependencies, + id: string +): Promise { + const trx = await deps.knex.transaction() + + try { + await Tenant.query(trx).deleteById(id) + const mutation = gql` + mutation DeleteAuthTenantMutation($input: DeleteTenantInput!) { + deleteTenant(input: $input) { + sucess + } + } + ` + const variables = { input: { id } } + // 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 + } +} From 61d45f71b0b00410a90d66793a3ea417e3cdcb4d Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Wed, 27 Nov 2024 13:30:48 -0800 Subject: [PATCH 02/10] fix: integration tests --- packages/backend/package.json | 2 +- pnpm-lock.yaml | 6 +++--- .../testenv/cloud-nine-wallet/docker-compose.yml | 2 ++ test/integration/testenv/happy-life-bank/docker-compose.yml | 2 ++ 4 files changed, 8 insertions(+), 4 deletions(-) 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/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 From d57bcc6628be8ef82b1bf70eae6a6af2b79360e5 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Wed, 27 Nov 2024 15:26:45 -0800 Subject: [PATCH 03/10] feat: use soft delete --- .../20241125224212_create_tenants_table.js | 3 +- packages/backend/src/tenants/model.ts | 11 +++++ packages/backend/src/tenants/service.test.ts | 44 ++++++++++++++++++- packages/backend/src/tenants/service.ts | 11 +++-- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js index df567d29af..5c2579f538 100644 --- a/packages/backend/migrations/20241125224212_create_tenants_table.js +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -6,11 +6,12 @@ exports.up = function (knex) { return knex.schema.createTable('tenants', function (table) { table.uuid('id').notNullable().primary() table.string('email').notNullable() + table.string('apiSecret').notNullable() 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/src/tenants/model.ts b/packages/backend/src/tenants/model.ts index 961de413f9..5af79794c0 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 { @@ -8,6 +9,16 @@ export class Tenant extends BaseModel { public email!: string public apiSecret!: string public publicName?: string + + public deletedAt?: Date + + $formatJson(json: Pojo): Pojo { + json = super.$formatJson(json) + return { + ...json, + deletedAt: json.deletedAt.toISOString() + } + } } export type TenantWithIdpConfig = Pick< diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index f2159b40bf..bee7d574ab 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -2,6 +2,7 @@ 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' @@ -17,6 +18,7 @@ describe('Tenant Service', (): void => { let tenantService: TenantService let config: IAppConfig let apolloClient: ApolloClient + let knex: Knex beforeAll(async (): Promise => { deps = initIocContainer(Config) @@ -24,6 +26,7 @@ describe('Tenant Service', (): void => { tenantService = await deps.use('tenantService') config = await deps.use('config') apolloClient = await deps.use('apolloClient') + knex = await deps.use('knex') }) afterEach(async (): Promise => { @@ -120,6 +123,17 @@ describe('Tenant Service', (): void => { } getScope.done() }) + + test('returns undefined if tenant is deleted', async (): Promise => { + const dbTenant = await Tenant.query(knex).insertAndFetch({ + apiSecret: 'test-secret', + email: faker.internet.email(), + deletedAt: new Date() + }) + + const tenant = await tenantService.get(dbTenant.id) + expect(tenant).toBeUndefined() + }) }) describe('create', (): void => { @@ -291,6 +305,31 @@ describe('Tenant Service', (): void => { 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, + 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 => { @@ -313,7 +352,9 @@ describe('Tenant Service', (): void => { await tenantService.delete(tenant.id) const dbTenant = await Tenant.query().findById(tenant.id) - expect(dbTenant).toBeUndefined() + expect(dbTenant?.deletedAt?.getTime()).toBeLessThanOrEqual( + new Date(Date.now()).getTime() + ) expect(apolloSpy).toHaveBeenCalledWith( expect.objectContaining({ variables: { @@ -349,6 +390,7 @@ describe('Tenant Service', (): void => { 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: { diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index be4a6b67f8..0676e6957c 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -37,7 +37,7 @@ async function getTenant( id: string ): Promise { const tenant = await Tenant.query(deps.knex).findById(id) - if (!tenant) return undefined + if (!tenant || !!tenant.deletedAt) return undefined const query = gql` query GetAuthTenant($input: GetTenantInput!) { @@ -124,8 +124,8 @@ interface UpdateTenantOptions { email?: string publicName?: string apiSecret?: string - idpConsentUrl: string | null - idpSecret: string | null + idpConsentUrl?: string + idpSecret?: string } async function updateTenant( @@ -143,6 +143,7 @@ async function updateTenant( publicName, apiSecret }) + .whereNull('deletedAt') .throwIfNotFound() if (idpConsentUrl || idpSecret) { @@ -183,7 +184,9 @@ async function deleteTenant( const trx = await deps.knex.transaction() try { - await Tenant.query(trx).deleteById(id) + await Tenant.query(trx).patchAndFetchById(id, { + deletedAt: new Date(Date.now()) + }) const mutation = gql` mutation DeleteAuthTenantMutation($input: DeleteTenantInput!) { deleteTenant(input: $input) { From 57a663d8e41981d1796a0a1ce679ef4961ac40f0 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Wed, 27 Nov 2024 15:37:28 -0800 Subject: [PATCH 04/10] refactor: compare whole object in test --- packages/backend/src/tenants/service.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index bee7d574ab..e51d73dd1f 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -70,12 +70,11 @@ describe('Tenant Service', (): void => { const apolloSpy = jest.spyOn(apolloClient, 'query') const tenant = await tenantService.get(createdTenant.id) assert.ok(tenant) - expect(tenant.id).toEqual(createdTenant.id) - expect(tenant.email).toEqual(createOptions.email) - expect(tenant.publicName).toEqual(createOptions.publicName) - expect(tenant.apiSecret).toEqual(createOptions.apiSecret) - expect(tenant.idpConsentUrl).toEqual(createOptions.idpConsentUrl) - expect(tenant.idpSecret).toEqual(createOptions.idpSecret) + expect(tenant).toEqual({ + ...createdTenant, + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret + }) expect(apolloSpy).toHaveBeenCalledWith( expect.objectContaining({ variables: { From fb3d702a7845bf83aa4df799745d68f455ae4d1e Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Mon, 2 Dec 2024 09:35:49 -0800 Subject: [PATCH 05/10] fix: better gql errors in tests --- packages/backend/src/tenants/service.test.ts | 54 ++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index e51d73dd1f..39b723b7a3 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -12,6 +12,44 @@ import { truncateTables } from '../tests/tableManager' import { ApolloClient, NormalizedCacheObject } from '@apollo/client' import { Tenant } from './model' +const generateMutateGqlError = (path: string = 'createTenant') => ({ + errors: [ + { + message: 'invalid input syntax', + locations: [ + { + line: 1, + column: 1 + } + ], + path: [path], + extensions: { + code: 'INTERNAl_SERVER_ERROR' + } + } + ], + data: null +}) + +const queryGqlError = { + errors: [ + { + message: 'unknown peer', + locations: [ + { + line: 1, + column: 1 + } + ], + path: ['tenant'], + extensions: { + code: 'NOT_FOUND' + } + } + ], + data: null +} + describe('Tenant Service', (): void => { let deps: IocContract let appContainer: TestContainer @@ -103,7 +141,9 @@ describe('Tenant Service', (): void => { const createdTenant = await tenantService.create(createOptions) createScope.done() - const getScope = nock(config.authAdminApiUrl).post('').reply(400) + const getScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, queryGqlError) const apolloSpy = jest.spyOn(apolloClient, 'query') let tenant try { @@ -180,7 +220,9 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl).post('').reply(400) + const scope = nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('createTenant')) const apolloSpy = jest.spyOn(apolloClient, 'mutate') let tenant @@ -277,7 +319,9 @@ describe('Tenant Service', (): void => { nock.cleanAll() - nock(config.authAdminApiUrl).post('').reply(400) + nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('updateTenant')) const apolloSpy = jest.spyOn(apolloClient, 'mutate') let updatedTenant try { @@ -382,7 +426,9 @@ describe('Tenant Service', (): void => { nock.cleanAll() const apolloSpy = jest.spyOn(apolloClient, 'mutate') - const deleteScope = nock(config.authAdminApiUrl).post('').reply(400) + const deleteScope = nock(config.authAdminApiUrl) + .post('') + .reply(200, generateMutateGqlError('deleteTenant')) try { await tenantService.delete(tenant.id) } catch (err) { From e5cc2b5db3e01b0a230ef972c8b5a6e247867db6 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Mon, 2 Dec 2024 16:19:53 -0800 Subject: [PATCH 06/10] feat: add idp columns to tenant model --- .../20241125224212_create_tenants_table.js | 2 ++ packages/backend/src/tenants/model.ts | 2 ++ packages/backend/src/tenants/service.test.ts | 16 +++++++--------- packages/backend/src/tenants/service.ts | 8 ++++++-- 4 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js index 5c2579f538..7cb7ed1508 100644 --- a/packages/backend/migrations/20241125224212_create_tenants_table.js +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -7,6 +7,8 @@ exports.up = function (knex) { table.uuid('id').notNullable().primary() table.string('email').notNullable() table.string('apiSecret').notNullable() + table.string('idpConsentUrl').notNullable() + table.string('idpSecret').notNullable() table.string('publicName') table.timestamp('createdAt').defaultTo(knex.fn.now()) diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts index 5af79794c0..2515e9e66a 100644 --- a/packages/backend/src/tenants/model.ts +++ b/packages/backend/src/tenants/model.ts @@ -8,6 +8,8 @@ export class Tenant extends BaseModel { public email!: string public apiSecret!: string + public idpConsentUrl!: string + public idpSecret!: string public publicName?: string public deletedAt?: Date diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 39b723b7a3..d15f7c97bd 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -167,6 +167,8 @@ describe('Tenant Service', (): void => { 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() }) @@ -192,9 +194,7 @@ describe('Tenant Service', (): void => { const apolloSpy = jest.spyOn(apolloClient, 'mutate') const tenant = await tenantService.create(createOptions) - expect(tenant.email).toEqual(createOptions.email) - expect(tenant.publicName).toEqual(createOptions.publicName) - expect(tenant.apiSecret).toEqual(createOptions.apiSecret) + expect(tenant).toEqual(expect.objectContaining(createOptions)) expect(apolloSpy).toHaveBeenCalledWith( expect.objectContaining({ @@ -278,9 +278,7 @@ describe('Tenant Service', (): void => { const apolloSpy = jest.spyOn(apolloClient, 'mutate') const updatedTenant = await tenantService.update(updatedTenantInfo) - expect(updatedTenant.apiSecret).toEqual(updatedTenantInfo.apiSecret) - expect(updatedTenant.email).toEqual(updatedTenantInfo.email) - expect(updatedTenant.publicName).toEqual(updatedTenantInfo.publicName) + expect(updatedTenant).toEqual(expect.objectContaining(updatedTenantInfo)) expect(apolloSpy).toHaveBeenCalledWith( expect.objectContaining({ variables: { @@ -330,9 +328,7 @@ describe('Tenant Service', (): void => { expect(updatedTenant).toBeUndefined() const dbTenant = await Tenant.query().findById(tenant.id) assert.ok(dbTenant) - expect(dbTenant.apiSecret).toEqual(originalTenantInfo.apiSecret) - expect(dbTenant.email).toEqual(originalTenantInfo.email) - expect(dbTenant.publicName).toEqual(originalTenantInfo.publicName) + expect(dbTenant).toEqual(expect.objectContaining(originalTenantInfo)) expect(apolloSpy).toHaveBeenCalledWith( expect.objectContaining({ variables: { @@ -354,6 +350,8 @@ describe('Tenant Service', (): void => { const dbTenant = await Tenant.query(knex).insertAndFetch({ email: faker.internet.url(), apiSecret: originalSecret, + idpSecret: 'test-idp-secret', + idpConsentUrl: faker.internet.url(), deletedAt: new Date() }) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 0676e6957c..9315f9698b 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -88,7 +88,9 @@ async function createTenant( const tenant = await Tenant.query(trx).insertAndFetch({ email, publicName, - apiSecret + apiSecret, + idpSecret, + idpConsentUrl }) const mutation = gql` @@ -141,7 +143,9 @@ async function updateTenant( .patchAndFetchById(options.id, { email, publicName, - apiSecret + apiSecret, + idpConsentUrl, + idpSecret }) .whereNull('deletedAt') .throwIfNotFound() From c824c569746e5b60b566f90a70998d9bcabfbf9c Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 3 Dec 2024 10:47:36 -0800 Subject: [PATCH 07/10] feat: pagination tests, push deletedAt to auth api call --- packages/backend/src/tenants/model.ts | 8 -- packages/backend/src/tenants/service.test.ts | 98 ++++---------------- packages/backend/src/tenants/service.ts | 56 ++++------- packages/backend/src/tests/tenant.ts | 41 ++++++++ 4 files changed, 76 insertions(+), 127 deletions(-) create mode 100644 packages/backend/src/tests/tenant.ts diff --git a/packages/backend/src/tenants/model.ts b/packages/backend/src/tenants/model.ts index 2515e9e66a..78b7a16139 100644 --- a/packages/backend/src/tenants/model.ts +++ b/packages/backend/src/tenants/model.ts @@ -22,11 +22,3 @@ export class Tenant extends BaseModel { } } } - -export type TenantWithIdpConfig = Pick< - Tenant, - 'id' | 'email' | 'apiSecret' | 'publicName' | 'createdAt' | 'updatedAt' -> & { - idpConsentUrl: string - idpSecret: string -} diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index d15f7c97bd..69b807de87 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -11,6 +11,9 @@ 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' const generateMutateGqlError = (path: string = 'createTenant') => ({ errors: [ @@ -31,25 +34,6 @@ const generateMutateGqlError = (path: string = 'createTenant') => ({ data: null }) -const queryGqlError = { - errors: [ - { - message: 'unknown peer', - locations: [ - { - line: 1, - column: 1 - } - ], - path: ['tenant'], - extensions: { - code: 'NOT_FOUND' - } - } - ], - data: null -} - describe('Tenant Service', (): void => { let deps: IocContract let appContainer: TestContainer @@ -76,6 +60,16 @@ describe('Tenant Service', (): void => { 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 = { @@ -93,19 +87,6 @@ describe('Tenant Service', (): void => { const createdTenant = await tenantService.create(createOptions) createScope.done() - const getScope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { - data: { - getTenant: { - tenant: { - idpConsentUrl: createOptions.idpConsentUrl, - idpSecret: createOptions.idpSecret - } - } - } - }) - const apolloSpy = jest.spyOn(apolloClient, 'query') const tenant = await tenantService.get(createdTenant.id) assert.ok(tenant) expect(tenant).toEqual({ @@ -113,54 +94,6 @@ describe('Tenant Service', (): void => { idpConsentUrl: createOptions.idpConsentUrl, idpSecret: createOptions.idpSecret }) - expect(apolloSpy).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - input: { - id: tenant.id - } - } - }) - ) - getScope.done() - }) - - test("returns undefined if auth tenant doesn't exist", async (): Promise => { - const createOptions = { - apiSecret: 'test-api-secret', - publicName: 'test tenant', - email: faker.internet.email(), - idpConsentUrl: faker.internet.url(), - idpSecret: 'test-idp-secret' - } - - const createScope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) - - const createdTenant = await tenantService.create(createOptions) - createScope.done() - - const getScope = nock(config.authAdminApiUrl) - .post('') - .reply(200, queryGqlError) - const apolloSpy = jest.spyOn(apolloClient, 'query') - let tenant - try { - tenant = await tenantService.get(createdTenant.id) - } catch (err) { - expect(tenant).toBeUndefined() - expect(apolloSpy).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - input: { - id: createdTenant.id - } - } - }) - ) - } - getScope.done() }) test('returns undefined if tenant is deleted', async (): Promise => { @@ -399,7 +332,7 @@ describe('Tenant Service', (): void => { expect(apolloSpy).toHaveBeenCalledWith( expect.objectContaining({ variables: { - input: { id: tenant.id } + input: { id: tenant.id, deletedAt: dbTenant?.deletedAt } } }) ) @@ -438,7 +371,8 @@ describe('Tenant Service', (): void => { expect.objectContaining({ variables: { input: { - id: tenant.id + id: tenant.id, + deletedAt: expect.any(Date) } } }) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 9315f9698b..453739c177 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -1,14 +1,16 @@ -import { Tenant, TenantWithIdpConfig } from './model' +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' export interface TenantService { - get: (id: string) => Promise + 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 { @@ -28,46 +30,25 @@ export async function createTenantService( get: (id: string) => getTenant(deps, id), create: (options) => createTenant(deps, options), update: (options) => updateTenant(deps, options), - delete: (id) => deleteTenant(deps, id) + delete: (id) => deleteTenant(deps, id), + getPage: (pagination, sortOrder) => + getTenantPage(deps, pagination, sortOrder) } } async function getTenant( deps: ServiceDependencies, id: string -): Promise { - const tenant = await Tenant.query(deps.knex).findById(id) - if (!tenant || !!tenant.deletedAt) return undefined - - const query = gql` - query GetAuthTenant($input: GetTenantInput!) { - getTenant(input: $input) { - tenant { - id - idpConsentUrl - idpSecret - } - } - } - ` - const variables = { input: { id } } - // TODO: add type to this in https://github.com/interledger/rafiki/issues/3125 - const authTenantResponse = await deps.apolloClient.query({ query, variables }) - const authTenant = authTenantResponse.data.getTenant.tenant - if (!authTenant) { - deps.logger.error( - { tenantId: id }, - 'could not find auth tenant entry for existing backend entry' - ) - return undefined - } +): Promise { + return await Tenant.query(deps.knex).findById(id).whereNull('deletedAt') +} - const { idpConsentUrl, idpSecret } = authTenant - return { - ...tenant, - idpConsentUrl, - idpSecret - } +async function getTenantPage( + deps: ServiceDependencies, + pagination?: Pagination, + sortOrder?: SortOrder +): Promise { + return await Tenant.query(deps.knex).getPage(pagination, sortOrder) } interface CreateTenantOptions { @@ -188,8 +169,9 @@ async function deleteTenant( const trx = await deps.knex.transaction() try { + const deletedAt = new Date(Date.now()) await Tenant.query(trx).patchAndFetchById(id, { - deletedAt: new Date(Date.now()) + deletedAt }) const mutation = gql` mutation DeleteAuthTenantMutation($input: DeleteTenantInput!) { @@ -198,7 +180,7 @@ async function deleteTenant( } } ` - const variables = { input: { id } } + 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() 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 +} From 9211ca3b29a3127592c48242c93ee2241b954679 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 3 Dec 2024 14:35:59 -0800 Subject: [PATCH 08/10] feat: add cache --- packages/backend/src/index.ts | 7 +- packages/backend/src/tenants/service.test.ts | 100 +++++++++++++++++-- packages/backend/src/tenants/service.ts | 15 ++- 3 files changed, 109 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 4734ace48e..b167410756 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -216,11 +216,16 @@ export function initIocContainer( 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') + apolloClient: await deps.use('apolloClient'), + tenantCache: await deps.use('tenantCache') }) }) diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index 69b807de87..84fad049ca 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -14,6 +14,7 @@ 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: [ @@ -80,20 +81,12 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const createScope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) - - const createdTenant = await tenantService.create(createOptions) - createScope.done() + const createdTenant = + await Tenant.query(knex).insertAndFetch(createOptions) const tenant = await tenantService.get(createdTenant.id) assert.ok(tenant) - expect(tenant).toEqual({ - ...createdTenant, - idpConsentUrl: createOptions.idpConsentUrl, - idpSecret: createOptions.idpSecret - }) + expect(tenant).toEqual(createdTenant) }) test('returns undefined if tenant is deleted', async (): Promise => { @@ -382,4 +375,89 @@ describe('Tenant Service', (): void => { 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 index 453739c177..6c06253684 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -4,6 +4,7 @@ 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 @@ -16,6 +17,7 @@ export interface TenantService { export interface ServiceDependencies extends BaseService { knex: TransactionOrKnex apolloClient: ApolloClient + tenantCache: CacheDataStore } export async function createTenantService( @@ -40,7 +42,14 @@ async function getTenant( deps: ServiceDependencies, id: string ): Promise { - return await Tenant.query(deps.knex).findById(id).whereNull('deletedAt') + 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( @@ -95,6 +104,8 @@ async function createTenant( // 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() @@ -155,6 +166,7 @@ async function updateTenant( } await trx.commit() + await deps.tenantCache.set(tenant.id, tenant) return tenant } catch (err) { await trx.rollback() @@ -168,6 +180,7 @@ async function deleteTenant( ): Promise { const trx = await deps.knex.transaction() + await deps.tenantCache.delete(id) try { const deletedAt = new Date(Date.now()) await Tenant.query(trx).patchAndFetchById(id, { From e2bbc79882834cf61bc3029c648ec4ca27226586 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Tue, 3 Dec 2024 15:25:39 -0800 Subject: [PATCH 09/10] fix: update localenv environment variables --- localenv/cloud-nine-wallet/docker-compose.yml | 2 ++ localenv/happy-life-bank/docker-compose.yml | 2 ++ 2 files changed, 4 insertions(+) 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= From 3625938cdb5fa3ce8d8ab2b06da0e68fd30357b4 Mon Sep 17 00:00:00 2001 From: Nathan Lie Date: Fri, 6 Dec 2024 14:47:53 -0800 Subject: [PATCH 10/10] feat: make some tenants fields optional, small refactors --- .../migrations/20241125224212_create_tenants_table.js | 6 +++--- packages/backend/src/tenants/service.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/migrations/20241125224212_create_tenants_table.js b/packages/backend/migrations/20241125224212_create_tenants_table.js index 7cb7ed1508..e6fc77e934 100644 --- a/packages/backend/migrations/20241125224212_create_tenants_table.js +++ b/packages/backend/migrations/20241125224212_create_tenants_table.js @@ -5,10 +5,10 @@ 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').notNullable() - table.string('idpSecret').notNullable() + table.string('idpConsentUrl') + table.string('idpSecret') table.string('publicName') table.timestamp('createdAt').defaultTo(knex.fn.now()) diff --git a/packages/backend/src/tenants/service.ts b/packages/backend/src/tenants/service.ts index 6c06253684..d1973471eb 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -182,7 +182,7 @@ async function deleteTenant( await deps.tenantCache.delete(id) try { - const deletedAt = new Date(Date.now()) + const deletedAt = new Date() await Tenant.query(trx).patchAndFetchById(id, { deletedAt })