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
64 changes: 61 additions & 3 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,71 @@ STELLAR_RPC_URL=https://soroban-testnet.stellar.org
STELLAR_NETWORK=testnet
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015

# Stellar Signing Key (for submitting Soroban transactions)
# Should be a Stellar secret key (starting with 'S')
STELLAR_SECRET_KEY=

# Vault Configuration
VAULT_CONTRACT_ID=

# Optional: Database Configuration (for future use)
# DATABASE_URL=
# DATABASE_POOL_SIZE=10
# Optional: Database Configuration
DATABASE_URL=
DATABASE_REPLICA_URL=
DATABASE_POOL_SIZE=10
PRISMA_POOL_MAX=10
PRISMA_POOL_TIMEOUT_MS=10000
PRISMA_QUERY_TIMEOUT_MS=5000

# Admin audit log persistence mode: memory | prisma | hybrid
ADMIN_AUDIT_LOG_STORAGE=hybrid

# Prisma runtime connection settings
PRISMA_POOL_SIZE=10
PRISMA_POOL_TIMEOUT_SEC=10
PRISMA_QUERY_TIMEOUT_MS=5000
PRISMA_TX_MAX_WAIT_MS=5000
PRISMA_TX_TIMEOUT_MS=10000

# Optional: Cache Configuration (for future use)
# REDIS_URL=redis://localhost:6379
# CACHE_TTL=300

# CORS Configuration
# Comma-separated list of allowed origins. Supports strings or regex like /https?:\/\/.*\.yieldvault\.finance/
CORS_ALLOWED_ORIGINS=http://localhost:3000,https://app.yieldvault.finance

# Email Notification Configuration
EMAIL_PROVIDER=resend
EMAIL_API_KEY=
EMAIL_FROM_ADDRESS=notifications@yieldvault.finance

# Latency SLO Monitoring Configuration
# SLO thresholds in milliseconds
SLO_READ_THRESHOLD_MS=200
SLO_WRITE_THRESHOLD_MS=500

# Evaluation window for P95 calculation (5 minutes = 300000 ms)
SLO_EVALUATION_WINDOW_MS=300000

# Alert cooldown period to prevent spam (15 minutes = 900000 ms)
SLO_ALERT_COOLDOWN_MS=900000

# SLO check interval (1 minute = 60000 ms)
SLO_CHECK_INTERVAL_MS=60000

# Alert Integration Configuration
# Set to 'slack', 'pagerduty', or 'both'
ALERT_TYPE=slack

# Slack Webhook URL (required if ALERT_TYPE includes 'slack')
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK

# PagerDuty Integration Key (required if ALERT_TYPE includes 'pagerduty')
PAGERDUTY_INTEGRATION_KEY=

# Event Polling Configuration
# Poll interval for checking new events (10 seconds = 10000 ms)
EVENT_POLL_INTERVAL_MS=10000

# Batch size for event replay (number of ledgers per batch)
EVENT_REPLAY_BATCH_SIZE=100
44 changes: 33 additions & 11 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
"test": "jest",
"test:smoke": "npm run build && npm run start &",
"lint": "eslint src",
"format": "prettier --write src"
"format": "prettier --write src",
"audit": "npm audit",
"generate:openapi": "tsx scripts/generate-openapi.ts",
"ci:governance": "npm run lint && npm run test && node scripts/check-migrations.js",
"db:check-drift": "prisma migrate diff --from-schema-datamodel prisma/schema.prisma --to-migrations prisma/migrations --exit-code"
},
"keywords": [
"stellar",
Expand All @@ -21,24 +25,42 @@
"author": "",
"license": "MIT",
"dependencies": {
"@opentelemetry/api": "^1.9.1",
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
"@opentelemetry/instrumentation-express": "^0.66.0",
"@opentelemetry/instrumentation-http": "^0.218.0",
"@opentelemetry/resources": "^2.7.1",
"@opentelemetry/sdk-node": "^0.218.0",
"@opentelemetry/semantic-conventions": "^1.41.1",
"@prisma/client": "^5.10.0",
"@stellar/stellar-sdk": "^12.4.0",
"@types/decimal.js": "^0.0.32",
"cors": "^2.8.6",
"decimal.js": "^10.6.0",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.0.0",
"dotenv": "^16.3.1",
"node-cache": "^5.1.2"
"ioredis": "^5.10.1",
"node-cache": "^5.1.2",
"prom-client": "^15.1.3",
"rate-limit-redis": "^4.3.1"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^4.17.17",
"@types/node": "^20.0.0",
"@types/jest": "^29.5.0",
"typescript": "^5.1.0",
"tsx": "^4.0.0",
"jest": "^29.5.0",
"ts-jest": "^29.1.0",
"supertest": "^6.3.3",
"@types/node": "^20.0.0",
"@types/supertest": "^2.0.12",
"eslint": "^8.40.0",
"@typescript-eslint/eslint-plugin": "^5.59.0",
"@typescript-eslint/parser": "^5.59.0",
"prettier": "^3.0.0"
"eslint": "^8.40.0",
"fast-check": "^3.23.2",
"jest": "^29.5.0",
"prettier": "^3.0.0",
"prisma": "^5.10.0",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"tsx": "^4.0.0",
"typescript": "^5.1.0"
}
}
122 changes: 80 additions & 42 deletions backend/src/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
import request from 'supertest';

