diff --git a/localenv/cloud-nine-wallet/docker-compose.yml b/localenv/cloud-nine-wallet/docker-compose.yml index 6f22a11e27..5f1a6177bb 100644 --- a/localenv/cloud-nine-wallet/docker-compose.yml +++ b/localenv/cloud-nine-wallet/docker-compose.yml @@ -67,6 +67,7 @@ services: 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=' + AUTH_SERVICE_API_URL: 'http://cloud-nine-wallet-auth:3011' ILP_ADDRESS: ${ILP_ADDRESS:-test.cloud-nine-wallet} STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= API_SECRET: iyIgCprjb9uL8wFckR+pLEkJWMB7FJhgkvqhTQR/964= @@ -108,6 +109,7 @@ services: - '3006:3006' - "9230:9229" - '3009:3009' + - '3011:3011' environment: NODE_ENV: ${NODE_ENV:-development} TRUST_PROXY: ${TRUST_PROXY} @@ -119,6 +121,7 @@ services: COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= OPERATOR_TENANT_ID: 438fa74a-fa7d-4317-9ced-dde32ece1787 + SERVICE_API_PORT: 3011 depends_on: - shared-database - shared-redis diff --git a/localenv/happy-life-bank/docker-compose.yml b/localenv/happy-life-bank/docker-compose.yml index 15d41cce4a..ba4acb74d5 100644 --- a/localenv/happy-life-bank/docker-compose.yml +++ b/localenv/happy-life-bank/docker-compose.yml @@ -60,6 +60,7 @@ services: 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=' + AUTH_SERVICE_API_URL: 'http://happy-life-bank-auth:4011' ILP_ADDRESS: test.happy-life-bank ILP_CONNECTOR_URL: http://happy-life-bank-backend:4002 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= @@ -98,6 +99,7 @@ services: - '4006:3006' - '9232:9229' - '4009:3009' + - '4011:4011' environment: NODE_ENV: development AUTH_DATABASE_URL: postgresql://happy_life_bank_auth:happy_life_bank_auth@shared-database/happy_life_bank_auth @@ -108,6 +110,7 @@ services: COOKIE_KEY: 42397d1f371dd4b8b7d0308a689a57c882effd4ea909d792302542af47e2cd37 ADMIN_API_SECRET: rPoZpe9tVyBNCigm05QDco7WLcYa0xMao7lO5KG1XG4= OPERATOR_TENANT_ID: cf5fd7d3-1eb1-4041-8e43-ba45747e9e5d + SERVICE_API_PORT: 4011 depends_on: - cloud-nine-auth happy-life-admin: diff --git a/packages/auth/src/app.ts b/packages/auth/src/app.ts index dba8efebda..40895cc000 100644 --- a/packages/auth/src/app.ts +++ b/packages/auth/src/app.ts @@ -113,6 +113,7 @@ export class App { private interactionServer!: Server private introspectionServer!: Server private adminServer!: Server + private serviceAPIServer!: Server private logger!: Logger private config!: IAppConfig private databaseCleanupRules!: { @@ -455,6 +456,51 @@ export class App { this.interactionServer = koa.listen(port) } + public async startServiceAPIServer(port: number | string): Promise { + const koa = await this.createKoaServer() + + const router = new Router() + router.use(bodyParser()) + + const errorHandler = async (ctx: Koa.Context, next: Koa.Next) => { + try { + await next() + } catch (err) { + const logger = await ctx.container.use('logger') + logger.info( + { + method: ctx.method, + route: ctx.path, + headers: ctx.headers, + params: ctx.params, + requestBody: ctx.request.body, + err + }, + 'Service API Error' + ) + } + } + + koa.use(errorHandler) + + router.get('/healthz', (ctx: AppContext): void => { + ctx.status = 200 + }) + + const tenantRoutes = await this.container.use('tenantRoutes') + + router.get('/tenant/:id', tenantRoutes.get) + router.post('/tenant', tenantRoutes.create) + router.patch('/tenant/:id', tenantRoutes.update) + router.delete('/tenant/:id', tenantRoutes.delete) + + koa.use(cors()) + koa.use(router.middleware()) + koa.use(router.routes()) + + this.serviceAPIServer = koa.listen(port) + } + private async createKoaServer(): Promise> { const koa = new Koa({ proxy: this.config.trustProxy @@ -500,6 +546,9 @@ export class App { if (this.introspectionServer) { await this.stopServer(this.introspectionServer) } + if (this.serviceAPIServer) { + await this.stopServer(this.serviceAPIServer) + } } private async stopServer(server: Server): Promise { @@ -530,6 +579,10 @@ export class App { return this.getPort(this.introspectionServer) } + public getServiceAPIPort(): number { + return this.getPort(this.serviceAPIServer) + } + private getPort(server: Server): number { const address = server?.address() if (address && !(typeof address == 'string')) { diff --git a/packages/auth/src/config/app.ts b/packages/auth/src/config/app.ts index f3372e16f6..ef4daeffc0 100644 --- a/packages/auth/src/config/app.ts +++ b/packages/auth/src/config/app.ts @@ -43,6 +43,7 @@ export const Config = { authPort: envInt('AUTH_PORT', 3006), interactionPort: envInt('INTERACTION_PORT', 3009), introspectionPort: envInt('INTROSPECTION_PORT', 3007), + serviceAPIPort: envInt('SERVICE_API_PORT', 3011), env: envString('NODE_ENV', 'development'), trustProxy: envBool('TRUST_PROXY', false), enableManualMigrations: envBool('ENABLE_MANUAL_MIGRATIONS', false), diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 356b321cf1..b997a29f6a 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -22,6 +22,7 @@ import { createInteractionService } from './interaction/service' import { getTokenIntrospectionOpenAPI } from 'token-introspection' import { Redis } from 'ioredis' import { createTenantService } from './tenant/service' +import { createTenantRoutes } from './tenant/routes' const container = initIocContainer(Config) const app = new App(container) @@ -163,6 +164,16 @@ export function initIocContainer( } ) + container.singleton( + 'tenantRoutes', + async (deps: IocContract) => { + return createTenantRoutes({ + tenantService: await deps.use('tenantService'), + logger: await deps.use('logger') + }) + } + ) + container.singleton('openApi', async () => { const authServerSpec = await getAuthServerOpenAPI() const idpSpec = await createOpenAPI( @@ -315,6 +326,9 @@ export const start = async ( await app.startIntrospectionServer(config.introspectionPort) logger.info(`Introspection server listening on ${app.getIntrospectionPort()}`) + + await app.startServiceAPIServer(config.serviceAPIPort) + logger.info(`Service API server listening on ${app.getServiceAPIPort()}`) } // If this script is run directly, start the server diff --git a/packages/auth/src/shared/utils.test.ts b/packages/auth/src/shared/utils.test.ts index 8a5b2630ee..5bfbab445a 100644 --- a/packages/auth/src/shared/utils.test.ts +++ b/packages/auth/src/shared/utils.test.ts @@ -6,7 +6,7 @@ import { Config } from '../config/app' import { createContext } from '../tests/context' import { generateApiSignature } from '../tests/apiSignature' import { initIocContainer } from '..' -import { verifyApiSignature } from './utils' +import { verifyApiSignature, isValidDateString } from './utils' import { TestContainer, createTestApp } from '../tests/app' describe('utils', (): void => { @@ -145,4 +145,19 @@ describe('utils', (): void => { expect(verified).toBe(false) }) }) + + describe('isValidDateString', () => { + test.each([ + ['2024-12-05T15:10:09.545Z', true], + ['2024-12-05', true], + ['invalid-date', false], // Invalid date string + ['2024-12-05T25:10:09.545Z', false], // Invalid date string (invalid hour) + ['"2024-12-05T15:10:09.545Z"', false], // Improperly formatted string + ['', false], // Empty string + [null, false], // Null value + [undefined, false] // Undefined value + ])('should return %p for input %p', (input, expected) => { + expect(isValidDateString(input!)).toBe(expected) + }) + }) }) diff --git a/packages/auth/src/shared/utils.ts b/packages/auth/src/shared/utils.ts index c3b6ea5e26..f3b9bcbd84 100644 --- a/packages/auth/src/shared/utils.ts +++ b/packages/auth/src/shared/utils.ts @@ -104,3 +104,8 @@ export async function verifyApiSignature( return verifyApiSignatureDigest(signature as string, ctx.request, config) } + +// Intended for Date strings like "2024-12-05T15:10:09.545Z" (e.g., from new Date().toISOString()) +export function isValidDateString(date: string): boolean { + return !isNaN(Date.parse(date)) +} diff --git a/packages/auth/src/tenant/routes.test.ts b/packages/auth/src/tenant/routes.test.ts new file mode 100644 index 0000000000..2e3226d4e4 --- /dev/null +++ b/packages/auth/src/tenant/routes.test.ts @@ -0,0 +1,227 @@ +import { IocContract } from '@adonisjs/fold' +import { v4 } from 'uuid' + +import { createContext } from '../tests/context' +import { createTestApp, TestContainer } from '../tests/app' +import { Config } from '../config/app' +import { initIocContainer } from '..' +import { AppServices } from '../app' +import { truncateTables } from '../tests/tableManager' +import { + CreateContext, + UpdateContext, + DeleteContext, + TenantRoutes, + createTenantRoutes, + GetContext +} from './routes' +import { TenantService } from './service' +import { Tenant } from './model' + +describe('Tenant Routes', (): void => { + let deps: IocContract + let appContainer: TestContainer + let tenantRoutes: TenantRoutes + let tenantService: TenantService + + beforeAll(async (): Promise => { + deps = initIocContainer(Config) + appContainer = await createTestApp(deps) + tenantService = await deps.use('tenantService') + const logger = await deps.use('logger') + + tenantRoutes = createTenantRoutes({ + tenantService, + logger + }) + }) + + afterEach(async (): Promise => { + await truncateTables(appContainer.knex) + }) + + afterAll(async (): Promise => { + await appContainer.shutdown() + }) + + describe('get', (): void => { + test('Gets a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + + await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(200) + expect(ctx.body).toEqual({ + id: tenant.id, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret + }) + }) + + test('Returns 404 when getting non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + + await expect(tenantRoutes.get(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + expect(ctx.body).toBeUndefined() + }) + }) + + describe('create', (): void => { + test('Creates a tenant', async (): Promise => { + const tenantData = { + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + {} + ) + ctx.request.body = tenantData + + await expect(tenantRoutes.create(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + expect(ctx.body).toBe(undefined) + + const tenant = await Tenant.query().findById(tenantData.id) + expect(tenant).toBeDefined() + expect(tenant?.idpConsentUrl).toBe(tenantData.idpConsentUrl) + expect(tenant?.idpSecret).toBe(tenantData.idpSecret) + }) + }) + + describe('update', (): void => { + test('Updates a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const updateData = { + idpConsentUrl: 'https://example.com/new-consent', + idpSecret: 'newSecret123' + } + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + ctx.request.body = updateData + + await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + expect(ctx.body).toBe(undefined) + + const updatedTenant = await Tenant.query().findById(tenant.id) + expect(updatedTenant?.idpConsentUrl).toBe(updateData.idpConsentUrl) + expect(updatedTenant?.idpSecret).toBe(updateData.idpSecret) + }) + + test('Returns 404 when updating non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + ctx.request.body = { + idpConsentUrl: 'https://example.com/new-consent' + } + + await expect(tenantRoutes.update(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + }) + }) + + describe('delete', (): void => { + test('Deletes a tenant', async (): Promise => { + const tenant = await Tenant.query().insert({ + id: v4(), + idpConsentUrl: 'https://example.com/consent', + idpSecret: 'secret123' + }) + + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: tenant.id + } + ) + ctx.request.body = { deletedAt: new Date().toISOString() } + + await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(204) + + const deletedTenant = await Tenant.query().findById(tenant.id) + expect(deletedTenant?.deletedAt).not.toBeNull() + }) + + test('Returns 404 when deleting non-existent tenant', async (): Promise => { + const ctx = createContext( + { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json' + } + }, + { + id: v4() + } + ) + ctx.request.body = { deletedAt: new Date().toISOString() } + + await expect(tenantRoutes.delete(ctx)).resolves.toBeUndefined() + expect(ctx.status).toBe(404) + }) + }) +}) diff --git a/packages/auth/src/tenant/routes.ts b/packages/auth/src/tenant/routes.ts new file mode 100644 index 0000000000..0ffe7c5940 --- /dev/null +++ b/packages/auth/src/tenant/routes.ts @@ -0,0 +1,147 @@ +import { ParsedUrlQuery } from 'querystring' +import { AppContext } from '../app' +import { TenantService } from './service' +import { BaseService } from '../shared/baseService' +import { Tenant } from './model' +import { isValidDateString } from '../shared/utils' + +type TenantRequest = Exclude< + AppContext['request'], + 'body' +> & { + body: BodyT + query: ParsedUrlQuery & QueryT +} + +type TenantContext = Exclude< + AppContext, + 'request' +> & { + request: TenantRequest +} + +interface CreateTenantBody { + id: string + idpConsentUrl: string + idpSecret: string +} + +type UpdateTenantBody = Partial> + +interface TenantParams { + id: string +} + +interface TenantResponse { + id: string + idpConsentUrl: string + idpSecret: string +} + +export type GetContext = TenantContext +export type CreateContext = TenantContext +export type UpdateContext = TenantContext +export type DeleteContext = TenantContext<{ deletedAt: string }, TenantParams> + +export interface TenantRoutes { + get(ctx: GetContext): Promise + create(ctx: CreateContext): Promise + update(ctx: UpdateContext): Promise + delete(ctx: DeleteContext): Promise +} + +interface ServiceDependencies extends BaseService { + tenantService: TenantService +} + +export function createTenantRoutes({ + tenantService, + logger +}: ServiceDependencies): TenantRoutes { + const log = logger.child({ + service: 'TenantRoutes' + }) + + const deps = { tenantService, logger: log } + + return { + get: (ctx: GetContext) => getTenant(deps, ctx), + create: (ctx: CreateContext) => createTenant(deps, ctx), + update: (ctx: UpdateContext) => updateTenant(deps, ctx), + delete: (ctx: DeleteContext) => deleteTenant(deps, ctx) + } +} + +async function createTenant( + deps: ServiceDependencies, + ctx: CreateContext +): Promise { + const { body } = ctx.request + + await deps.tenantService.create(body) + + ctx.status = 204 +} + +async function updateTenant( + deps: ServiceDependencies, + ctx: UpdateContext +): Promise { + const { id } = ctx.params + const { body } = ctx.request + const tenant = await deps.tenantService.update(id, body) + + if (!tenant) { + ctx.status = 404 + return + } + + ctx.status = 204 +} + +async function deleteTenant( + deps: ServiceDependencies, + ctx: DeleteContext +): Promise { + const { id } = ctx.params + const { deletedAt: deletedAtString } = ctx.request.body + + if (!isValidDateString(deletedAtString)) { + ctx.status = 400 + return + } + const deletedAt = new Date(deletedAtString) + + const deleted = await deps.tenantService.delete(id, deletedAt) + + if (!deleted) { + ctx.status = 404 + return + } + + ctx.status = 204 +} + +async function getTenant( + deps: ServiceDependencies, + ctx: GetContext +): Promise { + const { id } = ctx.params + const tenant = await deps.tenantService.get(id) + + if (!tenant) { + ctx.status = 404 + return + } + + ctx.status = 200 + ctx.body = toTenantResponse(tenant) +} + +function toTenantResponse(tenant: Tenant): TenantResponse { + return { + id: tenant.id, + idpConsentUrl: tenant.idpConsentUrl, + idpSecret: tenant.idpSecret + } +} diff --git a/packages/auth/src/tenant/service.test.ts b/packages/auth/src/tenant/service.test.ts index fa553b7b18..d5b68ffc15 100644 --- a/packages/auth/src/tenant/service.test.ts +++ b/packages/auth/src/tenant/service.test.ts @@ -39,12 +39,14 @@ describe('Tenant Service', (): void => { const tenantData = createTenantData() const tenant = await tenantService.create(tenantData) - expect(tenant).toMatchObject({ + expect(tenant).toEqual({ id: tenantData.id, idpConsentUrl: tenantData.idpConsentUrl, - idpSecret: tenantData.idpSecret + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: undefined }) - expect(tenant.deletedAt).toBe(undefined) }) test('fails to create tenant with duplicate id', async (): Promise => { @@ -61,7 +63,14 @@ describe('Tenant Service', (): void => { const created = await tenantService.create(tenantData) const tenant = await tenantService.get(created.id) - expect(tenant).toMatchObject(tenantData) + expect(tenant).toEqual({ + id: tenantData.id, + idpConsentUrl: tenantData.idpConsentUrl, + idpSecret: tenantData.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null + }) }) test('returns undefined for non-existent tenant', async (): Promise => { @@ -72,7 +81,7 @@ describe('Tenant Service', (): void => { test('returns undefined for soft deleted tenant', async (): Promise => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - await tenantService.delete(created.id) + await tenantService.delete(created.id, new Date()) const tenant = await tenantService.get(created.id) expect(tenant).toBeUndefined() @@ -90,9 +99,12 @@ describe('Tenant Service', (): void => { } const updated = await tenantService.update(created.id, updateData) - expect(updated).toMatchObject({ + expect(updated).toEqual({ id: created.id, - ...updateData + ...updateData, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null }) }) @@ -105,10 +117,13 @@ describe('Tenant Service', (): void => { } const updated = await tenantService.update(created.id, updateData) - expect(updated).toMatchObject({ + expect(updated).toEqual({ id: created.id, idpConsentUrl: updateData.idpConsentUrl, - idpSecret: created.idpSecret + idpSecret: created.idpSecret, + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null }) }) @@ -122,7 +137,7 @@ describe('Tenant Service', (): void => { test('returns undefined for soft-deleted tenant', async (): Promise => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - await tenantService.delete(created.id) + await tenantService.delete(created.id, new Date()) const updated = await tenantService.update(created.id, { idpConsentUrl: faker.internet.url() @@ -136,7 +151,7 @@ describe('Tenant Service', (): void => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - const result = await tenantService.delete(created.id) + const result = await tenantService.delete(created.id, new Date()) expect(result).toBe(true) const tenant = await tenantService.get(created.id) @@ -150,7 +165,7 @@ describe('Tenant Service', (): void => { }) test('returns false for non-existent tenant', async (): Promise => { - const result = await tenantService.delete(faker.string.uuid()) + const result = await tenantService.delete(faker.string.uuid(), new Date()) expect(result).toBe(false) }) @@ -158,8 +173,8 @@ describe('Tenant Service', (): void => { const tenantData = createTenantData() const created = await tenantService.create(tenantData) - await tenantService.delete(created.id) - const secondDelete = await tenantService.delete(created.id) + await tenantService.delete(created.id, new Date()) + const secondDelete = await tenantService.delete(created.id, new Date()) expect(secondDelete).toBe(false) }) }) diff --git a/packages/auth/src/tenant/service.ts b/packages/auth/src/tenant/service.ts index d8d9f2a24c..d4f3cc336a 100644 --- a/packages/auth/src/tenant/service.ts +++ b/packages/auth/src/tenant/service.ts @@ -15,7 +15,7 @@ export interface TenantService { id: string, input: Partial> ): Promise - delete(id: string): Promise + delete(id: string, deletedAt: Date): Promise } interface ServiceDependencies extends BaseService { @@ -39,7 +39,7 @@ export async function createTenantService({ get: (id: string) => getTenant(deps, id), update: (id: string, input: Partial>) => updateTenant(deps, id, input), - delete: (id: string) => deleteTenant(deps, id) + delete: (id: string, deletedAt: Date) => deleteTenant(deps, id, deletedAt) } } @@ -72,10 +72,11 @@ async function updateTenant( async function deleteTenant( deps: ServiceDependencies, - id: string + id: string, + deletedAt: Date ): Promise { const deleted = await Tenant.query(deps.knex) - .patch({ deletedAt: new Date() }) + .patch({ deletedAt }) .whereNull('deletedAt') .where('id', id) return deleted > 0 diff --git a/packages/auth/src/tests/app.ts b/packages/auth/src/tests/app.ts index 87338ba17c..aedf90fad3 100644 --- a/packages/auth/src/tests/app.ts +++ b/packages/auth/src/tests/app.ts @@ -34,6 +34,7 @@ export const createTestApp = async ( config.introspectionPort = 0 config.adminPort = 0 config.interactionPort = 0 + config.serviceAPIPort = 0 const logger = createLogger({ transport: { diff --git a/packages/backend/jest.env.js b/packages/backend/jest.env.js index 5509b4f583..4a8435dd72 100644 --- a/packages/backend/jest.env.js +++ b/packages/backend/jest.env.js @@ -6,6 +6,7 @@ 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_SERVICE_API_URL = 'http://127.0.0.1:3011' 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/src/app.ts b/packages/backend/src/app.ts index 03fbbcf1b6..db0e174012 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -105,6 +105,7 @@ import { getTenantFromApiSignature, TenantApiSignatureResult } from './shared/utils' +import { AuthServiceClient } from './auth-service-client/client' export interface AppContextData { logger: Logger container: AppContainer @@ -265,6 +266,7 @@ export interface AppServices { paymentMethodHandlerService: Promise ilpPaymentService: Promise localPaymentService: Promise + authServiceClient: AuthServiceClient } export type AppContainer = IocContract diff --git a/packages/backend/src/auth-service-client/client.test.ts b/packages/backend/src/auth-service-client/client.test.ts new file mode 100644 index 0000000000..5a4af980c0 --- /dev/null +++ b/packages/backend/src/auth-service-client/client.test.ts @@ -0,0 +1,123 @@ +import { faker } from '@faker-js/faker' +import nock from 'nock' +import { AuthServiceClient, AuthServiceClientError } from './client' + +describe('AuthServiceClient', () => { + const baseUrl = 'http://auth-service.biz' + let client: AuthServiceClient + + beforeEach(() => { + client = new AuthServiceClient(baseUrl) + nock.cleanAll() + }) + + afterEach(() => { + expect(nock.isDone()).toBeTruthy() + }) + + const createTenantData = () => ({ + id: faker.string.uuid(), + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + }) + + describe('tenant', () => { + describe('get', () => { + test('retrieves a tenant', async () => { + const tenantData = createTenantData() + + nock(baseUrl).get(`/tenant/${tenantData.id}`).reply(200, tenantData) + + const tenant = await client.tenant.get(tenantData.id) + expect(tenant).toEqual(tenantData) + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + + nock(baseUrl).get(`/tenant/${id}`).reply(404) + + await expect(client.tenant.get(id)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('create', () => { + test('creates a new tenant', async () => { + const tenantData = createTenantData() + + nock(baseUrl).post('/tenant', tenantData).reply(204) + + await expect(client.tenant.create(tenantData)).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const tenantData = createTenantData() + + nock(baseUrl) + .post('/tenant', tenantData) + .reply(409, { message: 'Tenant already exists' }) + + await expect(client.tenant.create(tenantData)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('update', () => { + test('updates an existing tenant', async () => { + const id = faker.string.uuid() + const updateData = { + idpConsentUrl: faker.internet.url(), + idpSecret: faker.string.alphanumeric(32) + } + + nock(baseUrl).patch(`/tenant/${id}`, updateData).reply(204) + + await expect( + client.tenant.update(id, updateData) + ).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + const updateData = { + idpConsentUrl: faker.internet.url() + } + + nock(baseUrl) + .patch(`/tenant/${id}`, updateData) + .reply(404, { message: 'Tenant not found' }) + + await expect(client.tenant.update(id, updateData)).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + + describe('delete', () => { + test('deletes an existing tenant', async () => { + const id = faker.string.uuid() + + nock(baseUrl).delete(`/tenant/${id}`).reply(204) + + await expect( + client.tenant.delete(id, new Date()) + ).resolves.toBeUndefined() + }) + + test('throws on bad request', async () => { + const id = faker.string.uuid() + + nock(baseUrl) + .delete(`/tenant/${id}`) + .reply(404, { message: 'Tenant not found' }) + + await expect(client.tenant.delete(id, new Date())).rejects.toThrow( + AuthServiceClientError + ) + }) + }) + }) +}) diff --git a/packages/backend/src/auth-service-client/client.ts b/packages/backend/src/auth-service-client/client.ts new file mode 100644 index 0000000000..da2e0a9a72 --- /dev/null +++ b/packages/backend/src/auth-service-client/client.ts @@ -0,0 +1,92 @@ +interface Tenant { + id: string + idpConsentUrl: string + idpSecret: string +} + +export class AuthServiceClientError extends Error { + constructor( + message: string, + public status: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public details?: any + ) { + super(message) + this.status = status + this.details = details + } +} + +export class AuthServiceClient { + private baseUrl: string + + constructor(baseUrl: string) { + this.baseUrl = baseUrl + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async request(path: string, options: RequestInit): Promise { + options.headers = { 'Content-Type': 'application/json', ...options.headers } + + const response = await fetch(`${this.baseUrl}${path}`, options) + + if (!response.ok) { + let errorDetails + try { + errorDetails = await response.json() + } catch { + errorDetails = { message: response.statusText } + } + + throw new AuthServiceClientError( + `Auth Service Client Error: ${response.status} ${response.statusText}`, + response.status, + errorDetails + ) + } + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + return undefined as T + } + + const contentType = response.headers.get('Content-Type') + if (contentType && contentType.includes('application/json')) { + try { + return (await response.json()) as T + } catch (error) { + throw new AuthServiceClientError( + `Failed to parse JSON response from ${path}`, + response.status + ) + } + } + + return (await response.text()) as T + } + + public tenant = { + get: (id: string) => + this.request(`/tenant/${id}`, { method: 'GET' }), + create: (data: Tenant) => + this.request('/tenant', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }), + update: (id: string, data: Partial>) => + this.request(`/tenant/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }), + delete: (id: string, deletedAt: Date) => + this.request(`/tenant/${id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deletedAt }) + }) + } +} diff --git a/packages/backend/src/config/app.ts b/packages/backend/src/config/app.ts index ec70dbb608..0d51cc501f 100644 --- a/packages/backend/src/config/app.ts +++ b/packages/backend/src/config/app.ts @@ -129,6 +129,7 @@ export const Config = { authAdminApiUrl: envString('AUTH_ADMIN_API_URL'), authAdminApiSecret: envString('AUTH_ADMIN_API_SECRET'), authAdminApiSignatureVersion: envInt('AUTH_ADMIN_API_SIGNATURE_VERSION', 1), + authServiceApiUrl: envString('AUTH_SERVICE_API_URL'), 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 b167410756..c0be42bb28 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -74,6 +74,7 @@ import { import { createWebhookService } from './webhook/service' import { createInMemoryDataStore } from './middleware/cache/data-stores/in-memory' import { createTenantService } from './tenants/service' +import { AuthServiceClient } from './auth-service-client/client' BigInt.prototype.toJSON = function () { return this.toString() @@ -220,12 +221,16 @@ export function initIocContainer( return createInMemoryDataStore(config.localCacheDuration) }) + container.singleton('authServiceClient', () => { + return new AuthServiceClient(config.authServiceApiUrl) + }) + 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') + tenantCache: await deps.use('tenantCache'), + authServiceClient: deps.use('authServiceClient') }) }) diff --git a/packages/backend/src/tenants/service.test.ts b/packages/backend/src/tenants/service.test.ts index da6d3b7009..3a7234b0de 100644 --- a/packages/backend/src/tenants/service.test.ts +++ b/packages/backend/src/tenants/service.test.ts @@ -7,49 +7,28 @@ import { AppServices } from '../app' import { initIocContainer } from '..' import { createTestApp, TestContainer } from '../tests/app' import { TenantService } from './service' -import { Config, IAppConfig } from '../config/app' +import { Config } 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 -}) +import { AuthServiceClient } from '../auth-service-client/client' describe('Tenant Service', (): void => { let deps: IocContract let appContainer: TestContainer let tenantService: TenantService - let config: IAppConfig - let apolloClient: ApolloClient let knex: Knex + let authServiceClient: AuthServiceClient 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') + authServiceClient = await deps.use('authServiceClient') }) afterEach(async (): Promise => { @@ -126,28 +105,21 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') const tenant = await tenantService.create(createOptions) expect(tenant).toEqual(expect.objectContaining(createOptions)) - expect(apolloSpy).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( expect.objectContaining({ - variables: { - input: { - id: tenant.id, - idpSecret: createOptions.idpSecret, - idpConsentUrl: createOptions.idpConsentUrl - } - } + id: tenant.id, + idpSecret: createOptions.idpSecret, + idpConsentUrl: createOptions.idpConsentUrl }) ) - - scope.done() }) test('tenant creation rolls back if auth tenant create fails', async (): Promise => { @@ -159,11 +131,13 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, generateMutateGqlError('createTenant')) + const spy = jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(() => { + throw new Error() + }) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + expect.assertions(3) let tenant try { tenant = await tenantService.create(createOptions) @@ -173,19 +147,14 @@ describe('Tenant Service', (): void => { const tenants = await Tenant.query() expect(tenants.length).toEqual(0) - expect(apolloSpy).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( expect.objectContaining({ - variables: { - input: { - id: expect.any(String), - idpConsentUrl: createOptions.idpConsentUrl, - idpSecret: createOptions.idpSecret - } - } + id: expect.any(String), + idpConsentUrl: createOptions.idpConsentUrl, + idpSecret: createOptions.idpSecret }) ) } - scope.done() }) }) @@ -199,10 +168,10 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) - .persist() + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(originalTenantInfo) const updatedTenantInfo = { @@ -214,22 +183,16 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret-two' } - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => undefined) 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() + expect(spy).toHaveBeenCalledWith(tenant.id, { + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret + }) }) test('rolls back tenant if auth tenant update fails', async (): Promise => { @@ -241,9 +204,10 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) + const tenant = await tenantService.create(originalTenantInfo) const updatedTenantInfo = { id: tenant.id, @@ -254,13 +218,14 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret-two' } - nock.cleanAll() + const spy = jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementationOnce(async () => { + throw new Error() + }) - nock(config.authAdminApiUrl) - .post('') - .reply(200, generateMutateGqlError('updateTenant')) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') let updatedTenant + expect.assertions(3) try { updatedTenant = await tenantService.update(updatedTenantInfo) } catch (err) { @@ -268,20 +233,14 @@ describe('Tenant Service', (): void => { const dbTenant = await Tenant.query().findById(tenant.id) assert.ok(dbTenant) expect(dbTenant).toEqual(expect.objectContaining(originalTenantInfo)) - expect(apolloSpy).toHaveBeenCalledWith( + expect(spy).toHaveBeenCalledWith( + tenant.id, expect.objectContaining({ - variables: { - input: { - id: tenant.id, - idpConsentUrl: updatedTenantInfo.idpConsentUrl, - idpSecret: updatedTenantInfo.idpSecret - } - } + idpConsentUrl: updatedTenantInfo.idpConsentUrl, + idpSecret: updatedTenantInfo.idpSecret }) ) } - - nock.cleanAll() }) test('Cannot update deleted tenant', async (): Promise => { @@ -294,7 +253,7 @@ describe('Tenant Service', (): void => { deletedAt: new Date() }) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const spy = jest.spyOn(authServiceClient.tenant, 'update') try { await tenantService.update({ id: dbTenant.id, @@ -307,7 +266,7 @@ describe('Tenant Service', (): void => { assert.ok(dbTenantAfterUpdate) expect(dbTenantAfterUpdate.apiSecret).toEqual(originalSecret) - expect(apolloSpy).toHaveBeenCalledTimes(0) + expect(spy).toHaveBeenCalledTimes(0) } }) }) @@ -322,28 +281,22 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) - .persist() + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) const tenant = await tenantService.create(createOptions) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => undefined) await tenantService.delete(tenant.id) const dbTenant = await Tenant.query().findById(tenant.id) - expect(dbTenant?.deletedAt?.getTime()).toBeLessThanOrEqual( + assert.ok(dbTenant?.deletedAt) + 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() + expect(spy).toHaveBeenCalledWith(tenant.id, dbTenant.deletedAt) }) test('Reverts deletion if auth tenant delete fails', async (): Promise => { @@ -355,17 +308,18 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { id: 1234 } } }) + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) const tenant = await tenantService.create(createOptions) - nock.cleanAll() + const spy = jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementationOnce(async () => { + throw new Error() + }) - const apolloSpy = jest.spyOn(apolloClient, 'mutate') - const deleteScope = nock(config.authAdminApiUrl) - .post('') - .reply(200, generateMutateGqlError('deleteTenant')) + expect.assertions(3) try { await tenantService.delete(tenant.id) } catch (err) { @@ -373,28 +327,17 @@ describe('Tenant Service', (): void => { 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) - } - } - }) - ) + expect(spy).toHaveBeenCalledWith(tenant.id, 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 + let authServiceClient: AuthServiceClient beforeAll(async (): Promise => { deps = initIocContainer({ @@ -402,9 +345,9 @@ describe('Tenant Service', (): void => { 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') + authServiceClient = await deps.use('authServiceClient') }) afterEach(async (): Promise => { @@ -425,10 +368,9 @@ describe('Tenant Service', (): void => { idpSecret: 'test-idp-secret' } - const scope = nock(config.authAdminApiUrl) - .post('') - .reply(200, { data: { createTenant: { tenant: { id: 1234 } } } }) - .persist() + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementation(async () => undefined) const spyCacheSet = jest.spyOn(tenantCache, 'set') const tenant = await tenantService.create(createOptions) @@ -447,6 +389,9 @@ describe('Tenant Service', (): void => { expect(spyCacheGet).toHaveBeenCalledWith(tenant.id) const spyCacheUpdateSet = jest.spyOn(tenantCache, 'set') + jest + .spyOn(authServiceClient.tenant, 'update') + .mockImplementation(async () => undefined) const updatedTenant = await tenantService.update({ id: tenant.id, apiSecret: 'test-api-secret-2' @@ -461,6 +406,9 @@ describe('Tenant Service', (): void => { expect(spyCacheUpdateSet).toHaveBeenCalledWith(tenant.id, updatedTenant) const spyCacheDelete = jest.spyOn(tenantCache, 'delete') + jest + .spyOn(authServiceClient.tenant, 'delete') + .mockImplementation(async () => undefined) await tenantService.delete(tenant.id) await expect(tenantService.get(tenant.id)).resolves.toBeUndefined() @@ -468,8 +416,6 @@ describe('Tenant Service', (): void => { // 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 d1973471eb..947881619f 100644 --- a/packages/backend/src/tenants/service.ts +++ b/packages/backend/src/tenants/service.ts @@ -1,10 +1,9 @@ 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' +import type { AuthServiceClient } from '../auth-service-client/client' export interface TenantService { get: (id: string) => Promise @@ -16,8 +15,8 @@ export interface TenantService { export interface ServiceDependencies extends BaseService { knex: TransactionOrKnex - apolloClient: ApolloClient tenantCache: CacheDataStore + authServiceClient: AuthServiceClient } export async function createTenantService( @@ -83,26 +82,12 @@ async function createTenant( idpConsentUrl }) - const mutation = gql` - mutation CreateAuthTenant($input: CreateTenantInput!) { - createTenant(input: $input) { - tenant { - id - } - } - } - ` - - const variables = { - input: { - id: tenant.id, - idpSecret, - idpConsentUrl - } - } + await deps.authServiceClient.tenant.create({ + 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) @@ -143,26 +128,10 @@ async function updateTenant( .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 deps.authServiceClient.tenant.update(id, { + idpConsentUrl, + idpSecret + }) } await trx.commit() @@ -186,16 +155,7 @@ async function deleteTenant( 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 deps.authServiceClient.tenant.delete(id, deletedAt) await trx.commit() } catch (err) { await trx.rollback() diff --git a/packages/backend/src/tests/tenant.ts b/packages/backend/src/tests/tenant.ts index f174a58f2f..579735b73d 100644 --- a/packages/backend/src/tests/tenant.ts +++ b/packages/backend/src/tests/tenant.ts @@ -1,4 +1,3 @@ -import nock from 'nock' import { IocContract } from '@adonisjs/fold' import { faker } from '@faker-js/faker' import { AppServices } from '../app' @@ -17,10 +16,10 @@ export async function createTenant( 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 authServiceClient = await deps.use('authServiceClient') + jest + .spyOn(authServiceClient.tenant, 'create') + .mockImplementationOnce(async () => undefined) const tenant = await tenantService.create( options || { email: faker.internet.email(), @@ -30,7 +29,6 @@ export async function createTenant( idpSecret: 'test-idp-secret' } ) - scope.done() if (!tenant) { throw Error('Failed to create test tenant') diff --git a/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx b/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx index 9f206994b4..6cda45440a 100644 --- a/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx +++ b/packages/documentation/src/content/docs/integration/prod/docker-compose.mdx @@ -112,6 +112,7 @@ services: AUTH_PORT: 3006 INTROSPECTION_PORT: 3007 INTERACTION_PORT: 3009 + SERVICE_API_PORT: 3011 COOKIE_KEY: {...} IDENTITY_SERVER_SECRET: {...} IDENTITY_SERVER_URL: {https://idp.mysystem.com} @@ -126,6 +127,7 @@ services: - '3006:3006' - '3007:3007' - '3009:3009' + - '3011:3011' restart: always rafiki-backend: @@ -137,6 +139,7 @@ services: environment: AUTH_SERVER_GRANT_URL: {https://auth.myrafiki.com} AUTH_SERVER_INTROSPECTION_URL: {https://auth.myrafiki.com/3007} + AUTH_SERVICE_API_URL: {https://auth.myrafiki.com/3011} DATABASE_URL: {postgresql://...} ILP_ADDRESS: {test.myrafiki} ADMIN_PORT: 3001 diff --git a/packages/documentation/src/partials/auth-variables.mdx b/packages/documentation/src/partials/auth-variables.mdx index d4851b48e3..b970386202 100644 --- a/packages/documentation/src/partials/auth-variables.mdx +++ b/packages/documentation/src/partials/auth-variables.mdx @@ -33,6 +33,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `INTERACTION_EXPIRY_SECONDS` | `auth.interactionExpirySeconds` | `600` (10 minutes) | The time, in seconds, for which a user can interact with a grant request before the request expires. | | `INTERACTION_PORT` | `auth.port.interaction` | `3009` | The port number of your Open Payments interaction-related APIs. | | `INTROSPECTION_PORT` | `auth.port.introspection` | `3007` | The port of your Open Payments access token introspection server. | +| `SERVICE_API_PORT` | `auth.port.serviceAPIPort` | `3011` | The port to expose the internal service api. | | `LIST_ALL_ACCESS_INTERACTION` | `auth.interaction.listAll` | `true` | When `true`, grant requests that include a `list-all` action will require interaction. In these requests, the client asks to list resources that it did not create. | | `LOG_LEVEL` | `auth.logLevel` | `info` | Pino log level | | `NODE_ENV` | `auth.nodeEnv` | `development` | The type of node environment: `development`, `test`, or `production`. | diff --git a/packages/documentation/src/partials/backend-variables.mdx b/packages/documentation/src/partials/backend-variables.mdx index 68fdb527b0..ce6f576811 100644 --- a/packages/documentation/src/partials/backend-variables.mdx +++ b/packages/documentation/src/partials/backend-variables.mdx @@ -17,6 +17,7 @@ import { LinkOut } from '@interledger/docs-design-system' | `REDIS_URL` | `backend.redis.host`,
`backend.redis.port` | `redis://127.0.0.1:6379` | The Redis URL of the database handling ILP packet data. For Helm, these components are provided individually. | | `USE_TIGERBEETLE` | `backend.use.tigerbeetle` | `true` | When `true`, a TigerBeetle database is used for accounting. When `false`, a Postgres database is used. | | `WEBHOOK_URL` | `backend.serviceUrls.WEBHOOK_URL` | _undefined_ | Your endpoint that consumes webhook events. | +| `AUTH_SERVICE_API_URL` | `backend.serviceUrls.AUTH_SERVICE_API_URL` | _undefined_ | The service-to-service api endpoint on your Open Payments authorization server. | diff --git a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml index 18630e78eb..d0a45c6bd9 100644 --- a/test/integration/testenv/cloud-nine-wallet/docker-compose.yml +++ b/test/integration/testenv/cloud-nine-wallet/docker-compose.yml @@ -33,6 +33,7 @@ services: 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' + AUTH_SERVICE_API_URL: 'http://cloud-nine-wallet-auth:3111/' ILP_ADDRESS: test.cloud-nine-wallet-test ILP_CONNECTOR_URL: http://cloud-nine-wallet-test-backend:3102 STREAM_SECRET: BjPXtnd00G2mRQwP/8ZpwyZASOch5sUXT5o0iR5b5wU= @@ -62,6 +63,7 @@ services: - '3106:3106' - '3107:3107' - '3109:3109' + - '3111:3111' environment: NODE_ENV: ${NODE_ENV:-development} AUTH_SERVER_URL: http://cloud-nine-wallet-test-auth:3106 @@ -71,6 +73,7 @@ services: INTERACTION_PORT: 3109 AUTH_PORT: 3106 ADMIN_PORT: 3103 + SERVICE_API_PORT: 3111 REDIS_URL: redis://shared-redis:6379/1 IDENTITY_SERVER_URL: http://localhost:3030/mock-idp/ IDENTITY_SERVER_SECRET: 2pEcn2kkCclbOHQiGNEwhJ0rucATZhrA807HTm2rNXE= diff --git a/test/integration/testenv/happy-life-bank/docker-compose.yml b/test/integration/testenv/happy-life-bank/docker-compose.yml index a0a42586ba..951a769ad3 100644 --- a/test/integration/testenv/happy-life-bank/docker-compose.yml +++ b/test/integration/testenv/happy-life-bank/docker-compose.yml @@ -28,6 +28,7 @@ services: 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' + AUTH_SERVICE_API_URL: 'http://happy-life-bank-test-auth:4111/' # matches pfry key id KEY_ID: keyid-97a3a431-8ee1-48fc-ac85-70e2f5eba8e5 PRIVATE_KEY_FILE: /workspace/private-key.pem @@ -62,6 +63,7 @@ services: - '4106:4106' - '4107:4107' - '4109:4109' + - '4111:4111' environment: NODE_ENV: development AUTH_DATABASE_URL: postgresql://happy_life_bank_test_auth:happy_life_bank_test_auth@shared-database/happy_life_bank_test_auth @@ -70,6 +72,7 @@ services: INTERACTION_PORT: 4109 INTROSPECTION_PORT: 4107 ADMIN_PORT: 4103 + SERVICE_API_PORT: 4111 AUTH_PORT: 4106 REDIS_URL: redis://shared-redis:6379/3 IDENTITY_SERVER_URL: http://localhost:3030/mock-idp/