From 6e59f9f8ecb4e7c3ccee66da9d351f40fa2ade56 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 27 Jun 2026 13:24:05 +0100 Subject: [PATCH 1/3] feat: implement StorageProvider with metadata HEAD requests to verify file existence --- backend/package.json | 3 +- backend/src/services/storage.provider.test.ts | 92 +++ backend/src/services/storage.provider.ts | 66 ++ package-lock.json | 573 +++++++++++++++++- 4 files changed, 716 insertions(+), 18 deletions(-) create mode 100644 backend/src/services/storage.provider.test.ts create mode 100644 backend/src/services/storage.provider.ts diff --git a/backend/package.json b/backend/package.json index 21bb4c71..a8fad5f5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "lint:fix": "eslint \"src/**/*.ts\" --fix" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1075.0", "@prisma/client": "^6.19.2", "@stellar/stellar-sdk": "^14.6.1", "cors": "^2.8.5", @@ -22,10 +23,10 @@ "express-rate-limit": "^7.1.0", "ioredis": "^5.3.0", "jsonwebtoken": "^9.0.3", + "prom-client": "^15.1.0", "rate-limit-redis": "^4.0.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", - "prom-client": "^15.1.0", "vite": "6.4.1", "winston": "^3.19.0", "zod": "^4.3.6" diff --git a/backend/src/services/storage.provider.test.ts b/backend/src/services/storage.provider.test.ts new file mode 100644 index 00000000..e0fec5ff --- /dev/null +++ b/backend/src/services/storage.provider.test.ts @@ -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: { send: jest.Mock }; + let storageProvider: StorageProvider; + + beforeEach(() => { + jest.clearAllMocks(); + mockS3Client = new S3Client({}); + storageProvider = new StorageProvider('test-bucket', mockS3Client); + }); + + describe('objectExists', () => { + it('should return true if the object exists (HEAD request succeeds)', async () => { + mockS3Client.send.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.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.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.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.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(); + }); + }); +}); diff --git a/backend/src/services/storage.provider.ts b/backend/src/services/storage.provider.ts new file mode 100644 index 00000000..aa03d334 --- /dev/null +++ b/backend/src/services/storage.provider.ts @@ -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 { + 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; + } + } +} diff --git a/package-lock.json b/package-lock.json index e8c378f3..1ea9e263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "backend": { "version": "1.0.0", "dependencies": { + "@aws-sdk/client-s3": "^3.1075.0", "@prisma/client": "^6.19.2", "@stellar/stellar-sdk": "^14.6.1", "cors": "^2.8.5", @@ -549,6 +550,435 @@ "openapi-types": ">=7" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.8.tgz", + "integrity": "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1075.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1075.0.tgz", + "integrity": "sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-node": "^3.972.58", + "@aws-sdk/middleware-flexible-checksums": "^3.974.33", + "@aws-sdk/middleware-sdk-s3": "^3.972.54", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", + "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.31", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", + "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", + "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", + "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-login": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", + "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", + "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-ini": "^3.972.56", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", + "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", + "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/token-providers": "3.1074.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", + "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.33.tgz", + "integrity": "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.54.tgz", + "integrity": "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", + "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1074.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", + "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", + "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2664,6 +3094,126 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/core": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", + "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz", + "integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", + "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", + "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@so-ric/colorspace": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", @@ -3872,6 +4422,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", @@ -10504,23 +11060,6 @@ "dev": true, "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", From f40f2ad02fb730998dc7f6b2fa3fca0b2287e70d Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 27 Jun 2026 13:43:52 +0100 Subject: [PATCH 2/3] fix: resolve compile and type issues after merging upstream/main --- backend/src/api/controllers/sep12.controller.ts | 3 --- backend/src/api/routes/queue-dashboard.route.ts | 3 ++- backend/src/services/storage.provider.test.ts | 14 +++++++------- backend/tsconfig.json | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 4e5d0005..d8497e14 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -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; diff --git a/backend/src/api/routes/queue-dashboard.route.ts b/backend/src/api/routes/queue-dashboard.route.ts index 11ae9d11..9fdcb795 100644 --- a/backend/src/api/routes/queue-dashboard.route.ts +++ b/backend/src/api/routes/queue-dashboard.route.ts @@ -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()); diff --git a/backend/src/services/storage.provider.test.ts b/backend/src/services/storage.provider.test.ts index e0fec5ff..bdae61d2 100644 --- a/backend/src/services/storage.provider.test.ts +++ b/backend/src/services/storage.provider.test.ts @@ -13,18 +13,18 @@ jest.mock('@aws-sdk/client-s3', () => { }); describe('StorageProvider', () => { - let mockS3Client: { send: jest.Mock }; + let mockS3Client: S3Client; let storageProvider: StorageProvider; beforeEach(() => { jest.clearAllMocks(); - mockS3Client = new S3Client({}); + 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.mockResolvedValue({}); + (mockS3Client.send as jest.Mock).mockResolvedValue({}); const exists = await storageProvider.objectExists('valid-file.pdf'); @@ -35,7 +35,7 @@ describe('StorageProvider', () => { 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.mockRejectedValue(notFoundError); + (mockS3Client.send as jest.Mock).mockRejectedValue(notFoundError); const exists = await storageProvider.objectExists('missing-file.pdf'); @@ -46,7 +46,7 @@ describe('StorageProvider', () => { 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.mockRejectedValue(noSuchKeyError); + (mockS3Client.send as jest.Mock).mockRejectedValue(noSuchKeyError); const exists = await storageProvider.objectExists('missing-file.pdf'); @@ -57,7 +57,7 @@ describe('StorageProvider', () => { 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.mockRejectedValue(error404); + (mockS3Client.send as jest.Mock).mockRejectedValue(error404); const exists = await storageProvider.objectExists('missing-file.pdf'); @@ -67,7 +67,7 @@ describe('StorageProvider', () => { it('should return false and log error for generic unexpected error', async () => { const genericError = new Error('S3 connection timed out'); - mockS3Client.send.mockRejectedValue(genericError); + (mockS3Client.send as jest.Mock).mockRejectedValue(genericError); const exists = await storageProvider.objectExists('error-file.pdf'); diff --git a/backend/tsconfig.json b/backend/tsconfig.json index fd4dfce8..fba81cd1 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "CommonJS", "moduleResolution": "node", - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "outDir": "./dist", "rootDir": "./src", "strict": true, From 14ee1f4f8b0c2f9b2199248d0d6d41c3e0760696 Mon Sep 17 00:00:00 2001 From: Olamidepy Date: Sat, 27 Jun 2026 14:04:26 +0100 Subject: [PATCH 3/3] test: add unit tests for storage-provider.service to meet coverage threshold --- .../services/storage-provider.service.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 backend/src/services/storage-provider.service.test.ts diff --git a/backend/src/services/storage-provider.service.test.ts b/backend/src/services/storage-provider.service.test.ts new file mode 100644 index 00000000..6eacc511 --- /dev/null +++ b/backend/src/services/storage-provider.service.test.ts @@ -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(); + }); + }); +});