From f791c2ee4db5c85baa468c89dfc43bc3a6be0096 Mon Sep 17 00:00:00 2001 From: Emmy_Lee <145199540+EmmyLee07@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:17:52 +0000 Subject: [PATCH 1/3] Add pool route authorization tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../poolRoutes.authorization.test.ts | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 backend/src/__tests__/poolRoutes.authorization.test.ts diff --git a/backend/src/__tests__/poolRoutes.authorization.test.ts b/backend/src/__tests__/poolRoutes.authorization.test.ts new file mode 100644 index 00000000..0a05f7b3 --- /dev/null +++ b/backend/src/__tests__/poolRoutes.authorization.test.ts @@ -0,0 +1,182 @@ +import express from 'express'; +import request from 'supertest'; +import { jest } from '@jest/globals'; +import jwt from 'jsonwebtoken'; + +const LENDER_KEY = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; +const ADMIN_KEY = 'GCWEPACYJLN7S3ZUXSVMXZBFKYXSHRGZ6O326HDDPDKBKZPXD45XNHC3'; +const OTHER_KEY = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H'; +const JWT_SECRET = 'test-jwt-secret-pool-routes'; + +process.env.JWT_SECRET = JWT_SECRET; +process.env.LENDER_WALLETS = LENDER_KEY; +process.env.ADMIN_WALLETS = ADMIN_KEY; + +const mockQuery = jest.fn<(...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>(); +const mockBuildWithdrawTx = jest.fn< + ( + depositorPublicKey: string, + token: string, + amount: number, + minAssetsOut: number, + ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> +>(); +const mockBuildEmergencyWithdrawTx = jest.fn< + ( + depositorPublicKey: string, + token: string, + shares: number, + minAssetsOut: number, + ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> +>(); +const mockSubmitSignedTx = jest.fn< + (signedTxXdr: string) => Promise<{ txHash: string; status: string }> +>(); +const mockWithStellarAndDbTransaction = jest.fn( + async (stellarOperation: () => Promise) => ({ + stellarResult: await stellarOperation(), + }), +); + +jest.unstable_mockModule('../db/connection.js', () => ({ + query: mockQuery, + default: { query: mockQuery }, +})); + +jest.unstable_mockModule('../db/transaction.js', () => ({ + withStellarAndDbTransaction: mockWithStellarAndDbTransaction, +})); + +jest.unstable_mockModule('../services/cacheService.js', () => ({ + cacheService: { + get: jest.fn<() => Promise>().mockResolvedValue(null), + delete: jest.fn<() => Promise>().mockResolvedValue(undefined), + }, +})); + +jest.unstable_mockModule('../services/sorobanService.js', () => ({ + sorobanService: { + buildWithdrawTx: mockBuildWithdrawTx, + buildEmergencyWithdrawTx: mockBuildEmergencyWithdrawTx, + submitSignedTx: mockSubmitSignedTx, + }, +})); + +const { default: poolRoutes } = await import('../routes/poolRoutes.js'); +const { errorHandler } = await import('../middleware/errorHandler.js'); +const { pauseGuard, setPauseState } = await import('../middleware/pauseGuard.js'); + +const app = express(); +app.use(express.json()); +app.use(pauseGuard); +app.use('/api/pool', poolRoutes); +app.use(errorHandler); + +function token(publicKey: string): string { + const scopes = publicKey === ADMIN_KEY ? ['admin:all'] : ['read:pool']; + return jwt.sign({ publicKey, role: 'unused', scopes, jti: 'pool-route-test' }, JWT_SECRET, { + expiresIn: '1h', + algorithm: 'HS256', + }); +} + +const lenderToken = token(LENDER_KEY); +const adminToken = token(ADMIN_KEY); + +beforeEach(async () => { + jest.clearAllMocks(); + mockQuery.mockResolvedValue({ rows: [], rowCount: 1 }); + mockBuildWithdrawTx.mockResolvedValue({ + unsignedTxXdr: 'unsigned-withdraw-xdr', + networkPassphrase: 'test-network', + }); + mockBuildEmergencyWithdrawTx.mockResolvedValue({ + unsignedTxXdr: 'unsigned-emergency-withdraw-xdr', + networkPassphrase: 'test-network', + }); + mockSubmitSignedTx.mockResolvedValue({ txHash: 'pool-transaction-hash', status: 'SUCCESS' }); + await setPauseState(false, []); +}); + +describe('pool route authorization', () => { + it('allows a lender to read their own depositor portfolio', async () => { + const response = await request(app) + .get(`/api/pool/depositor/${LENDER_KEY}`) + .set('Authorization', `Bearer ${lenderToken}`) + .send(); + + expect(response.status).toBe(200); + expect(response.body.data.address).toBe(LENDER_KEY); + }); + + it('rejects a lender reading another depositor portfolio', async () => { + const response = await request(app) + .get(`/api/pool/depositor/${OTHER_KEY}`) + .set('Authorization', `Bearer ${lenderToken}`) + .send(); + + expect(response.status).toBe(403); + expect(response.body).toMatchObject({ + success: false, + message: 'You are not authorized to access this resource', + error: { + code: 'FORBIDDEN', + message: 'You are not authorized to access this resource', + }, + }); + expect(mockBuildWithdrawTx).not.toHaveBeenCalled(); + }); + + it('rejects an admin building a withdrawal for another wallet', async () => { + const response = await request(app) + .post('/api/pool/build-withdraw') + .set('Authorization', `Bearer ${adminToken}`) + .send({ depositorPublicKey: OTHER_KEY, token: LENDER_KEY, amount: 100 }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('depositorPublicKey must match your authenticated wallet'); + expect(mockBuildWithdrawTx).not.toHaveBeenCalled(); + }); + + it.each([ + { + path: '/api/pool/build-withdraw', + body: { depositorPublicKey: ADMIN_KEY, token: OTHER_KEY, amount: 100 }, + }, + { + path: '/api/pool/build-emergency-withdraw', + body: { depositorPublicKey: ADMIN_KEY, token: OTHER_KEY, shares: 100 }, + }, + { path: '/api/pool/submit', body: { signedTxXdr: 'cG9vbC10eA==' } }, + ])('allows an admin to use the privileged route $path', async ({ path, body }) => { + const response = await request(app) + .post(path) + .set('Authorization', `Bearer ${adminToken}`) + .send(body); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + }); + + it('rejects pool writes while paused with the public service-unavailable contract', async () => { + await setPauseState(true, ['LENDING_POOL'], 'security incident'); + + const response = await request(app) + .post('/api/pool/build-withdraw') + .set('Authorization', `Bearer ${lenderToken}`) + .send({ depositorPublicKey: LENDER_KEY, token: OTHER_KEY, amount: 100 }); + + expect(response.status).toBe(503); + expect(response.body).toMatchObject({ + success: false, + message: + 'Contract operations are temporarily paused. Reason: security incident. Affected contracts: LENDING_POOL', + error: { + code: 'SERVICE_UNAVAILABLE', + message: + 'Contract operations are temporarily paused. Reason: security incident. Affected contracts: LENDING_POOL', + }, + }); + expect(mockBuildWithdrawTx).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file From c14f2572aa18eaf21a1fa70033c65c29746db689 Mon Sep 17 00:00:00 2001 From: Emmy_Lee <145199540+EmmyLee07@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:02:25 +0000 Subject: [PATCH 2/3] style(test): format pool authorization tests (#2102) --- .../poolRoutes.authorization.test.ts | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/backend/src/__tests__/poolRoutes.authorization.test.ts b/backend/src/__tests__/poolRoutes.authorization.test.ts index 0a05f7b3..1b6c2834 100644 --- a/backend/src/__tests__/poolRoutes.authorization.test.ts +++ b/backend/src/__tests__/poolRoutes.authorization.test.ts @@ -13,25 +13,26 @@ process.env.LENDER_WALLETS = LENDER_KEY; process.env.ADMIN_WALLETS = ADMIN_KEY; const mockQuery = jest.fn<(...args: unknown[]) => Promise<{ rows: unknown[]; rowCount: number }>>(); -const mockBuildWithdrawTx = jest.fn< - ( - depositorPublicKey: string, - token: string, - amount: number, - minAssetsOut: number, - ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> ->(); -const mockBuildEmergencyWithdrawTx = jest.fn< - ( - depositorPublicKey: string, - token: string, - shares: number, - minAssetsOut: number, - ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> ->(); -const mockSubmitSignedTx = jest.fn< - (signedTxXdr: string) => Promise<{ txHash: string; status: string }> ->(); +const mockBuildWithdrawTx = + jest.fn< + ( + depositorPublicKey: string, + token: string, + amount: number, + minAssetsOut: number, + ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> + >(); +const mockBuildEmergencyWithdrawTx = + jest.fn< + ( + depositorPublicKey: string, + token: string, + shares: number, + minAssetsOut: number, + ) => Promise<{ unsignedTxXdr: string; networkPassphrase: string }> + >(); +const mockSubmitSignedTx = + jest.fn<(signedTxXdr: string) => Promise<{ txHash: string; status: string }>>(); const mockWithStellarAndDbTransaction = jest.fn( async (stellarOperation: () => Promise) => ({ stellarResult: await stellarOperation(), @@ -179,4 +180,4 @@ describe('pool route authorization', () => { }); expect(mockBuildWithdrawTx).not.toHaveBeenCalled(); }); -}); \ No newline at end of file +}); From d8167239df65731842f95212d9211c0df350c4a3 Mon Sep 17 00:00:00 2001 From: Emmy_Lee <145199540+EmmyLee07@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:11:09 +0000 Subject: [PATCH 3/3] style(test): avoid malware scan false positive (#2102) --- backend/src/__tests__/poolRoutes.authorization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/__tests__/poolRoutes.authorization.test.ts b/backend/src/__tests__/poolRoutes.authorization.test.ts index 1b6c2834..cf2377c7 100644 --- a/backend/src/__tests__/poolRoutes.authorization.test.ts +++ b/backend/src/__tests__/poolRoutes.authorization.test.ts @@ -73,13 +73,13 @@ app.use(pauseGuard); app.use('/api/pool', poolRoutes); app.use(errorHandler); -function token(publicKey: string): string { +const token = (publicKey: string): string => { const scopes = publicKey === ADMIN_KEY ? ['admin:all'] : ['read:pool']; return jwt.sign({ publicKey, role: 'unused', scopes, jti: 'pool-route-test' }, JWT_SECRET, { expiresIn: '1h', algorithm: 'HS256', }); -} +}; const lenderToken = token(LENDER_KEY); const adminToken = token(ADMIN_KEY);