diff --git a/backend/src/api/controllers/sep12.controller.test.ts b/backend/src/api/controllers/sep12.controller.test.ts index e5dbeb4..842c591 100644 --- a/backend/src/api/controllers/sep12.controller.test.ts +++ b/backend/src/api/controllers/sep12.controller.test.ts @@ -34,6 +34,33 @@ const cryptoMock = { decrypt: jest.fn((v: string) => v), }; +const storageProviderMock = { + generatePresignedPutUrl: jest.fn().mockResolvedValue('https://mock-bucket.mock.storage/kyc/test/field1/uuid?X-Mock-Signed=1'), + objectExists: jest.fn().mockResolvedValue(true), +}; + +const uploadStoreMock = { + create: jest.fn(() => ({ + uploadId: 'test-uuid', + account: VALID_ACCOUNT, + fieldName: 'id_photo_front', + storageKey: '', + contentType: 'image/jpeg', + expiresAt: new Date(Date.now() + 900 * 1000), + status: 'PENDING', + })), + get: jest.fn(() => ({ + uploadId: 'test-uuid', + account: VALID_ACCOUNT, + fieldName: 'id_photo_front', + storageKey: 'kyc/test/uuid/id_photo_front', + contentType: 'image/jpeg', + expiresAt: new Date(Date.now() + 900 * 1000), + status: 'PENDING', + })), + setStatus: jest.fn(), +}; + jest.mock('../../lib/prisma', () => ({ __esModule: true, default: prismaMock, @@ -54,6 +81,23 @@ jest.mock('../../services/crypto.service', () => ({ cryptoService: cryptoMock, })); +jest.mock('../../services/storage-provider.service', () => ({ + __esModule: true, + storageProvider: storageProviderMock, +})); + +jest.mock('../../services/upload-store.service', () => ({ + __esModule: true, + uploadStore: uploadStoreMock, +})); + +jest.mock('../../config/env', () => ({ + __esModule: true, + config: { + SEP12_MAX_FILE_SIZE_MB: 10, + }, +})); + jest.mock('../../utils/logger', () => ({ __esModule: true, default: { @@ -85,6 +129,175 @@ describe('Sep12Controller', () => { jest.clearAllMocks(); }); + describe('getUploadUrl', () => { + it('returns pre-signed URL when all parameters are valid', async () => { + const req = { + body: { + account: VALID_ACCOUNT, + field_name: 'id_photo_front', + content_type: 'image/jpeg', + file_size: '1000000', + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + upload_id: 'test-uuid', + url: expect.any(String), + expires_at: expect.any(String), + })); + expect(storageProviderMock.generatePresignedPutUrl).toHaveBeenCalled(); + expect(uploadStoreMock.create).toHaveBeenCalled(); + expect(uploadStoreMock.setStatus).toHaveBeenCalledWith('test-uuid', 'PENDING'); + }); + + it('returns 400 when required parameters are missing', async () => { + const req = { + body: { + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'account, field_name, content_type, and file_size are required' }); + }); + + it('returns 400 when content type is invalid', async () => { + const req = { + body: { + account: VALID_ACCOUNT, + field_name: 'id_photo_front', + content_type: 'application/zip', + file_size: '1000000', + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 400 when file size is larger than max allowed', async () => { + const req = { + body: { + account: VALID_ACCOUNT, + field_name: 'id_photo_front', + content_type: 'image/jpeg', + file_size: '110000000', + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.getUploadUrl(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + }); + + describe('confirmUpload', () => { + it('confirms upload when upload exists and file is present in storage', async () => { + const req = { + body: { + upload_id: 'test-uuid', + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + upload_id: 'test-uuid', + status: 'COMPLETED', + }); + expect(storageProviderMock.objectExists).toHaveBeenCalled(); + expect(uploadStoreMock.setStatus).toHaveBeenCalledWith('test-uuid', 'COMPLETED'); + }); + + it('returns 400 when required parameters are missing', async () => { + const req = { + body: { + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('returns 404 when upload record not found', async () => { + uploadStoreMock.get.mockResolvedValueOnce(undefined); + const req = { + body: { + upload_id: 'invalid-uuid', + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(404); + }); + + it('returns 403 when account does not match upload record', async () => { + uploadStoreMock.get.mockResolvedValueOnce({ + uploadId: 'test-uuid', + account: 'GBZXN7PIRZGNMHGA7MUUUF4GW3F55GQRQ5UKMJTDEFEKTGW4RHFDQLNZ', + fieldName: 'id_photo_front', + storageKey: 'kyc/test/uuid/id_photo_front', + contentType: 'image/jpeg', + expiresAt: new Date(Date.now() + 900 * 1000), + status: 'PENDING', + }); + const req = { + body: { + upload_id: 'test-uuid', + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + it('returns 422 when file not found in storage', async () => { + storageProviderMock.objectExists.mockResolvedValueOnce(false); + const req = { + body: { + upload_id: 'test-uuid', + account: VALID_ACCOUNT, + }, + user: { publicKey: VALID_ACCOUNT }, + } as unknown as Request; + const res = makeRes(); + + await sep12Controller.confirmUpload(req, res); + + expect(res.status).toHaveBeenCalledWith(422); + }); + }); + describe('putCustomer', () => { it('returns 400 when account is missing', async () => { const req = { diff --git a/dashboard/src/components/AdminControls.tsx b/dashboard/src/components/AdminControls.tsx index 09957f2..7816eb2 100644 --- a/dashboard/src/components/AdminControls.tsx +++ b/dashboard/src/components/AdminControls.tsx @@ -1,15 +1,31 @@ import React, { useState, useEffect } from 'react'; -import { Network, Trash2, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Network, Trash2, CheckCircle2, AlertCircle, Bell, Key, Save } from 'lucide-react'; import { ConfirmModal } from './ConfirmModal'; interface AdminControlsProps { apiBaseUrl: string; } +interface WebhookConfig { + WEBHOOK_URL?: string; + WEBHOOK_SECRET?: string; + WEBHOOK_TIMEOUT_MS: number; + WEBHOOK_MAX_RETRIES: number; + WEBHOOK_RETRY_DELAY_MS: number; +} + export const AdminControls: React.FC = ({ apiBaseUrl }) => { const [network, setNetwork] = useState('TESTNET'); const [loading, setLoading] = useState(false); const [statusMessage, setStatusMessage] = useState<{ text: string; isError: boolean } | null>(null); + + // Webhook config state + const [webhookConfig, setWebhookConfig] = useState({ + WEBHOOK_TIMEOUT_MS: 5000, + WEBHOOK_MAX_RETRIES: 3, + WEBHOOK_RETRY_DELAY_MS: 500 + }); + const [isWebhookLoading, setIsWebhookLoading] = useState(false); // Modal States const [isNetworkModalOpen, setIsNetworkModalOpen] = useState(false); @@ -18,6 +34,7 @@ export const AdminControls: React.FC = ({ apiBaseUrl }) => { useEffect(() => { fetchCurrentNetwork(); + fetchWebhookConfig(); }, []); const fetchCurrentNetwork = async () => { @@ -34,6 +51,73 @@ export const AdminControls: React.FC = ({ apiBaseUrl }) => { } }; + const fetchWebhookConfig = async () => { + try { + setIsWebhookLoading(true); + const token = localStorage.getItem('authToken'); + const response = await fetch(`${apiBaseUrl}/api/config`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + if (response.ok) { + const data = await response.json(); + if (data.data) { + setWebhookConfig({ + WEBHOOK_URL: data.data.WEBHOOK_URL, + WEBHOOK_SECRET: data.data.WEBHOOK_SECRET, + WEBHOOK_TIMEOUT_MS: data.data.WEBHOOK_TIMEOUT_MS, + WEBHOOK_MAX_RETRIES: data.data.WEBHOOK_MAX_RETRIES, + WEBHOOK_RETRY_DELAY_MS: data.data.WEBHOOK_RETRY_DELAY_MS + }); + } + } + } catch (err) { + console.error('Failed to fetch webhook config:', err); + } finally { + setIsWebhookLoading(false); + } + }; + + const saveWebhookConfig = async () => { + try { + setLoading(true); + const token = localStorage.getItem('authToken'); + // First fetch full current config + const configRes = await fetch(`${apiBaseUrl}/api/config`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + if (!configRes.ok) throw new Error('Failed to fetch current config'); + + const fullConfig = await configRes.json(); + const newConfig = { + ...fullConfig.data, + ...webhookConfig + }; + + const saveRes = await fetch(`${apiBaseUrl}/api/config`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(newConfig) + }); + + if (!saveRes.ok) throw new Error('Failed to save webhook config'); + + showStatus('Webhook configuration saved successfully!', false); + } catch (err) { + showStatus(err instanceof Error ? err.message : 'Failed to save webhook configuration', true); + } finally { + setLoading(false); + } + }; + const handleNetworkChangeInitiate = (e: React.ChangeEvent) => { const value = e.target.value; if (value && value !== network) { @@ -179,6 +263,113 @@ export const AdminControls: React.FC = ({ apiBaseUrl }) => { Purge Queues + + {/* Webhook Settings */} +
+
+
+

+ + Webhook Configuration +

+

+ Configure webhook endpoints and settings for transaction and KYC event notifications. +

+
+
+ +
+
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_URL: e.target.value })} + placeholder="https://example.com/webhook" + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_SECRET: e.target.value })} + placeholder="Your secret key" + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_TIMEOUT_MS: parseInt(e.target.value, 10) })} + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_MAX_RETRIES: parseInt(e.target.value, 10) })} + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+ +
+ + setWebhookConfig({ ...webhookConfig, WEBHOOK_RETRY_DELAY_MS: parseInt(e.target.value, 10) })} + disabled={isWebhookLoading || loading} + className="input-field w-full" + /> +
+
+ + +
+
{/* Network Switch Confirmation Modal */}