Skip to content

Commit c1d3f05

Browse files
Merge pull request #173 from EDOHWARES/chore/stellar-address-validation
add stellar addr val
2 parents 82d57a9 + 6c64baf commit c1d3f05

30 files changed

Lines changed: 9753 additions & 105 deletions

src/__test__/adminAuth.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import express from "express";
22
import request from "supertest";
33
import { adminAuth, ADMIN_KEY_HEADER } from "../middleware/adminAuth.js";
4-
import { afterEach, beforeEach } from "node:test";
4+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
55

66
const SECRET = "test-admin-secret-key";
77

src/__tests__/index.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,11 @@ describe('Main Application', () => {
2424
.expect(200);
2525

2626
expect(response.body).toEqual({
27-
status: 'ok',
28-
service: 'creditra-backend'
27+
data: {
28+
status: 'ok',
29+
service: 'creditra-backend'
30+
},
31+
error: null
2932
});
3033

3134
// Restore original PORT
@@ -43,17 +46,17 @@ describe('Main Application', () => {
4346
.get('/api/credit/lines')
4447
.expect(200);
4548

46-
expect(response.body.creditLines).toBeDefined();
49+
expect(response.body.data.creditLines).toBeDefined();
4750
});
4851

4952
it('should handle risk routes', async () => {
5053
const { default: app } = await import('../index.js');
5154

5255
const response = await request(app)
5356
.post('/api/risk/evaluate')
54-
.send({ walletAddress: 'test-wallet' })
57+
.send({ walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1' })
5558
.expect(200);
5659

57-
expect(response.body.walletAddress).toBe('test-wallet');
60+
expect(response.body.data.walletAddress).toBe('GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S1');
5861
});
5962
});

src/__tests__/risk.test.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,33 +14,44 @@ afterAll(() => {
1414
});
1515

1616
describe('POST /api/risk/evaluate (public)', () => {
17-
it('returns 200 with a valid walletAddress', async () => {
17+
it('returns 200 with a valid Stellar walletAddress', async () => {
18+
const validAddress = 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S2';
1819
const res = await request(app)
1920
.post('/api/risk/evaluate')
20-
.send({ walletAddress: '0xDeAdBeEf' });
21+
.send({ walletAddress: validAddress });
2122
expect(res.status).toBe(200);
22-
expect(res.body.walletAddress).toBe('0xDeAdBeEf');
23+
expect(res.body.walletAddress).toBe(validAddress);
2324
expect(res.body).toHaveProperty('riskScore');
2425
expect(res.body).toHaveProperty('creditLimit');
2526
expect(res.body).toHaveProperty('interestRateBps');
2627
});
2728

29+
it('returns 400 with an invalid Stellar walletAddress', async () => {
30+
const res = await request(app)
31+
.post('/api/risk/evaluate')
32+
.send({ walletAddress: 'invalid-address' });
33+
expect(res.status).toBe(400);
34+
expect(res.body.error).toBe('Validation failed');
35+
expect(res.body.details[0].message).toBe('Invalid Stellar wallet address');
36+
});
37+
2838
it('returns 400 when walletAddress is missing', async () => {
2939
const res = await request(app).post('/api/risk/evaluate').send({});
3040
expect(res.status).toBe(400);
31-
expect(res.body).toEqual({ error: 'walletAddress required' });
41+
expect(res.body.error).toBe('Validation failed');
42+
expect(res.body.details[0].message).toBe('walletAddress is required');
3243
});
3344

3445
it('returns 400 when body is empty', async () => {
35-
const res = await request(app).post('/api/risk/evaluate');
46+
const res = await request(app).post('/api/risk/evaluate').send({});
3647
expect(res.status).toBe(400);
37-
expect(res.body).toEqual({ error: 'walletAddress required' });
48+
expect(res.body.error).toBe('Validation failed');
3849
});
3950

4051
it('does not require an API key', async () => {
4152
const res = await request(app)
4253
.post('/api/risk/evaluate')
43-
.send({ walletAddress: '0x1234' });
54+
.send({ walletAddress: 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S2' });
4455
expect(res.status).toBe(200);
4556
});
4657
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, it, expect, beforeAll } from 'vitest';
2+
import request from 'supertest';
3+
import { app } from '../index.js';
4+
5+
const VALID_ADDRESS = 'GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S2';
6+
const INVALID_ADDRESS = 'invalid-stellar-address';
7+
8+
describe('Stellar Address Validation', () => {
9+
beforeAll(() => {
10+
process.env.NODE_ENV = 'test';
11+
});
12+
13+
describe('POST /api/risk/evaluate', () => {
14+
it('should accept a valid Stellar address', async () => {
15+
const res = await request(app)
16+
.post('/api/risk/evaluate')
17+
.send({ walletAddress: VALID_ADDRESS });
18+
expect(res.status).toBe(200);
19+
});
20+
21+
it('should reject an invalid Stellar address', async () => {
22+
const res = await request(app)
23+
.post('/api/risk/evaluate')
24+
.send({ walletAddress: INVALID_ADDRESS });
25+
expect(res.status).toBe(400);
26+
expect(res.body.error).toBe('Validation failed');
27+
expect(res.body.details[0].message).toBe('Invalid Stellar wallet address');
28+
});
29+
});
30+
31+
describe('POST /api/credit/lines', () => {
32+
it('should accept a valid Stellar address', async () => {
33+
const res = await request(app)
34+
.post('/api/credit/lines')
35+
.send({ walletAddress: VALID_ADDRESS, requestedLimit: '1000' });
36+
expect(res.status).toBe(201);
37+
});
38+
39+
it('should reject an invalid Stellar address', async () => {
40+
const res = await request(app)
41+
.post('/api/credit/lines')
42+
.send({ walletAddress: INVALID_ADDRESS, requestedLimit: '1000' });
43+
expect(res.status).toBe(400);
44+
expect(res.body.error).toBe('Validation failed');
45+
});
46+
});
47+
48+
describe('GET /api/credit/wallet/:walletAddress/lines', () => {
49+
it('should accept a valid Stellar address', async () => {
50+
const res = await request(app).get(`/api/credit/wallet/${VALID_ADDRESS}/lines`);
51+
expect(res.status).toBe(200);
52+
});
53+
54+
it('should reject an invalid Stellar address', async () => {
55+
const res = await request(app).get(`/api/credit/wallet/${INVALID_ADDRESS}/lines`);
56+
expect(res.status).toBe(400);
57+
expect(res.body.error).toBe('Validation failed');
58+
});
59+
});
60+
61+
describe('GET /api/risk/wallet/:walletAddress/latest', () => {
62+
it('should accept a valid Stellar address', async () => {
63+
const res = await request(app).get(`/api/risk/wallet/${VALID_ADDRESS}/latest`);
64+
// It might return 404 if not found, but it should pass validation
65+
expect([200, 404]).toContain(res.status);
66+
});
67+
68+
it('should reject an invalid Stellar address', async () => {
69+
const res = await request(app).get(`/api/risk/wallet/${INVALID_ADDRESS}/latest`);
70+
expect(res.status).toBe(400);
71+
expect(res.body.error).toBe('Validation failed');
72+
});
73+
});
74+
75+
describe('GET /api/risk/wallet/:walletAddress/history', () => {
76+
it('should accept a valid Stellar address', async () => {
77+
const res = await request(app).get(`/api/risk/wallet/${VALID_ADDRESS}/history`);
78+
expect(res.status).toBe(200);
79+
});
80+
81+
it('should reject an invalid Stellar address', async () => {
82+
const res = await request(app).get(`/api/risk/wallet/${INVALID_ADDRESS}/history`);
83+
expect(res.status).toBe(400);
84+
expect(res.body.error).toBe('Validation failed');
85+
});
86+
});
87+
});

src/middleware/validate.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,25 @@ export function validateQuery<T>(schema: z.ZodType<T>) {
6161
next();
6262
};
6363
}
64+
65+
/**
66+
* Express middleware factory that validates `req.params` against a Zod schema.
67+
*/
68+
export function validateParams<T>(schema: z.ZodType<T>) {
69+
return (req: Request, res: Response, next: NextFunction): void => {
70+
const result = schema.safeParse(req.params);
71+
72+
if (!result.success) {
73+
const details = result.error.issues.map((issue) => ({
74+
field: issue.path.join('.'),
75+
message: issue.message,
76+
}));
77+
78+
res.status(400).json({ error: 'Validation failed', details });
79+
return;
80+
}
81+
82+
req.params = result.data as any;
83+
next();
84+
};
85+
}

src/models/CreditLine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export interface CreditLine {
33
walletAddress: string;
44
creditLimit: string; // Using string for precise decimal handling
55
availableCredit: string;
6+
utilized: string;
67
interestRateBps: number; // Basis points (e.g., 500 = 5%)
78
status: CreditLineStatus;
89
createdAt: Date;
@@ -26,4 +27,5 @@ export interface UpdateCreditLineRequest {
2627
creditLimit?: string;
2728
interestRateBps?: number;
2829
status?: CreditLineStatus;
30+
utilized?: string;
2931
}

src/openapi.yaml

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,87 @@ paths:
10711071
error: Risk evaluation not found
10721072
id: "abc123"
10731073

1074+
/api/risk/wallet/{walletAddress}/latest:
1075+
get:
1076+
tags: [Risk]
1077+
operationId: getLatestRiskEvaluation
1078+
summary: Get the latest risk evaluation for a wallet
1079+
parameters:
1080+
- name: walletAddress
1081+
in: path
1082+
required: true
1083+
schema:
1084+
type: string
1085+
pattern: '^G[A-Z2-7]{55}$'
1086+
description: Stellar public key
1087+
responses:
1088+
"200":
1089+
description: Risk evaluation found
1090+
content:
1091+
application/json:
1092+
schema:
1093+
$ref: "#/components/schemas/RiskEvaluateResponse"
1094+
"400":
1095+
description: Invalid wallet address
1096+
content:
1097+
application/json:
1098+
schema:
1099+
$ref: "#/components/schemas/ErrorResponse"
1100+
"404":
1101+
description: No risk evaluation found for wallet
1102+
content:
1103+
application/json:
1104+
schema:
1105+
$ref: "#/components/schemas/ErrorResponse"
1106+
1107+
/api/risk/wallet/{walletAddress}/history:
1108+
get:
1109+
tags: [Risk]
1110+
operationId: getRiskEvaluationHistory
1111+
summary: Get risk evaluation history for a wallet
1112+
parameters:
1113+
- name: walletAddress
1114+
in: path
1115+
required: true
1116+
schema:
1117+
type: string
1118+
pattern: '^G[A-Z2-7]{55}$'
1119+
description: Stellar public key
1120+
- name: offset
1121+
in: query
1122+
required: false
1123+
schema:
1124+
type: integer
1125+
minimum: 0
1126+
description: Pagination offset
1127+
- name: limit
1128+
in: query
1129+
required: false
1130+
schema:
1131+
type: integer
1132+
minimum: 1
1133+
maximum: 100
1134+
description: Pagination limit
1135+
responses:
1136+
"200":
1137+
description: Array of risk evaluations
1138+
content:
1139+
application/json:
1140+
schema:
1141+
type: object
1142+
properties:
1143+
evaluations:
1144+
type: array
1145+
items:
1146+
$ref: "#/components/schemas/RiskEvaluation"
1147+
"400":
1148+
description: Invalid parameters
1149+
content:
1150+
application/json:
1151+
schema:
1152+
$ref: "#/components/schemas/ErrorResponse"
1153+
1154+
10741155
components:
10751156
schemas:
10761157
HealthResponse:
@@ -1120,8 +1201,9 @@ components:
11201201
properties:
11211202
walletAddress:
11221203
type: string
1123-
description: EVM-compatible wallet address
1124-
example: "0xAbC1234567890abcdef1234567890abcdef123456"
1204+
description: Stellar public key (G...)
1205+
pattern: '^G[A-Z2-7]{55}$'
1206+
example: "GBAHQCUPC7G2B4D2F2I2K2M2O2Q2S2U2W2Y2A2C2E2G2I2K2M2O2Q2S2"
11251207
forceRefresh:
11261208
type: boolean
11271209
description: Optional hint to bypass cache and recalculate risk

src/repositories/memory/InMemoryCreditLineRepository.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class InMemoryCreditLineRepository implements CreditLineRepository {
1414
walletAddress: request.walletAddress,
1515
creditLimit: request.creditLimit,
1616
availableCredit: request.creditLimit, // Initially full credit available
17+
utilized: '0',
1718
interestRateBps: request.interestRateBps,
1819
status: CreditLineStatus.ACTIVE,
1920
createdAt: now,
@@ -101,6 +102,12 @@ export class InMemoryCreditLineRepository implements CreditLineRepository {
101102
updatedAt: new Date()
102103
};
103104

105+
// Keep availableCredit in sync if utilized changes
106+
if (request.utilized !== undefined) {
107+
const limit = parseFloat(updated.creditLimit);
108+
const utilized = parseFloat(request.utilized);
109+
updated.availableCredit = (limit - utilized).toString();
110+
}
104111
// If credit limit changed, adjust available credit proportionally
105112
if (request.creditLimit && request.creditLimit !== existing.creditLimit) {
106113
const oldLimit = parseFloat(existing.creditLimit);

0 commit comments

Comments
 (0)