diff --git a/apps/agent-provisioning/src/agent-provisioning.service.spec.ts b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts new file mode 100644 index 000000000..31b3b542a --- /dev/null +++ b/apps/agent-provisioning/src/agent-provisioning.service.spec.ts @@ -0,0 +1,139 @@ +const mockExecFile = jest.fn(); +const mockReadFile = jest.fn(); +const mockAccess = jest.fn(); + +jest.mock('node:child_process', () => ({ execFile: mockExecFile })); +jest.mock('node:util', () => ({ promisify: jest.fn(() => mockExecFile) })); +jest.mock('fs', () => ({ promises: { access: mockAccess, readFile: mockReadFile } })); + +import { AgentType } from '@credebl/enum/enum'; +import { AgentProvisioningService } from './agent-provisioning.service'; + +const payload = { + orgId: 'org-123', + externalIp: '127.0.0.1', + walletName: 'wallet', + walletPassword: 'wallet-secret', + seed: 'seed', + webhookEndpoint: 'https://example.test/webhook', + walletStorageHost: 'postgres', + walletStoragePort: '5432', + walletStorageUser: 'user', + walletStoragePassword: 'storage-secret', + internalIp: '127.0.0.1', + containerName: 'issuer-agent', + agentType: AgentType.AFJ, + orgName: 'Organization', + indyLedger: '[]', + protocol: 'http', + credoImage: 'credo:latest', + tenant: false, + inboundEndpoint: '127.0.0.1' +}; + +describe('AgentProvisioningService', () => { + const logger = { log: jest.fn(), error: jest.fn() }; + const service = new AgentProvisioningService(logger as never); + const savedEnv = { ...process.env }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...savedEnv, + AFJ_AGENT_SPIN_UP: '/apps/agent-provisioning/AFJ/scripts/start_agent.sh', + AFJ_AGENT_ENDPOINT_PATH: '/apps/agent-provisioning/AFJ/endpoints/', + SCHEMA_FILE_SERVER_URL: 'https://schema.example', + AGENT_API_KEY: 'agent-key', + AWS_ACCOUNT_ID: 'account', + S3_BUCKET_ARN: 'bucket', + CLUSTER_NAME: 'cluster', + TASKDEFINITION_FAMILY: 'family', + ADMIN_TG_ARN: 'admin-tg', + INBOUND_TG_ARN: 'inbound-tg', + FILESYSTEMID: 'filesystem', + ECS_SUBNET_ID: 'subnet', + ECS_SECURITY_GROUP_ID: 'security-group' + }; + }); + + afterAll(() => { + process.env = savedEnv; + }); + + it('executes the provisioning script without a shell and returns the generated endpoint', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('{"CONTROLLER_ENDPOINT":"https://agent.example"}'); + + await expect(service.walletProvision(payload)).resolves.toEqual({ agentEndPoint: 'https://agent.example' }); + expect(mockExecFile).toHaveBeenCalledWith( + expect.stringContaining('/apps/agent-provisioning/AFJ/scripts/start_agent.sh'), + expect.arrayContaining([payload.orgId, payload.containerName, payload.walletPassword]), + expect.objectContaining({ timeout: 300000 }) + ); + }); + + it('normalizes organization-derived container names before executing the script and reading its endpoint', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('{"CONTROLLER_ENDPOINT":"https://agent.example"}'); + + await expect(service.walletProvision({ ...payload, containerName: 'Crédit Agricole, Inc.' })).resolves.toEqual({ + agentEndPoint: 'https://agent.example' + }); + expect(mockExecFile).toHaveBeenCalledWith( + expect.any(String), + expect.arrayContaining(['Credit_Agricole_Inc']), + expect.any(Object) + ); + expect(mockReadFile).toHaveBeenCalledWith(expect.stringContaining('org-123_Credit_Agricole_Inc.json'), 'utf8'); + }); + + it('rejects non-string identifiers before executing a script', async () => { + await expect(service.walletProvision({ ...payload, orgId: 123 as unknown as string })).rejects.toThrow( + 'orgId contains unsafe characters' + ); + expect(mockExecFile).not.toHaveBeenCalled(); + }); + + it('uses a configured provisioning timeout', async () => { + process.env.AFJ_AGENT_PROVISION_TIMEOUT_MS = '600000'; + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('{"CONTROLLER_ENDPOINT":"https://agent.example"}'); + + await service.walletProvision(payload); + expect(mockExecFile).toHaveBeenCalledWith( + expect.any(String), + expect.any(Array), + expect.objectContaining({ timeout: 600000 }) + ); + }); + + it.each([{}, 1, [], '', ' '])('rejects invalid CONTROLLER_ENDPOINT values', async (endpoint) => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue(JSON.stringify({ CONTROLLER_ENDPOINT: endpoint })); + + await expect(service.walletProvision(payload)).rejects.toThrow('Missing CONTROLLER_ENDPOINT'); + }); + + it('rejects a non-object endpoint document', async () => { + mockExecFile.mockResolvedValue({ stdout: '', stderr: '' }); + mockReadFile.mockResolvedValue('null'); + + await expect(service.walletProvision(payload)).rejects.toThrow('Missing CONTROLLER_ENDPOINT'); + }); + + it('propagates a provisioning script failure instead of attempting to read an endpoint file', async () => { + const failure = Object.assign(new Error('script failed'), { + code: 17, + stdout: 'stdout-secret', + stderr: 'stderr-secret' + }); + mockExecFile.mockRejectedValue(failure); + + await expect(service.walletProvision(payload)).rejects.toThrow('Agent provisioning script failed'); + expect(mockReadFile).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain('stdout-secret'); + expect(JSON.stringify(logger.error.mock.calls)).not.toContain('stderr-secret'); + expect(JSON.stringify(logger.error.mock.calls)).toContain('exit code 17'); + }); +}); diff --git a/apps/agent-provisioning/src/agent-provisioning.service.ts b/apps/agent-provisioning/src/agent-provisioning.service.ts index 3fe825e47..4f944dd50 100644 --- a/apps/agent-provisioning/src/agent-provisioning.service.ts +++ b/apps/agent-provisioning/src/agent-provisioning.service.ts @@ -1,12 +1,17 @@ import * as dotenv from 'dotenv'; -import * as fs from 'fs'; +import { promises as fs } from 'fs'; import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AgentType } from '@credebl/enum/enum'; import { IWalletProvision } from './interface/agent-provisioning.interfaces'; import { RpcException } from '@nestjs/microservices'; -import { exec } from 'child_process'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const SAFE_FILE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const DEFAULT_AGENT_PROVISION_TIMEOUT_MS = 300_000; dotenv.config(); @@ -21,84 +26,190 @@ export class AgentProvisioningService { */ async walletProvision(payload: IWalletProvision): Promise { try { - const { - containerName, - externalIp, - orgId, - seed, - walletName, - walletPassword, - walletStorageHost, - walletStoragePassword, - walletStoragePort, - walletStorageUser, - webhookEndpoint, - agentType, - protocol, - credoImage, - tenant, - indyLedger, - inboundEndpoint - } = payload; - if (agentType === AgentType.AFJ) { - // The wallet provision command is used to invoke a shell script - const walletProvision = `${process.cwd() + process.env.AFJ_AGENT_SPIN_UP} ${orgId} "${externalIp}" "${walletName}" "${walletPassword}" ${seed} ${webhookEndpoint} ${walletStorageHost} ${walletStoragePort} ${walletStorageUser} ${walletStoragePassword} ${containerName} ${protocol} ${tenant} ${credoImage} "${indyLedger}" ${inboundEndpoint} ${process.env.SCHEMA_FILE_SERVER_URL} ${process.env.AGENT_API_KEY} ${process.env.AWS_ACCOUNT_ID} ${process.env.S3_BUCKET_ARN} ${process.env.CLUSTER_NAME} ${process.env.TASKDEFINITION_FAMILY} ${process.env.ADMIN_TG_ARN} ${process.env.INBOUND_TG_ARN} ${process.env.FILESYSTEMID} ${process.env.ECS_SUBNET_ID} ${process.env.ECS_SECURITY_GROUP_ID}`; - const spinUpResponse: object = new Promise(async (resolve) => { - await exec(walletProvision, async (err, stdout, stderr) => { - this.logger.log(`shell script output: ${stdout}`); - if (stderr) { - this.logger.log(`shell script error: ${stderr}`); - } - - const agentEndpointPath = `${process.cwd()}${process.env.AFJ_AGENT_ENDPOINT_PATH}${orgId}_${containerName}.json`; - - const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); - - let agentEndPoint; - - if (agentEndPointExists) { - this.logger.log('Agent endpoint file exists'); - agentEndPoint = await fs.readFileSync(agentEndpointPath, 'utf8'); - // Proceed with accessing the files if needed - } else { - this.logger.log('Agent endpoint file does not exist'); - throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); - } - - let parsedEndpoint; - try { - parsedEndpoint = JSON.parse(agentEndPoint); - } catch (parseError) { - this.logger.error(`Failed to parse agent endpoint file: ${parseError.message}`); - throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); - } - - if (!parsedEndpoint.CONTROLLER_ENDPOINT) { - this.logger.error('CONTROLLER_ENDPOINT key missing in agent endpoint file'); - throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); - } - - resolve({ - agentEndPoint: parsedEndpoint.CONTROLLER_ENDPOINT - }); - }); - }); - return spinUpResponse; - } else if (agentType === AgentType.ACAPY) { + if (payload.agentType === AgentType.AFJ) { + return await this.provisionAfjAgent(payload); + } + + if (payload.agentType === AgentType.ACAPY) { // TODO: ACA-PY Agent Spin-Up } + + throw new RpcException(`Unsupported agent type: ${payload.agentType}`); } catch (error) { - this.logger.error(`[walletProvision] - error in wallet provision: ${JSON.stringify(error)}`); + this.logger.error( + `[walletProvision] - error in wallet provision: ${error instanceof Error ? error.message : 'Unknown error'}` + ); throw new RpcException(error); } } + private async provisionAfjAgent(payload: IWalletProvision): Promise { + this.assertSafeFileIdentifier(payload.orgId, 'orgId'); + const safeContainerName = this.normalizeContainerName(payload.containerName); + + const { spinUpScript, endpointDirectory, requiredEnvironment } = this.validateAfjConfig(); + const provisionTimeoutMs = this.getProvisionTimeoutMs(); + + await this.executeProvisioningScript( + payload, + safeContainerName, + spinUpScript, + requiredEnvironment, + provisionTimeoutMs + ); + + return this.readAgentEndpoint(payload.orgId, safeContainerName, endpointDirectory); + } + + private validateAfjConfig(): { + spinUpScript: string; + endpointDirectory: string; + requiredEnvironment: string[]; + } { + const spinUpScript = process.env.AFJ_AGENT_SPIN_UP; + const endpointDirectory = process.env.AFJ_AGENT_ENDPOINT_PATH; + if (!spinUpScript || !endpointDirectory) { + throw new Error('AFJ_AGENT_SPIN_UP and AFJ_AGENT_ENDPOINT_PATH must be configured'); + } + + const requiredEnvironment = [ + 'SCHEMA_FILE_SERVER_URL', + 'AGENT_API_KEY', + 'AWS_ACCOUNT_ID', + 'S3_BUCKET_ARN', + 'CLUSTER_NAME', + 'TASKDEFINITION_FAMILY', + 'ADMIN_TG_ARN', + 'INBOUND_TG_ARN', + 'FILESYSTEMID', + 'ECS_SUBNET_ID', + 'ECS_SECURITY_GROUP_ID' + ]; + const missingEnvironment = requiredEnvironment.filter((name) => !process.env[name]); + if (missingEnvironment.length) { + throw new Error(`Missing provisioning configuration: ${missingEnvironment.join(', ')}`); + } + + return { spinUpScript, endpointDirectory, requiredEnvironment }; + } + + private async executeProvisioningScript( + payload: IWalletProvision, + safeContainerName: string, + spinUpScript: string, + requiredEnvironment: string[], + provisionTimeoutMs: number + ): Promise { + await execFileAsync( + `${process.cwd()}${spinUpScript}`, + [ + payload.orgId, + payload.externalIp, + payload.walletName, + payload.walletPassword, + payload.seed, + payload.webhookEndpoint, + payload.walletStorageHost, + payload.walletStoragePort, + payload.walletStorageUser, + payload.walletStoragePassword, + safeContainerName, + payload.protocol, + String(payload.tenant), + payload.credoImage, + payload.indyLedger, + payload.inboundEndpoint, + ...requiredEnvironment.map((name) => process.env[name] as string) + ], + { timeout: provisionTimeoutMs, maxBuffer: 1024 * 1024 } + ).catch((error) => { + throw new Error(`Agent provisioning script failed${this.formatScriptFailure(error)}`); + }); + } + + private formatScriptFailure(error: { code?: unknown; signal?: unknown }): string { + if ('number' === typeof error?.code) { + return ` (exit code ${error.code})`; + } + if ('string' === typeof error?.signal) { + return ` (signal ${error.signal})`; + } + return ''; + } + + private async readAgentEndpoint( + orgId: string, + safeContainerName: string, + endpointDirectory: string + ): Promise { + const agentEndpointPath = `${process.cwd()}${endpointDirectory}${orgId}_${safeContainerName}.json`; + const agentEndPointExists = await this.checkFileExistence(agentEndpointPath); + if (!agentEndPointExists) { + throw new NotFoundException(`Agent endpoint file does not exist: ${agentEndpointPath}`); + } + + const agentEndPoint = await fs.readFile(agentEndpointPath, 'utf8'); + let parsedEndpoint: unknown; + try { + parsedEndpoint = JSON.parse(agentEndPoint); + } catch (parseError) { + this.logger.error(`Failed to parse agent endpoint file: ${parseError.message}`); + throw new Error(`Invalid JSON in agent endpoint file: ${agentEndpointPath}`); + } + + const controllerEndpoint = + null !== parsedEndpoint && 'object' === typeof parsedEndpoint && !Array.isArray(parsedEndpoint) + ? (parsedEndpoint as Record).CONTROLLER_ENDPOINT + : undefined; + if ('string' !== typeof controllerEndpoint || !controllerEndpoint.trim()) { + throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`); + } + + return { agentEndPoint: controllerEndpoint }; + } + + private getProvisionTimeoutMs(): number { + const configuredTimeout = process.env.AFJ_AGENT_PROVISION_TIMEOUT_MS; + if (!configuredTimeout) { + return DEFAULT_AGENT_PROVISION_TIMEOUT_MS; + } + + const timeout = Number(configuredTimeout); + if (!Number.isInteger(timeout) || 0 >= timeout) { + throw new Error('AFJ_AGENT_PROVISION_TIMEOUT_MS must be a positive integer'); + } + + return timeout; + } + + private normalizeContainerName(value: unknown): string { + if ('string' !== typeof value) { + throw new TypeError('containerName contains unsafe characters'); + } + + const normalized = value + .normalize('NFKD') + .replace(/\p{M}/gu, '') + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/^_+/, '') + .replace(/_+$/, '') + .slice(0, 128); + + return normalized || 'agent'; + } + async checkFileExistence(filePath: string): Promise { try { - await fs.accessSync(filePath); + await fs.access(filePath); return true; // File exists } catch (error) { return false; // File does not exist } } + + private assertSafeFileIdentifier(value: unknown, field: string): void { + if ('string' !== typeof value || !SAFE_FILE_IDENTIFIER.test(value)) { + throw new Error(`${field} contains unsafe characters`); + } + } }