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 Untitled
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
git config user.email "tawfiqamuhammad@gmail.com"
199 changes: 199 additions & 0 deletions backend/src/api/controllers/sep12.controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> = {};
Expand All @@ -83,6 +84,7 @@ const makeRes = (): Response => {
describe('Sep12Controller', () => {
beforeEach(() => {
jest.clearAllMocks();
uploadStore._reset();
});

describe('putCustomer', () => {
Expand Down Expand Up @@ -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' },
Expand Down
71 changes: 57 additions & 14 deletions backend/src/api/controllers/sep12.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>; kycFields: Record<string, string> }
| { 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<string, string>,
uploadedFiles?: UploadedFiles
): DocumentResolution {
const kycFields: Record<string, string> = {};
const documents: Record<string, string> = {};

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) {
Expand Down Expand Up @@ -81,14 +126,13 @@ export class Sep12Controller {
}

const uploadedFiles = (req as AuthRequest & { files?: UploadedFiles }).files;
const documents: Record<string, string> = {};
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<string, unknown> = { ...otherFields };
const extraPayload: Record<string, unknown> = { ...kycFields };
if (Object.keys(documents).length > 0) {
extraPayload.documents = documents;
}
Expand Down Expand Up @@ -117,7 +161,7 @@ export class Sep12Controller {
firstName: first_name,
lastName: last_name,
email: email_address,
extraFields: otherFields,
extraFields: kycFields,
};

let providerStatus = KycStatus.PENDING;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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' });
}
Expand Down
Loading