diff --git a/Untitled b/Untitled new file mode 100644 index 00000000..0f5e55aa --- /dev/null +++ b/Untitled @@ -0,0 +1 @@ +git config user.email "tawfiqamuhammad@gmail.com" \ No newline at end of file diff --git a/backend/src/api/controllers/sep12.controller.test.ts b/backend/src/api/controllers/sep12.controller.test.ts index e5dbeb48..a0915a61 100644 --- a/backend/src/api/controllers/sep12.controller.test.ts +++ b/backend/src/api/controllers/sep12.controller.test.ts @@ -71,6 +71,7 @@ jest.mock('@stellar/stellar-sdk', () => ({ })); import { sep12Controller } from './sep12.controller'; +import { uploadStore } from '../../services/upload-store.service'; const makeRes = (): Response => { const res: Partial = {}; @@ -83,6 +84,7 @@ const makeRes = (): Response => { describe('Sep12Controller', () => { beforeEach(() => { jest.clearAllMocks(); + uploadStore._reset(); }); describe('putCustomer', () => { @@ -256,6 +258,203 @@ describe('Sep12Controller', () => { expect(res.status).toHaveBeenCalledWith(202); }); + it('resolves completed upload_id fields to storage keys', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + '', + 'image/jpeg', + expiresAt + ); + const storageKey = `kyc/${VALID_ACCOUNT}/id_photo_front/${record.uploadId}`; + uploadStore.setStorageKey(record.uploadId, storageKey); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + const req = { + body: { + account: VALID_ACCOUNT, + first_name: 'Jane', + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_upload', + }); + + await sep12Controller.putCustomer(req, res); + + expect(providerMock.submitCustomer).toHaveBeenCalledWith( + expect.objectContaining({ account: VALID_ACCOUNT, firstName: 'Jane', extraFields: {} }), + { id_photo_front: storageKey } + ); + expect(res.status).toHaveBeenCalledWith(202); + }); + + it('returns 400 when upload_id is not confirmed', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + 'kyc/pending-key', + 'image/jpeg', + expiresAt + ); + + const req = { + body: { + account: VALID_ACCOUNT, + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + + await sep12Controller.putCustomer(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Upload not confirmed for field: id_photo_front', + }); + expect(providerMock.submitCustomer).not.toHaveBeenCalled(); + }); + + it('returns 403 when upload_id belongs to a different account', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + 'GBZXN7PIRZGNMHGA7MUUUF4GW3F55GQRQ5UKMJTDEFEKTGW4RHFDQLNZ', + 'id_photo_front', + 'kyc/other/key', + 'image/jpeg', + expiresAt + ); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + const req = { + body: { + account: VALID_ACCOUNT, + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + + await sep12Controller.putCustomer(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Upload account does not match request for field: id_photo_front', + }); + }); + + it('prefers upload_id over a direct multipart attachment for the same field', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + '', + 'image/jpeg', + expiresAt + ); + const storageKey = `kyc/${VALID_ACCOUNT}/id_photo_front/${record.uploadId}`; + uploadStore.setStorageKey(record.uploadId, storageKey); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + const req = { + body: { + account: VALID_ACCOUNT, + first_name: 'Jane', + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + files: { + id_photo_front: [{ path: '/uploads/kyc/id-front.jpg' }], + }, + } as unknown as Request; + const res = makeRes(); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_pref', + }); + + await sep12Controller.putCustomer(req, res); + + expect(providerMock.submitCustomer).toHaveBeenCalledWith( + expect.objectContaining({ account: VALID_ACCOUNT }), + { id_photo_front: storageKey } + ); + }); + + it('produces identical provider submissions for upload_id and multipart paths', async () => { + const expiresAt = new Date(Date.now() + 60_000); + const record = uploadStore.create( + VALID_ACCOUNT, + 'id_photo_front', + '', + 'image/jpeg', + expiresAt + ); + const storageKey = `kyc/${VALID_ACCOUNT}/id_photo_front/${record.uploadId}`; + uploadStore.setStorageKey(record.uploadId, storageKey); + uploadStore.setStatus(record.uploadId, 'COMPLETED'); + + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_same', + }); + + const uploadIdReq = { + body: { + account: VALID_ACCOUNT, + first_name: 'Jane', + id_photo_front_upload_id: record.uploadId, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const multipartReq = { + body: { account: VALID_ACCOUNT, first_name: 'Jane' }, + user: { publicKey: VALID_ACCOUNT }, + files: { + id_photo_front: [{ path: storageKey }], + }, + } as unknown as Request; + + await sep12Controller.putCustomer(uploadIdReq, makeRes()); + const uploadIdCall = providerMock.submitCustomer.mock.calls[0]; + + jest.clearAllMocks(); + prismaMock.user.findUnique.mockResolvedValue({ id: 'u1', publicKey: VALID_ACCOUNT }); + prismaMock.kycCustomer.upsert.mockResolvedValue({ id: 'k1' }); + providerMock.submitCustomer.mockResolvedValue({ + success: true, + status: 'PENDING', + providerRef: 'mock_same', + }); + + await sep12Controller.putCustomer(multipartReq, makeRes()); + const multipartCall = providerMock.submitCustomer.mock.calls[0]; + + expect(uploadIdCall).toEqual(multipartCall); + }); + it('returns 202 with PROCESSING when provider submission fails', async () => { const req = { body: { account: VALID_ACCOUNT, first_name: 'Jane' }, diff --git a/backend/src/api/controllers/sep12.controller.ts b/backend/src/api/controllers/sep12.controller.ts index 4e5d0005..b3705b11 100644 --- a/backend/src/api/controllers/sep12.controller.ts +++ b/backend/src/api/controllers/sep12.controller.ts @@ -15,12 +15,57 @@ 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 UPLOAD_ID_SUFFIX = '_upload_id'; const pack = (enc?: { encryptedData: string; iv: string } | null) => enc ? `${enc.iv}|${enc.encryptedData}` : null; +type DocumentResolution = + | { documents: Record; kycFields: Record } + | { error: string; status: 400 | 403 }; + +/** Resolve KYC document references from pre-signed upload IDs and/or multipart files. */ +export function resolveCustomerDocuments( + account: string, + otherFields: Record, + uploadedFiles?: UploadedFiles +): DocumentResolution { + const kycFields: Record = {}; + const documents: Record = {}; + + for (const [key, value] of Object.entries(otherFields)) { + if (key.endsWith(UPLOAD_ID_SUFFIX)) { + const fieldName = key.slice(0, -UPLOAD_ID_SUFFIX.length); + const record = uploadStore.get(value); + + if (!record || record.status === 'EXPIRED') { + return { error: `Upload not found or expired for field: ${fieldName}`, status: 400 }; + } + if (record.status !== 'COMPLETED') { + return { error: `Upload not confirmed for field: ${fieldName}`, status: 400 }; + } + if (record.account !== account) { + return { error: `Upload account does not match request for field: ${fieldName}`, status: 403 }; + } + + documents[fieldName] = + record.storageKey || `${KEY_PREFIX}/${account}/${record.fieldName}/${value}`; + } else { + kycFields[key] = value; + } + } + + if (uploadedFiles) { + for (const field of Object.keys(uploadedFiles)) { + if (!documents[field]) { + documents[field] = uploadedFiles[field][0].path; + } + } + } + + return { documents, kycFields }; +} + export class Sep12Controller { private toDbStatus(status: KycStatus): KYCStatus { switch (status) { @@ -81,14 +126,13 @@ export class Sep12Controller { } const uploadedFiles = (req as AuthRequest & { files?: UploadedFiles }).files; - const documents: Record = {}; - if (uploadedFiles) { - for (const field of Object.keys(uploadedFiles)) { - documents[field] = uploadedFiles[field][0].path; - } + const resolution = resolveCustomerDocuments(account, otherFields, uploadedFiles); + if ('error' in resolution) { + return res.status(resolution.status).json({ error: resolution.error }); } + const { documents, kycFields } = resolution; - const extraPayload: Record = { ...otherFields }; + const extraPayload: Record = { ...kycFields }; if (Object.keys(documents).length > 0) { extraPayload.documents = documents; } @@ -117,7 +161,7 @@ export class Sep12Controller { firstName: first_name, lastName: last_name, email: email_address, - extraFields: otherFields, + extraFields: kycFields, }; let providerStatus = KycStatus.PENDING; @@ -301,10 +345,7 @@ export class Sep12Controller { const expiresAt = new Date(Date.now() + UPLOAD_URL_EXPIRY_SECONDS * 1000); const record = uploadStore.create(account, field_name, '', content_type, expiresAt); const storageKey = `${KEY_PREFIX}/${account}/${field_name}/${record.uploadId}`; - uploadStore.setStatus(record.uploadId, 'PENDING'); - // Persist the computed storage key back onto the record via a second set - const storedRecord = uploadStore.get(record.uploadId)!; - (storedRecord as any).storageKey = storageKey; + uploadStore.setStorageKey(record.uploadId, storageKey); const url = await storageProvider.generatePresignedPutUrl(storageKey, content_type, UPLOAD_URL_EXPIRY_SECONDS); @@ -345,7 +386,9 @@ export class Sep12Controller { return res.status(403).json({ error: 'account does not match upload record' }); } - const exists = await storageProvider.objectExists((record as any).storageKey ?? `${KEY_PREFIX}/${account}/${record.fieldName}/${upload_id}`); + const exists = await storageProvider.objectExists( + record.storageKey || `${KEY_PREFIX}/${account}/${record.fieldName}/${upload_id}` + ); if (!exists) { return res.status(422).json({ error: 'File not found in storage; upload may not have completed' }); } 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..f9c4489d --- /dev/null +++ b/backend/src/services/storage-provider.service.test.ts @@ -0,0 +1,142 @@ +import { + createStorageProvider, + GcsStorageProvider, + MockStorageProvider, + S3StorageProvider, + StorageProviderError, + storageConfigFromEnv, + validateStorageProviderConfig, +} from './storage-provider.service'; + +describe('StorageProvider initialization', () => { + describe('validateStorageProviderConfig', () => { + it('accepts a valid mock configuration', () => { + expect(validateStorageProviderConfig({ provider: 'mock', bucket: 'test-bucket' })).toEqual({ + provider: 'mock', + bucket: 'test-bucket', + region: undefined, + }); + }); + + it('accepts a valid S3 configuration', () => { + expect( + validateStorageProviderConfig({ provider: 's3', bucket: 'kyc-bucket', region: 'us-east-1' }) + ).toEqual({ + provider: 's3', + bucket: 'kyc-bucket', + region: 'us-east-1', + }); + }); + + it('accepts a valid GCS configuration', () => { + expect(validateStorageProviderConfig({ provider: 'gcs', bucket: 'kyc-bucket' })).toEqual({ + provider: 'gcs', + bucket: 'kyc-bucket', + region: undefined, + }); + }); + + it('rejects missing provider', () => { + expect(() => validateStorageProviderConfig({ bucket: 'b' })).toThrow(StorageProviderError); + expect(() => validateStorageProviderConfig({ bucket: 'b' })).toThrow('STORAGE_PROVIDER is required'); + }); + + it('rejects unsupported provider', () => { + expect(() => + validateStorageProviderConfig({ provider: 'azure' as 'mock', bucket: 'b' }) + ).toThrow('Unsupported STORAGE_PROVIDER: azure'); + }); + + it('rejects missing bucket', () => { + expect(() => validateStorageProviderConfig({ provider: 'mock', bucket: '' })).toThrow( + 'STORAGE_BUCKET is required' + ); + }); + + it('rejects S3 configuration without region', () => { + expect(() => validateStorageProviderConfig({ provider: 's3', bucket: 'b' })).toThrow( + 'STORAGE_REGION is required for S3' + ); + }); + }); + + describe('createStorageProvider', () => { + it('creates a mock provider with mock configuration', () => { + const provider = createStorageProvider({ provider: 'mock', bucket: 'dev-bucket' }); + expect(provider).toBeInstanceOf(MockStorageProvider); + }); + + it('creates an S3 provider when region is supplied', () => { + const provider = createStorageProvider({ + provider: 's3', + bucket: 'prod-bucket', + region: 'eu-west-1', + }); + expect(provider).toBeInstanceOf(S3StorageProvider); + }); + + it('creates a GCS provider with bucket only', () => { + const provider = createStorageProvider({ provider: 'gcs', bucket: 'gcs-bucket' }); + expect(provider).toBeInstanceOf(GcsStorageProvider); + }); + + it('fails early when bucket is missing', () => { + expect(() => createStorageProvider({ provider: 'mock', bucket: ' ' })).toThrow( + StorageProviderError + ); + }); + }); + + describe('storageConfigFromEnv', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = originalEnv; + }); + + it('defaults to mock provider in test environments', () => { + process.env = { ...originalEnv }; + delete process.env.STORAGE_PROVIDER; + delete process.env.STORAGE_BUCKET; + + expect(storageConfigFromEnv()).toEqual({ + provider: 'mock', + bucket: 'mock-bucket', + region: undefined, + }); + }); + + it('parses S3 environment variables', () => { + process.env = { + ...originalEnv, + STORAGE_PROVIDER: 's3', + STORAGE_BUCKET: 'anchor-kyc', + STORAGE_REGION: 'us-west-2', + }; + + expect(storageConfigFromEnv()).toEqual({ + provider: 's3', + bucket: 'anchor-kyc', + region: 'us-west-2', + }); + }); + }); + + describe('MockStorageProvider', () => { + it('generates mock presigned URLs and tracks uploaded keys', async () => { + const provider = new MockStorageProvider('unit-test-bucket'); + const url = await provider.generatePresignedPutUrl('kyc/doc.pdf', 'application/pdf', 900); + + expect(url).toContain('unit-test-bucket.mock.storage'); + expect(url).toContain('kyc/doc.pdf'); + expect(await provider.objectExists('kyc/doc.pdf')).toBe(false); + + provider._markUploaded('kyc/doc.pdf'); + expect(await provider.objectExists('kyc/doc.pdf')).toBe(true); + }); + + it('rejects empty bucket at construction', () => { + expect(() => new MockStorageProvider('')).toThrow('STORAGE_BUCKET is required'); + }); + }); +}); diff --git a/backend/src/services/storage-provider.service.ts b/backend/src/services/storage-provider.service.ts index 27671e4e..96b2049a 100644 --- a/backend/src/services/storage-provider.service.ts +++ b/backend/src/services/storage-provider.service.ts @@ -9,12 +9,62 @@ export interface StorageProvider { objectExists(key: string): Promise; } +export type StorageProviderKind = 'mock' | 's3' | 'gcs'; + +export interface StorageProviderConfig { + provider: StorageProviderKind; + bucket: string; + region?: string; +} + +export class StorageProviderError extends Error { + constructor(message: string) { + super(message); + this.name = 'StorageProviderError'; + } +} + +const SUPPORTED_PROVIDERS: StorageProviderKind[] = ['mock', 's3', 'gcs']; + +export function validateStorageProviderConfig( + config: Partial +): StorageProviderConfig { + if (!config.provider) { + throw new StorageProviderError('STORAGE_PROVIDER is required'); + } + if (!SUPPORTED_PROVIDERS.includes(config.provider)) { + throw new StorageProviderError(`Unsupported STORAGE_PROVIDER: ${config.provider}`); + } + if (!config.bucket?.trim()) { + throw new StorageProviderError('STORAGE_BUCKET is required'); + } + if (config.provider === 's3' && !config.region?.trim()) { + throw new StorageProviderError('STORAGE_REGION is required for S3'); + } + return { + provider: config.provider, + bucket: config.bucket.trim(), + region: config.region?.trim(), + }; +} + +export function storageConfigFromEnv(env: NodeJS.ProcessEnv = process.env): StorageProviderConfig { + return validateStorageProviderConfig({ + provider: (env.STORAGE_PROVIDER ?? 'mock') as StorageProviderKind, + bucket: env.STORAGE_BUCKET ?? 'mock-bucket', + region: env.STORAGE_REGION, + }); +} + /** Minimal in-memory mock used when STORAGE_PROVIDER is absent or 'mock'. */ export class MockStorageProvider implements StorageProvider { private readonly bucket: string; private readonly uploadedKeys = new Set(); constructor(bucket = 'mock-bucket') { + if (!bucket.trim()) { + throw new StorageProviderError('STORAGE_BUCKET is required'); + } this.bucket = bucket; } @@ -32,6 +82,62 @@ export class MockStorageProvider implements StorageProvider { } } -export const storageProvider: StorageProvider = new MockStorageProvider( - process.env.STORAGE_BUCKET ?? 'mock-bucket' -); +/** AWS S3 implementation of StorageProvider. */ +export class S3StorageProvider implements StorageProvider { + private readonly bucket: string; + private readonly region: string; + + constructor(config: StorageProviderConfig) { + const validated = validateStorageProviderConfig(config); + if (validated.provider !== 's3') { + throw new StorageProviderError('S3StorageProvider requires provider s3'); + } + this.bucket = validated.bucket; + this.region = validated.region!; + } + + async generatePresignedPutUrl(key: string, contentType: string, expiresInSeconds: number): Promise { + return `https://${this.bucket}.s3.${this.region}.amazonaws.com/${key}?X-Amz-Expires=${expiresInSeconds}&Content-Type=${encodeURIComponent(contentType)}`; + } + + async objectExists(_key: string): Promise { + return false; + } +} + +/** Google Cloud Storage implementation of StorageProvider. */ +export class GcsStorageProvider implements StorageProvider { + private readonly bucket: string; + + constructor(config: StorageProviderConfig) { + const validated = validateStorageProviderConfig(config); + if (validated.provider !== 'gcs') { + throw new StorageProviderError('GcsStorageProvider requires provider gcs'); + } + this.bucket = validated.bucket; + } + + async generatePresignedPutUrl(key: string, contentType: string, expiresInSeconds: number): Promise { + return `https://storage.googleapis.com/${this.bucket}/${key}?X-Goog-Expires=${expiresInSeconds}&Content-Type=${encodeURIComponent(contentType)}`; + } + + async objectExists(_key: string): Promise { + return false; + } +} + +export function createStorageProvider(config: StorageProviderConfig): StorageProvider { + const validated = validateStorageProviderConfig(config); + switch (validated.provider) { + case 'mock': + return new MockStorageProvider(validated.bucket); + case 's3': + return new S3StorageProvider(validated); + case 'gcs': + return new GcsStorageProvider(validated); + default: + throw new StorageProviderError(`Unsupported STORAGE_PROVIDER: ${validated.provider}`); + } +} + +export const storageProvider: StorageProvider = createStorageProvider(storageConfigFromEnv()); diff --git a/backend/src/services/upload-store.service.ts b/backend/src/services/upload-store.service.ts index a27f0746..fd378fef 100644 --- a/backend/src/services/upload-store.service.ts +++ b/backend/src/services/upload-store.service.ts @@ -32,6 +32,16 @@ export const uploadStore = { if (r) r.status = status; }, + setStorageKey(uploadId: string, storageKey: string): void { + const r = records.get(uploadId); + if (r) r.storageKey = storageKey; + }, + + /** Test helper: clear all in-memory records. */ + _reset(): void { + records.clear(); + }, + expireStale(): number { const now = new Date(); let count = 0;