// Mock the sorobanClient before importing app
jest.mock('../sorobanClient', () => ({
submitVaultOperation: jest.fn(async (type: string, wallet: string, amount: string, asset: string) => {
// Return a mock transaction hash
return `mock_tx_hash_${type}_${Date.now()}`;
}),
SorobanSimulationError: class SorobanSimulationError extends Error {
code: string;
statusCode: number = 502;
constructor(message: string, code = 'SIMULATION_ERROR') {
super(message);
this.name = 'SorobanSimulationError';
this.code = code;
}
},
}));

import app from '../index';

describe('Backend API', () => {
Expand Down Expand Up @@ -75,7 +93,7 @@ describe('Backend API', () => {

describe('Rate Limiting - API Endpoints', () => {
it('should include rate limit headers in response', async () => {
const response = await request(app).get('/api/vault/summary');
const response = await request(app).get('/api/v1/vault/summary');

expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
Expand All @@ -88,32 +106,46 @@ describe('Backend API', () => {
// It attempts to exceed the API rate limit
const requests = Array(35).fill(null); // More than configured limit
const results = await Promise.all(
requests.map(() => request(app).get('/api/vault/summary'))
requests.map(() => request(app).get('/api/v1/vault/summary'))
);

expect(results.some((r) => r.status === 429)).toBe(true);
});

it('should return 429 with clear error message', async () => {
it('should return 429 with clear error message and Retry-After header', async () => {
// Make multiple requests to trigger rate limit
const requests = Array(35).fill(null);
await Promise.all(
requests.map(() => request(app).get('/api/vault/summary'))
requests.map(() => request(app).get('/api/v1/vault/summary'))
);

const response = await request(app).get('/api/vault/summary');
const response = await request(app).get('/api/v1/vault/summary');

if (response.status === 429) {
expect(response.body).toHaveProperty('error');
expect(response.body).toHaveProperty('status', 429);
expect(response.body).toHaveProperty('message');
// Issue #251: retryAfter field in body
expect(response.body).toHaveProperty('retryAfter');
expect(typeof response.body.retryAfter).toBe('number');
// Issue #251: Retry-After header must be present
expect(response.headers).toHaveProperty('retry-after');
}
});

it('should support per-user rate limiting with API key', async () => {
// Test that API key in header is used for rate limiting
it('should support per-user rate limiting with wallet address header', async () => {
// Test that x-wallet-address header is used as the rate-limit key
const response = await request(app)
.get('/api/vault/summary')
.get('/api/v1/vault/summary')
.set('x-wallet-address', 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567');

expect([200, 429]).toContain(response.status);
});

it('should support per-user rate limiting with API key (backward compat)', async () => {
// Test that x-api-key header is still accepted as fallback key
const response = await request(app)
.get('/api/v1/vault/summary')
.set('x-api-key', 'test-key-123');

expect([200, 429]).toContain(response.status);
Expand Down Expand Up @@ -143,48 +175,54 @@ describe('Backend API', () => {
});
});

// ─── Configuration Tests ─────────────────────────────────────────────────
describe('Cache Middleware', () => {
it('should cache repeated list endpoint requests and mark hits', async () => {
const first = await request(app).get('/api/v1/transactions');
expect(first.headers['x-cache-hit']).toBe('false');

describe('Configuration', () => {
it('should have proper rate limit defaults', async () => {
// This verifies the backend is configured with sensible defaults
expect(process.env.PORT || 3000).toBeDefined();
const second = await request(app).get('/api/v1/transactions');
expect(second.headers['x-cache-hit']).toBe('true');
});

it('should not expose sensitive info in error responses', async () => {
const response = await request(app).get('/api/vault/summary');
it('should separate cache entries by query string', async () => {
const first = await request(app).get('/api/v1/transactions?limit=1');
expect(first.headers['x-cache-hit']).toBe('false');

// Ensure no stack traces in error responses in production-like environment
if (response.status >= 500) {
if (process.env.NODE_ENV === 'production') {
expect(response.body.message).not.toContain('at ');
expect(response.body.message).not.toContain('Error');
}
}
const second = await request(app).get('/api/v1/transactions?limit=2');
expect(second.headers['x-cache-hit']).toBe('false');

const third = await request(app).get('/api/v1/transactions?limit=2');
expect(third.headers['x-cache-hit']).toBe('true');
});
});

// ─── Integration Tests ──────────────────────────────────────────────────
it('should invalidate cached list responses after a vault deposit', async () => {
const priorAllowlist = process.env.ALLOWLIST_ENABLED;
process.env.ALLOWLIST_ENABLED = 'false';

describe('Integration', () => {
it('should have proper CORS headers configured', async () => {
const response = await request(app)
.get('/health')
.set('Origin', 'http://localhost:5173');
try {
await request(app).get('/api/v1/transactions');
const cached = await request(app).get('/api/v1/transactions');
expect(cached.headers['x-cache-hit']).toBe('true');

// Response should include appropriate headers
expect(response.status).toBe(200);
});
const depositResponse = await request(app)
.post('/api/v1/vault/deposits')
.send({
amount: '100',
asset: 'USDC',
walletAddress: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
email: 'user@example.com',
});

it('should handle JSON body parsing', async () => {
const response = await request(app)
.post('/api/vault/summary')
.send({
test: 'data',
});
expect(depositResponse.status).toBe(201);

// Should either accept or reject with proper error
expect([200, 405, 404, 400]).toContain(response.status);
const afterInvalidate = await request(app).get('/api/v1/transactions');
expect(afterInvalidate.headers['x-cache-hit']).toBe('false');
} finally {
process.env.ALLOWLIST_ENABLED = priorAllowlist;
}
});
});
});

it('should cache referral stats for a referrer wallet', async () => {
const referrerWallet = 'GREFERRER1234567890';

const codeResponse = await request(app).get(`/api/v1/referrals/code/${referrerWallet}`);
47 changes: 44 additions & 3 deletions backend/src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import dotenv from 'dotenv';

// Load environment variables for tests
// CRITICAL: Set NODE_ENV first to ensure environment-aware initialization
process.env.NODE_ENV = 'test';

// Load environment variables for tests with override
// This must happen before any modules initialize Prisma or tracing
dotenv.config({
path: '.env.test',
override: true,
});

// Set test environment
process.env.NODE_ENV = 'test';
// Explicitly disable tracing for all tests
process.env.OTEL_ENABLED = 'false';

// Ensure health/readiness checks pass in test mode even without external RPC configuration
process.env.STELLAR_RPC_URL = process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org';

// Set test Soroban configuration (required for submitVaultOperation)
// In tests, these won't actually be used for real transactions
process.env.STELLAR_NETWORK_PASSPHRASE = process.env.STELLAR_NETWORK_PASSPHRASE || 'Test SDF Network ; September 2015';
if (!process.env.STELLAR_SECRET_KEY) {
// Use a dummy test keypair (not used in actual tests since they're mocked)
process.env.STELLAR_SECRET_KEY = 'SBZVMB74Z76QZ3ZZY66NIDQCB5X5KZDQK4JQHJCZVDZ5XWFSM7K7HRE';
}
if (!process.env.VAULT_CONTRACT_ID) {
process.env.VAULT_CONTRACT_ID = 'CBQHNAXSI55GX2WOOVEDW47GHQU2FWYKCFO4XWJWILTZLVNODAVZCXX';
}

// Suppress OpenTelemetry spam in test output
process.env.OTEL_LOG_LEVEL = 'error';

// CRITICAL: Patch PrismaClient constructor BEFORE any code tries to instantiate it
// This intercepts the instrumentation hooks and prevents the panic
const PrismaClientModule = require('@prisma/client');
const OriginalPrismaClient = PrismaClientModule.PrismaClient;

class PatchedPrismaClient extends OriginalPrismaClient {
constructor(options?: any) {
// Remove any corrupted options that the instrumentation added
const cleanOptions = options || {};
// Strip out any unrecognized instrumentation-related fields
if (cleanOptions._lib) {
delete cleanOptions._lib;
}
super(cleanOptions);
}
}

// Replace the exported PrismaClient
PrismaClientModule.PrismaClient = PatchedPrismaClient;
Loading