-
Notifications
You must be signed in to change notification settings - Fork 30
feat: expose wallet list API endpoint with authentication and tests #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import express from 'express'; | ||
| import type { AddressInfo } from 'node:net'; | ||
| import type { Response } from 'express'; | ||
|
|
||
| import { requireAdminApi } from '../appMiddlewares/jwtAuth.api'; | ||
| import { listWallets } from '../components/v1/wallet/wallet.controller'; | ||
| import walletRoutes from '../components/v1/wallet/wallet.routes'; | ||
| import walletRepository from '../components/v1/wallet/wallet.services'; | ||
| import type { IRequest } from '../types/global'; | ||
|
|
||
| type MockResponse = { | ||
| statusCode: number; | ||
| jsonData: any; | ||
| status: (code: number) => MockResponse; | ||
| json: (data: any) => MockResponse; | ||
| }; | ||
|
|
||
| const createMockResponse = (): MockResponse => { | ||
| const response: MockResponse = { | ||
| statusCode: 200, | ||
| jsonData: null, | ||
| status(code: number) { | ||
| this.statusCode = code; | ||
| return this; | ||
| }, | ||
| json(data: any) { | ||
| this.jsonData = data; | ||
| return this; | ||
| }, | ||
| }; | ||
| return response; | ||
| }; | ||
|
|
||
| test('listWallets returns wallet data when the repository succeeds', async () => { | ||
| const mockWallets = [ | ||
| { | ||
| id: 'wallet-1', | ||
| address: '0x123abc', | ||
| network: 'ETHEREUM', | ||
| chainId: '1', | ||
| chainName: 'Ethereum', | ||
| balance: '100.5', | ||
| createdAt: new Date(), | ||
| updatedAt: new Date(), | ||
| }, | ||
| ]; | ||
|
|
||
| const res = createMockResponse(); | ||
| const req = { auth: { userId: 'user-123' } } as IRequest; | ||
| const originalFind = walletRepository.find.bind(walletRepository); | ||
|
|
||
| try { | ||
| ( | ||
| walletRepository as unknown as { | ||
| find: typeof walletRepository.find; | ||
| } | ||
| ).find = async () => mockWallets as any; | ||
|
|
||
| await listWallets(req, res as unknown as Response); | ||
|
|
||
| assert.equal(res.statusCode, 200); | ||
| assert.equal(res.jsonData.success, true); | ||
| assert.deepEqual(res.jsonData.data, mockWallets); | ||
| } finally { | ||
| ( | ||
| walletRepository as unknown as { | ||
| find: typeof walletRepository.find; | ||
| } | ||
| ).find = originalFind; | ||
| } | ||
| }); | ||
|
Comment on lines
+50
to
+73
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Inspect hidden setup (lines 37-49) and any AppDataSource initialization in the test
fd -t f 'wallet.controller.test.ts' --exec sed -n '30,60p' {}
rg -nP 'isInitialized|AppDataSource' src/__tests__/wallet.controller.test.tsRepository: Fundable-Protocol/Backend Length of output: 977 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Show the relevant controller and test sections with line numbers.
controller_file=$(fd -t f 'wallet.controller.ts' src | head -n 1)
test_file='src/__tests__/wallet.controller.test.ts'
printf '\n== %s ==\n' "$controller_file"
sed -n '1,220p' "$controller_file" | cat -n
printf '\n== %s (30-160) ==\n' "$test_file"
sed -n '30,160p' "$test_file" | cat -n
printf '\n== occurrences of AppDataSource / isInitialized in repo ==\n'
rg -n 'AppDataSource|isInitialized' src --glob '!**/dist/**' --glob '!**/build/**'Repository: Fundable-Protocol/Backend Length of output: 8758 Stub 🤖 Prompt for AI Agents |
||
|
|
||
| test('wallet routes reject unauthenticated requests', async () => { | ||
| const app = express(); | ||
| app.use('/wallets', walletRoutes); | ||
|
|
||
| const server = app.listen(0); | ||
| await new Promise<void>((resolve) => | ||
| server.once('listening', () => resolve()) | ||
| ); | ||
|
|
||
| try { | ||
| const address = server.address() as AddressInfo; | ||
| const response = await fetch( | ||
| `http://127.0.0.1:${address.port}/wallets` | ||
| ); | ||
| const payload = await response.json(); | ||
|
|
||
| assert.equal(response.status, 401); | ||
| assert.equal(payload.success, false); | ||
| assert.equal(payload.error.code, 'AUTH_MISSING_TOKEN'); | ||
| } finally { | ||
| server.close(); | ||
| } | ||
| }); | ||
|
|
||
| test('requireAdminApi rejects non-admin requests', async () => { | ||
| const res = createMockResponse(); | ||
| const req = { | ||
| auth: { | ||
| userId: 'user-123', | ||
| claims: { role: 'user' }, | ||
| }, | ||
| } as IRequest; | ||
|
|
||
| const next = () => { | ||
| throw new Error('next should not be called'); | ||
| }; | ||
|
|
||
| const result = requireAdminApi(req, res as unknown as Response, next); | ||
|
|
||
| assert.equal(result, undefined); | ||
| assert.equal(res.statusCode, 403); | ||
| assert.equal(res.jsonData.success, false); | ||
| assert.equal(res.jsonData.error.code, 'FORBIDDEN'); | ||
| }); | ||
|
|
||
| test('listWallets returns DB_NOT_READY when the data source is not initialized', async () => { | ||
| const res = createMockResponse(); | ||
| const req = { auth: { userId: 'user-123' } } as IRequest; | ||
|
|
||
| await listWallets(req, res as unknown as Response); | ||
|
|
||
| assert.equal(res.statusCode, 500); | ||
| assert.equal(res.jsonData.success, false); | ||
| assert.equal(res.jsonData.error.code, 'DB_NOT_READY'); | ||
| assert.match(res.jsonData.error.message, /Database not initialized/); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import test from 'node:test'; | ||
| import assert from 'node:assert/strict'; | ||
| import type { Response } from 'express'; | ||
|
|
||
| import type { IRequest } from '../types/global'; | ||
| import { listWallets } from '../components/v1/wallet/wallet.controller'; | ||
|
|
||
| const createMockResponse = () => { | ||
| const response: Partial<Response> & { | ||
| statusCode: number; | ||
| jsonData: any; | ||
| status: (code: number) => any; | ||
| json: (data: any) => any; | ||
| } = { | ||
| statusCode: 200, | ||
| jsonData: null, | ||
| status(code: number) { | ||
| this.statusCode = code; | ||
| return this; | ||
| }, | ||
| json(data: any) { | ||
| this.jsonData = data; | ||
| return this; | ||
| }, | ||
| }; | ||
| return response; | ||
| }; | ||
|
|
||
| test('listWallets returns DB_NOT_READY when data source is not initialized', async () => { | ||
| const res = createMockResponse(); | ||
| const req = { auth: { userId: 'user-123' } } as IRequest; | ||
|
|
||
| await listWallets(req, res as Response); | ||
|
|
||
| assert.equal(res.statusCode, 500); | ||
| assert.equal(res.jsonData.success, false); | ||
| assert.equal(res.jsonData.error.code, 'DB_NOT_READY'); | ||
| assert.match(res.jsonData.error.message, /Database not initialized/); | ||
| }); | ||
|
|
||
| test('wallet routes are mounted on the API v1 router', async () => { | ||
| const router = await import('../components/v1/routes.api.v1'); | ||
| assert.ok(router.default); | ||
| }); | ||
|
|
||
| test('wallet route module exports a router', async () => { | ||
| const router = await import('../components/v1/wallet/wallet.routes'); | ||
| assert.ok(router.default); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,9 @@ | ||
| import EnhancedRouter from '../../utils/enhancedRouter'; | ||
|
|
||
| import distributionRoutes from "./distribution/distrubtion.routes" | ||
|
|
||
| import distributionRoutes from './distribution/distrubtion.routes'; | ||
|
|
||
| const routerV1 = new EnhancedRouter(); | ||
|
|
||
| routerV1.use("/distributions", distributionRoutes) | ||
| routerV1.use('/distributions', distributionRoutes); | ||
|
|
||
| export default routerV1.getRouter(); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,15 +1,27 @@ | ||||||||||||||||||
| import { Response } from 'express'; | ||||||||||||||||||
| import type { Response } from 'express'; | ||||||||||||||||||
|
|
||||||||||||||||||
| import AppDataSource from '../../../config/persistence/data-source'; | ||||||||||||||||||
| import { sendError, sendSuccess } from '../../../utils/apiResponse'; | ||||||||||||||||||
| import type { IRequest } from '../../../types/global'; | ||||||||||||||||||
| import walletRepository from './wallet.services'; | ||||||||||||||||||
| import { handleResponse } from '../../../utils/helper'; | ||||||||||||||||||
| import { IRequest } from '../../../types/global'; | ||||||||||||||||||
|
|
||||||||||||||||||
| export const listWallets = async (req: IRequest, res: Response) => { | ||||||||||||||||||
| const queryObject = {}; | ||||||||||||||||||
| export const listWallets = async (_req: IRequest, res: Response) => { | ||||||||||||||||||
| try { | ||||||||||||||||||
| if (!AppDataSource.isInitialized) { | ||||||||||||||||||
| return sendError(res, 500, { | ||||||||||||||||||
| code: 'DB_NOT_READY', | ||||||||||||||||||
| message: 'Database not initialized', | ||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
|
|
||||||||||||||||||
| const wallets = await walletRepository.findBy(queryObject); | ||||||||||||||||||
|
|
||||||||||||||||||
| return handleResponse(res, { | ||||||||||||||||||
| data: wallets, | ||||||||||||||||||
| }); | ||||||||||||||||||
| const wallets = await walletRepository.find(); | ||||||||||||||||||
| return sendSuccess(res, wallets); | ||||||||||||||||||
| } catch (error) { | ||||||||||||||||||
| const err = error as Error; | ||||||||||||||||||
| return sendError(res, 500, { | ||||||||||||||||||
| code: 'WALLET_LIST_FAILED', | ||||||||||||||||||
| message: 'Failed to retrieve wallets', | ||||||||||||||||||
| details: { reason: err.message }, | ||||||||||||||||||
|
Comment on lines
+21
to
+24
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Don't expose raw repository errors in the API payload. Line 16 returns Proposed fix } catch (error) {
- const err = error as Error;
return sendError(res, 500, {
code: 'WALLET_LIST_FAILED',
message: 'Failed to retrieve wallets',
- details: { reason: err.message },
+ details: {},
});
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| }); | ||||||||||||||||||
| } | ||||||||||||||||||
| }; | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,9 +1,14 @@ | ||
| import EnhancedRouter from '../../../utils/enhancedRouter'; | ||
| import { Router } from 'express'; | ||
|
|
||
| import { | ||
| requireAdminApi, | ||
| requireJwtAuthApi, | ||
| } from '../../../appMiddlewares/jwtAuth.api'; | ||
| import { listWallets } from './wallet.controller'; | ||
|
|
||
| const walletRouter = new EnhancedRouter(); | ||
| const router = Router(); | ||
|
|
||
| // walletRouter.get('/'); | ||
| // List all wallets (JWT + admin required) | ||
| router.get('/', requireJwtAuthApi, requireAdminApi, listWallets); | ||
|
|
||
| export default walletRouter.getRouter(); | ||
| export default router; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Document both 401 error variants.
The JWT middleware returns
AUTH_INVALID_TOKEN/ "Invalid authentication token" for malformed or failed verification, not justAUTH_MISSING_TOKEN. Update the 401 documentation to cover both missing and invalid token cases, or generalize the description to match the middleware behavior.As per the upstream contract in
src/appMiddlewares/jwtAuth.api.ts, the 401 response can have eitherAUTH_MISSING_TOKENorAUTH_INVALID_TOKENdepending on the failure mode.🤖 Prompt for AI Agents