Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
},
"dependencies": {
"@aws-sdk/client-kms": "^3.500.0",
"@aws-sdk/client-s3": "^3.1075.0",
"@bull-board/api": "^5.21.0",
"@bull-board/express": "^5.21.0",
"@iarna/toml": "^2.2.5",
Expand Down
3 changes: 0 additions & 3 deletions backend/src/api/controllers/sep12.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ type UploadedFiles = { [fieldname: string]: Array<{ path: string }> };
const ALLOWED_CONTENT_TYPES = (process.env.UPLOAD_ALLOWED_CONTENT_TYPES ?? 'image/jpeg,image/png,application/pdf').split(',');
const UPLOAD_URL_EXPIRY_SECONDS = parseInt(process.env.UPLOAD_URL_EXPIRY_SECONDS ?? '900', 10);
const KEY_PREFIX = process.env.STORAGE_KEY_PREFIX ?? 'kyc';

type UploadedFiles = { [fieldname: string]: Array<{ path: string }> };

const pack = (enc?: { encryptedData: string; iv: string } | null) =>
enc ? `${enc.iv}|${enc.encryptedData}` : null;

Expand Down
3 changes: 2 additions & 1 deletion backend/src/api/routes/queue-dashboard.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const queues = Object.values(QUEUE_NAMES).map(
(name) => new BullMQAdapter(new Queue(name, { connection: queueConnection }))
);

createBullBoard({ queues, serverAdapter });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createBullBoard({ queues: queues as any, serverAdapter });

const router = Router();
router.use('/', serverAdapter.getRouter());
Expand Down
33 changes: 33 additions & 0 deletions backend/src/services/storage-provider.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { MockStorageProvider, storageProvider } from './storage-provider.service';

describe('storage-provider.service', () => {
describe('MockStorageProvider', () => {
let mockProvider: MockStorageProvider;

beforeEach(() => {
mockProvider = new MockStorageProvider('test-mock-bucket');
});

it('should generate mock presigned URL', async () => {
const url = await mockProvider.generatePresignedPutUrl('test-key', 'image/png', 3600);
expect(url).toBe('https://test-mock-bucket.mock.storage/test-key?X-Mock-Signed=1');
});

it('should correctly check if object exists', async () => {
expect(await mockProvider.objectExists('test-key')).toBe(false);
mockProvider._markUploaded('test-key');
expect(await mockProvider.objectExists('test-key')).toBe(true);
});

it('should use default mock bucket name if not specified', () => {
const defaultProvider = new MockStorageProvider();
expect(defaultProvider).toBeDefined();
});
});

describe('default storageProvider instance', () => {
it('should be defined', () => {
expect(storageProvider).toBeDefined();
});
});
});
92 changes: 92 additions & 0 deletions backend/src/services/storage.provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { StorageProvider } from './storage.provider';
import { S3Client } from '@aws-sdk/client-s3';

jest.mock('@aws-sdk/client-s3', () => {
return {
S3Client: jest.fn().mockImplementation(() => {
return {
send: jest.fn(),
};
}),
HeadObjectCommand: jest.fn().mockImplementation((args) => args),
};
});

describe('StorageProvider', () => {
let mockS3Client: S3Client;
let storageProvider: StorageProvider;

beforeEach(() => {
jest.clearAllMocks();
mockS3Client = new S3Client({}) as any;
storageProvider = new StorageProvider('test-bucket', mockS3Client);
});

describe('objectExists', () => {
it('should return true if the object exists (HEAD request succeeds)', async () => {
(mockS3Client.send as jest.Mock).mockResolvedValue({});

const exists = await storageProvider.objectExists('valid-file.pdf');

expect(exists).toBe(true);
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
});

it('should return false if the object is not found (error.name is NotFound)', async () => {
const notFoundError = new Error('Not Found');
notFoundError.name = 'NotFound';
(mockS3Client.send as jest.Mock).mockRejectedValue(notFoundError);

const exists = await storageProvider.objectExists('missing-file.pdf');

expect(exists).toBe(false);
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
});

it('should return false if the object is not found (error.name is NoSuchKey)', async () => {
const noSuchKeyError = new Error('NoSuchKey');
noSuchKeyError.name = 'NoSuchKey';
(mockS3Client.send as jest.Mock).mockRejectedValue(noSuchKeyError);

const exists = await storageProvider.objectExists('missing-file.pdf');

expect(exists).toBe(false);
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
});

it('should return false if the response status code is 404', async () => {
const error404 = new Error('Forbidden/NotFound') as Error & { $metadata?: { httpStatusCode: number } };
error404.$metadata = { httpStatusCode: 404 };
(mockS3Client.send as jest.Mock).mockRejectedValue(error404);

const exists = await storageProvider.objectExists('missing-file.pdf');

expect(exists).toBe(false);
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
});

it('should return false and log error for generic unexpected error', async () => {
const genericError = new Error('S3 connection timed out');
(mockS3Client.send as jest.Mock).mockRejectedValue(genericError);

const exists = await storageProvider.objectExists('error-file.pdf');

expect(exists).toBe(false);
expect(mockS3Client.send).toHaveBeenCalledTimes(1);
});

it('should return false immediately without S3 call if key is empty', async () => {
const exists = await storageProvider.objectExists('');

expect(exists).toBe(false);
expect(mockS3Client.send).not.toHaveBeenCalled();
});
});

describe('constructor default values', () => {
it('should use default bucket name and initialize S3Client when not provided', () => {
const provider = new StorageProvider();
expect(provider).toBeDefined();
});
});
});
66 changes: 66 additions & 0 deletions backend/src/services/storage.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';
import logger from '../utils/logger';

/**
* StorageProvider handles operations related to object storage (e.g. AWS S3 / MinIO).
*/
export class StorageProvider {
private s3Client: S3Client;
private bucketName: string;

/**
* Constructs the StorageProvider.
*
* @param bucketName - The name of the S3 bucket to query (defaults to environment variable AWS_BUCKET_NAME or 'anchorpoint-kyc-files')
* @param s3Client - An optional pre-configured S3Client instance (useful for testing/dependency injection)
*/
constructor(bucketName?: string, s3Client?: S3Client) {
this.bucketName = bucketName || process.env.AWS_BUCKET_NAME || 'anchorpoint-kyc-files';
this.s3Client = s3Client || new S3Client({
region: process.env.AWS_REGION || 'us-east-1',
endpoint: process.env.AWS_ENDPOINT,
forcePathStyle: process.env.AWS_FORCE_PATH_STYLE === 'true',
});
}

/**
* Verifies if an object exists in the storage bucket using a light metadata query (HEAD request).
* Does NOT download file content.
* Handles missing objects and other S3 errors gracefully, returning false instead of throwing.
*
* @param key - The key (path) of the object in S3.
* @returns A promise that resolves to true if the object exists, or false if it does not or if there was an error.
*/
public async objectExists(key: string): Promise<boolean> {
if (!key) {
logger.warn('StorageProvider.objectExists called with an empty or undefined key');
return false;
}

try {
const command = new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
});

await this.s3Client.send(command);
logger.info(`Object confirmed to exist: ${key}`);
return true;
} catch (error: unknown) {
const s3Error = error as { name?: string; Code?: string; statusCode?: number; $metadata?: { httpStatusCode?: number } };
const statusCode = s3Error.$metadata?.httpStatusCode || s3Error.statusCode || s3Error.Code;

if (
s3Error.name === 'NotFound' ||
s3Error.name === 'NoSuchKey' ||
statusCode === 404
) {
logger.info(`Object not found in storage (graceful check): ${key}`);
return false;
}

logger.error(`Error querying object metadata for key "${key}":`, error);
return false;
}
}
}
2 changes: 1 addition & 1 deletion backend/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"ignoreDeprecations": "6.0",
"ignoreDeprecations": "5.0",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
Expand Down
Loading
Loading