Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions apps/agent-provisioning/src/agent-provisioning.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const mockExecFile = jest.fn();
const mockReadFile = jest.fn();
const mockAccess = jest.fn();

jest.mock('child_process', () => ({ execFile: mockExecFile }));
jest.mock('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('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');
});
});
171 changes: 125 additions & 46 deletions apps/agent-provisioning/src/agent-provisioning.service.ts
Original file line number Diff line number Diff line change
@@ -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 'child_process';
import { promisify } from 'util';

Check warning on line 10 in apps/agent-provisioning/src/agent-provisioning.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:util` over `util`.

See more on https://sonarcloud.io/project/issues?id=credebl_platform&issues=AZ_agfoWL4Y830V2lfVh&open=AZ_agfoWL4Y830V2lfVh&pullRequest=1708

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();

Expand Down Expand Up @@ -41,64 +46,138 @@
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
});
});
this.assertSafeFileIdentifier(orgId, 'orgId');
const safeContainerName = this.normalizeContainerName(containerName);

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(', ')}`);
}

const provisionTimeoutMs = this.getProvisionTimeoutMs();
await execFileAsync(
`${process.cwd()}${spinUpScript}`,
[
orgId,
externalIp,
walletName,
walletPassword,
seed,
webhookEndpoint,
walletStorageHost,
walletStoragePort,
walletStorageUser,
walletStoragePassword,
safeContainerName,
protocol,
String(tenant),
credoImage,
indyLedger,
inboundEndpoint,
...requiredEnvironment.map((name) => process.env[name] as string)
],
{ timeout: provisionTimeoutMs, maxBuffer: 1024 * 1024 }
).catch((error) => {
const failureDetail =
'number' === typeof error?.code
? ` (exit code ${error.code})`
: 'string' === typeof error?.signal
? ` (signal ${error.signal})`
: '';

Check warning on line 105 in apps/agent-provisioning/src/agent-provisioning.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=credebl_platform&issues=AaAkzJWEElfQYYjPVmTe&open=AaAkzJWEElfQYYjPVmTe&pullRequest=1708
throw new Error(`Agent provisioning script failed${failureDetail}`);
});
return spinUpResponse;

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: { CONTROLLER_ENDPOINT?: 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}`);
}

if ('string' !== typeof parsedEndpoint.CONTROLLER_ENDPOINT || !parsedEndpoint.CONTROLLER_ENDPOINT.trim()) {
throw new Error(`Missing CONTROLLER_ENDPOINT in: ${agentEndpointPath}`);
Comment thread
sign-mark marked this conversation as resolved.
Outdated
}

return { agentEndPoint: parsedEndpoint.CONTROLLER_ENDPOINT };
} else if (agentType === AgentType.ACAPY) {
// TODO: ACA-PY Agent Spin-Up
}
} 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 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 Error('containerName contains unsafe characters');

Check warning on line 156 in apps/agent-provisioning/src/agent-provisioning.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`new Error()` is too unspecific for a type check. Use `new TypeError()` instead.

See more on https://sonarcloud.io/project/issues?id=credebl_platform&issues=AaAkzJWEElfQYYjPVmTf&open=AaAkzJWEElfQYYjPVmTf&pullRequest=1708
}

const normalized = value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9_-]+/g, '_')
.replace(/^_+|_+$/g, '')

Check warning on line 163 in apps/agent-provisioning/src/agent-provisioning.service.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=credebl_platform&issues=AaAkzJWEElfQYYjPVmTg&open=AaAkzJWEElfQYYjPVmTg&pullRequest=1708
.slice(0, 128);

return normalized || 'agent';
}

async checkFileExistence(filePath: string): Promise<boolean> {
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`);
}
}
}