From f4b547d12ae5c59b4ab33dbcd18edd81e75ded20 Mon Sep 17 00:00:00 2001 From: Adeswalla Date: Wed, 27 May 2026 15:03:09 +0100 Subject: [PATCH] mass-commit --- backend/package-lock.json | 34 +++---- backend/src/app.js | 24 +++-- backend/src/config.js | 3 + backend/src/health.js | 56 ++++++++++++ backend/src/routes/vaccination.js | 28 +++--- backend/src/routes/verify.js | 6 +- backend/src/stellar/soroban.js | 103 ++++++++++++++++++++- backend/tests/app.test.js | 13 +++ backend/tests/cors.test.js | 66 +++++++++++++- backend/tests/soroban-helpers.test.js | 123 ++++++++++++++++++++++++++ 10 files changed, 400 insertions(+), 56 deletions(-) create mode 100644 backend/src/health.js create mode 100644 backend/tests/soroban-helpers.test.js diff --git a/backend/package-lock.json b/backend/package-lock.json index d65b13c..e525504 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,25 +8,25 @@ "name": "vaccichain-backend", "version": "1.0.0", "dependencies": { - "@aws-sdk/client-secrets-manager": "^3.500.0", - "@stellar/stellar-sdk": "^12.0.0", - "cors": "^2.8.5", - "dotenv": "^16.3.1", - "express": "^4.18.2", - "express-rate-limit": "^7.2.0", - "jsonwebtoken": "^9.0.2", - "sql.js": "^1.12.0", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "winston": "^3.19.0", - "zod": "^4.3.6" + "@aws-sdk/client-secrets-manager": "3.1038.0", + "@stellar/stellar-sdk": "12.3.0", + "cors": "2.8.6", + "dotenv": "16.6.1", + "express": "4.22.2", + "express-rate-limit": "7.2.0", + "jsonwebtoken": "9.0.3", + "sql.js": "1.12.0", + "swagger-jsdoc": "6.2.8", + "swagger-ui-express": "5.0.1", + "winston": "3.19.0", + "zod": "4.3.6" }, "devDependencies": { - "@faker-js/faker": "^8.4.1", - "c8": "^11.0.0", - "jest": "^29.7.0", - "nodemon": "^3.0.2", - "supertest": "^6.3.4" + "@faker-js/faker": "8.4.1", + "c8": "11.0.0", + "jest": "29.7.0", + "nodemon": "3.1.14", + "supertest": "6.3.4" } }, "node_modules/@apidevtools/json-schema-ref-parser": { diff --git a/backend/src/app.js b/backend/src/app.js index cfde75c..888a210 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -18,7 +18,7 @@ const patientRoutes = require('./routes/patient'); const consentRoutes = require('./routes/consent'); const onboardingRoutes = require('./routes/onboarding'); const apiVersion = require('./middleware/apiVersion'); -const { getRpcServer } = require('./stellar/soroban'); +const { getHealthStatus, startHealthProbe } = require('./health'); const requestId = require('./middleware/requestId'); const { sanitizeInputs } = require('./middleware/sanitize'); @@ -29,13 +29,17 @@ const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',').map(o => o app.use(cors({ origin: (origin, callback) => { + // Allow requests with no origin (like mobile apps or curl requests) if (!origin || allowedOrigins.includes(origin)) { callback(null, true); } else { callback(null, false); } }, - credentials: true + methods: ['GET', 'POST', 'OPTIONS'], + allowedHeaders: ['Authorization', 'Content-Type'], + credentials: true, + optionsSuccessStatus: 204, })); app.use(securityHeaders); app.use(express.json({ limit: config.BODY_LIMIT })); @@ -87,25 +91,19 @@ app.use(['/auth', '/vaccination', '/verify', '/admin', '/patient', '/events'], ( * Health check endpoint. * * @route GET /health - * @returns {Object} 200 - { status: "ok", soroban: true, timestamp } - * @returns {Object} 503 - { status: "degraded", soroban: false, timestamp } + * @returns {Object} 200 - { status: "ok", uptime } + * @returns {Object} 503 - { status: "degraded", uptime } */ app.get('/health', async (_req, res) => { - let soroban = false; - try { - await getRpcServer().getHealth(); - soroban = true; - } catch (_err) { - // RPC unreachable - } - const body = { status: soroban ? 'ok' : 'degraded', soroban, timestamp: new Date().toISOString() }; - res.status(soroban ? 200 : 503).json(body); + const body = getHealthStatus(); + res.status(body.status === 'ok' ? 200 : 503).json(body); }); if (require.main === module) { initializeSecrets().then(() => { initDb(config.DATABASE_PATH).then(() => { startPoller(config.EVENT_POLL_INTERVAL_MS); + startHealthProbe(); const server = app.listen(config.PORT, () => { logger.info(`Backend running on port ${config.PORT}`); }); diff --git a/backend/src/config.js b/backend/src/config.js index 9b6def3..097d767 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -28,6 +28,9 @@ const schema = z.object({ SOROBAN_FEE: z.coerce.number().int().positive().default(100), SOROBAN_TIP: z.coerce.number().int().min(0).default(0), + // Health probe interval for cached dependency status + HEALTH_POLL_INTERVAL_MS: z.coerce.number().int().positive().default(15000), + // Request limits BODY_LIMIT: z.string().default('10kb'), diff --git a/backend/src/health.js b/backend/src/health.js new file mode 100644 index 0000000..47e2a64 --- /dev/null +++ b/backend/src/health.js @@ -0,0 +1,56 @@ +const config = require('./config'); +const { getRpcServer } = require('./stellar/soroban'); +const logger = require('./logger'); + +let sorobanHealthy = true; +let lastProbeAt = null; + +function getHealthStatus() { + const status = sorobanHealthy ? 'ok' : 'degraded'; + return { + status, + uptime: Number(process.uptime().toFixed(3)), + }; +} + +async function probeSorobanHealth() { + try { + await getRpcServer().getHealth(); + sorobanHealthy = true; + } catch (error) { + sorobanHealthy = false; + logger.warn('Soroban health probe failed', { + message: error.message, + code: error.code, + }); + } finally { + lastProbeAt = new Date(); + } +} + +function startHealthProbe() { + // Run an initial probe immediately, then refresh at the configured interval. + probeSorobanHealth().catch((error) => { + logger.error('Initial Soroban health probe failed', { message: error.message }); + }); + + const interval = setInterval(() => { + probeSorobanHealth().catch((error) => { + logger.error('Soroban health probe failed', { message: error.message }); + }); + }, config.HEALTH_POLL_INTERVAL_MS); + + return () => clearInterval(interval); +} + +function setSorobanHealthy(value) { + sorobanHealthy = value; + lastProbeAt = new Date(); +} + +module.exports = { + getHealthStatus, + startHealthProbe, + setSorobanHealthy, + _getLastProbeAt: () => lastProbeAt, +}; diff --git a/backend/src/routes/vaccination.js b/backend/src/routes/vaccination.js index e957889..bedc937 100644 --- a/backend/src/routes/vaccination.js +++ b/backend/src/routes/vaccination.js @@ -4,7 +4,7 @@ const StellarSdk = require('@stellar/stellar-sdk'); const authMiddleware = require('../middleware/auth'); const issuerMiddleware = require('../middleware/issuer'); const { validateStellarPublicKey } = require('../middleware/wallet'); -const { invokeContract, simulateContract } = require('../stellar/soroban'); +const { invokeContract, simulateContract, mintVaccination } = require('../stellar/soroban'); const { resolveContractErrorMessage } = require('../stellar/contractErrors'); const { audit } = require('../middleware/auditLog'); const validate = require('../middleware/validate'); @@ -106,21 +106,13 @@ router.post( } try { - const toOptU32 = (v) => v != null - ? StellarSdk.xdr.ScVal.scvVec([StellarSdk.xdr.ScVal.scvU32(v)]) - : StellarSdk.xdr.ScVal.scvVoid(); - - const args = [ - StellarSdk.Address.fromString(patient_address).toScVal(), - StellarSdk.xdr.ScVal.scvString(vaccine_name), - StellarSdk.xdr.ScVal.scvString(date_administered), - StellarSdk.Address.fromString(req.user.publicKey).toScVal(), - toOptU32(dose_number), - toOptU32(dose_series), - ]; - - const result = await invokeContract(process.env.ISSUER_SECRET_KEY, 'mint_vaccination', args); - const tokenId = StellarSdk.scValToNative(result.returnValue); + const result = await mintVaccination( + patient_address, + vaccine_name, + date_administered, + process.env.ISSUER_SECRET_KEY, + { doseNumber: dose_number, doseSeries: dose_series } + ); const timestamp = new Date().toISOString(); audit({ @@ -128,12 +120,12 @@ router.post( action: 'vaccination.issue', target: patient_address, result: 'success', - meta: { token_id: tokenId, vaccine_name, date_administered, dose_number, dose_series }, + meta: { token_id: result.tokenId, vaccine_name, date_administered, dose_number, dose_series }, }); res.json({ success: true, - tokenId, + tokenId: result.tokenId, transactionHash: result.hash, ledger: result.ledger, timestamp, diff --git a/backend/src/routes/verify.js b/backend/src/routes/verify.js index bf94b45..affe89a 100644 --- a/backend/src/routes/verify.js +++ b/backend/src/routes/verify.js @@ -1,7 +1,7 @@ const express = require('express'); const StellarSdk = require('@stellar/stellar-sdk'); const { validateStellarPublicKey } = require('../middleware/wallet'); -const { simulateContract } = require('../stellar/soroban'); +const { simulateContract, verifyVaccination } = require('../stellar/soroban'); const { resolveContractErrorMessage } = require('../stellar/contractErrors'); const { verifyLimiter, verifierKeyLimiter } = require('../middleware/rateLimiter'); const verifierApiKey = require('../middleware/verifierApiKey'); @@ -102,9 +102,7 @@ router.get( } try { - const args = [StellarSdk.Address.fromString(wallet).toScVal()]; - const result = await simulateContract('verify_vaccination', args); - const [vaccinated, records] = StellarSdk.scValToNative(result); + const { vaccinated, records } = await verifyVaccination(wallet); verifyCache.set(wallet, { vaccinated, diff --git a/backend/src/stellar/soroban.js b/backend/src/stellar/soroban.js index 7c5acd8..e83a503 100644 --- a/backend/src/stellar/soroban.js +++ b/backend/src/stellar/soroban.js @@ -9,6 +9,35 @@ const { SOROBAN_RPC_MAX_RETRIES, } = config; +class SorobanError extends Error { + constructor(message, originalError) { + super(message); + this.name = 'SorobanError'; + this.original = originalError; + } +} + +class SorobanRpcError extends SorobanError { + constructor(message, originalError) { + super(message, originalError); + this.name = 'SorobanRpcError'; + } +} + +class SorobanTransactionError extends SorobanError { + constructor(message, originalError) { + super(message, originalError); + this.name = 'SorobanTransactionError'; + } +} + +class SorobanSimulationError extends SorobanError { + constructor(message, originalError) { + super(message, originalError); + this.name = 'SorobanSimulationError'; + } +} + // Fee in stroops (1 XLM = 10_000_000 stroops). Minimum is 100. const TX_FEE = String(process.env.SOROBAN_FEE || 100); // Inclusion tip in stroops for priority during congestion (0 = no tip). @@ -129,6 +158,67 @@ async function invokeContract(secretKey, method, args) { return { returnValue: result.returnValue, hash: response.hash, ledger: result.ledger }; } +function toOptionalU32(value) { + return value != null + ? StellarSdk.xdr.ScVal.scvVec([StellarSdk.xdr.ScVal.scvU32(value)]) + : StellarSdk.xdr.ScVal.scvVoid(); +} + +/** + * Mint a vaccination record and submit a signed transaction. + * @param {string} patient - Stellar address of the patient + * @param {string} vaccineName - Vaccine name + * @param {string} dateAdministered - ISO-8601 timestamp + * @param {string} issuerSecret - Issuer secret key for signing + * @param {Object} [options] + * @param {number|null} [options.doseNumber] + * @param {number|null} [options.doseSeries] + */ +async function mintVaccination(patient, vaccineName, dateAdministered, issuerSecret, options = {}) { + try { + const issuerKeypair = StellarSdk.Keypair.fromSecret(issuerSecret); + const args = [ + StellarSdk.Address.fromString(patient).toScVal(), + StellarSdk.xdr.ScVal.scvString(vaccineName), + StellarSdk.xdr.ScVal.scvString(dateAdministered), + StellarSdk.Address.fromString(issuerKeypair.publicKey()).toScVal(), + toOptionalU32(options.doseNumber), + toOptionalU32(options.doseSeries), + ]; + + const result = await invokeContract(issuerSecret, 'mint_vaccination', args); + const tokenId = StellarSdk.scValToNative(result.returnValue); + return { + tokenId, + hash: result.hash, + ledger: result.ledger, + }; + } catch (error) { + if (error instanceof SorobanError) throw error; + throw new SorobanTransactionError('Failed to mint vaccination record', error); + } +} + +/** + * Verify vaccination status via a read-only contract call. + * @param {string} wallet - Stellar wallet address to verify + */ +async function verifyVaccination(wallet) { + try { + const args = [StellarSdk.Address.fromString(wallet).toScVal()]; + const rawResult = await simulateContract('verify_vaccination', args); + const [vaccinated, records] = StellarSdk.scValToNative(rawResult); + return { + wallet, + vaccinated, + records: Array.isArray(records) ? records : [], + }; + } catch (error) { + if (error instanceof SorobanError) throw error; + throw new SorobanSimulationError('Failed to verify vaccination status', error); + } +} + /** * Read-only contract call (no signing needed). */ @@ -158,7 +248,18 @@ async function simulateContract(method, args) { return sim.result?.retval; } -module.exports = { getRpcServer, invokeContract, simulateContract, addIssuer }; +module.exports = { + getRpcServer, + invokeContract, + simulateContract, + mintVaccination, + verifyVaccination, + addIssuer, + SorobanError, + SorobanRpcError, + SorobanTransactionError, + SorobanSimulationError, +}; /** * Add a new issuer to the contract allowlist (admin-signed). diff --git a/backend/tests/app.test.js b/backend/tests/app.test.js index ba3061e..94d4311 100644 --- a/backend/tests/app.test.js +++ b/backend/tests/app.test.js @@ -1,11 +1,24 @@ const request = require('supertest'); const app = require('../src/app'); +const health = require('../src/health'); describe('Health check', () => { it('GET /health returns ok', async () => { const res = await request(app).get('/health'); expect(res.status).toBe(200); expect(res.body.status).toBe('ok'); + expect(typeof res.body.uptime).toBe('number'); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + }); + + it('GET /health returns degraded when Soroban is unhealthy', async () => { + health.setSorobanHealthy(false); + const res = await request(app).get('/health'); + expect(res.status).toBe(503); + expect(res.body.status).toBe('degraded'); + expect(typeof res.body.uptime).toBe('number'); + expect(res.body.uptime).toBeGreaterThanOrEqual(0); + health.setSorobanHealthy(true); }); }); diff --git a/backend/tests/cors.test.js b/backend/tests/cors.test.js index d82a810..034baf3 100644 --- a/backend/tests/cors.test.js +++ b/backend/tests/cors.test.js @@ -1,3 +1,6 @@ +/** + * CORS security configuration tests + */ const request = require('supertest'); describe('CORS', () => { @@ -22,15 +25,37 @@ describe('CORS', () => { expect(res.headers['access-control-allow-origin']).toBeUndefined(); }); - it('handles preflight for allowed origin', async () => { + it('handles preflight for allowed origin with 204', async () => { const res = await request(app) .options('/health') .set('Origin', ALLOWED) - .set('Access-Control-Request-Method', 'GET'); + .set('Access-Control-Request-Method', 'POST'); + expect(res.status).toBe(204); expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); }); + it('allows explicit methods in preflight response', async () => { + const res = await request(app) + .options('/health') + .set('Origin', ALLOWED) + .set('Access-Control-Request-Method', 'POST'); + + expect(res.headers['access-control-allow-methods']).toContain('GET'); + expect(res.headers['access-control-allow-methods']).toContain('POST'); + expect(res.headers['access-control-allow-methods']).toContain('OPTIONS'); + }); + + it('allows explicit headers in preflight response', async () => { + const res = await request(app) + .options('/health') + .set('Origin', ALLOWED) + .set('Access-Control-Request-Headers', 'Authorization,Content-Type'); + + expect(res.headers['access-control-allow-headers']).toContain('Authorization'); + expect(res.headers['access-control-allow-headers']).toContain('Content-Type'); + }); + it('sets credentials header for allowed origin', async () => { const res = await request(app).get('/health').set('Origin', ALLOWED); expect(res.headers['access-control-allow-credentials']).toBe('true'); @@ -41,7 +66,42 @@ describe('CORS', () => { process.env.ALLOWED_ORIGINS = `${ALLOWED},https://admin.example.com`; const multiApp = require('../src/app'); - const res = await request(multiApp).get('/health').set('Origin', 'https://admin.example.com'); + const res = await request(multiApp) + .get('/health') + .set('Origin', 'https://admin.example.com'); expect(res.headers['access-control-allow-origin']).toBe('https://admin.example.com'); }); + + it('does not use wildcard origin in any response', async () => { + const origins = [ALLOWED, BLOCKED, 'http://random.org']; + + for (const origin of origins) { + const res = await request(app).get('/health').set('Origin', origin); + const allowedOriginHeader = res.headers['access-control-allow-origin']; + + if (allowedOriginHeader) { + expect(allowedOriginHeader).not.toBe('*'); + expect(allowedOriginHeader).not.toContain('*'); + } + } + }); + + it('allows GET requests with explicit method', async () => { + const res = await request(app) + .get('/health') + .set('Origin', ALLOWED); + + expect(res.status).toBe(200); + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); + }); + + it('allows POST requests with explicit method', async () => { + const res = await request(app) + .post('/auth/sep10') + .set('Origin', ALLOWED) + .send({ public_key: 'invalid' }); + + // POST is allowed; response may be 400 for validation but CORS should pass + expect(res.headers['access-control-allow-origin']).toBe(ALLOWED); + }); }); diff --git a/backend/tests/soroban-helpers.test.js b/backend/tests/soroban-helpers.test.js new file mode 100644 index 0000000..b2e4216 --- /dev/null +++ b/backend/tests/soroban-helpers.test.js @@ -0,0 +1,123 @@ +/** + * Tests for Soroban error classes and helper function signatures + */ +const { + SorobanError, + SorobanTransactionError, + SorobanSimulationError, + SorobanRpcError, +} = require('../src/stellar/soroban'); + +describe('Soroban Error Classes', () => { + describe('SorobanError', () => { + it('is an Error subclass with proper name', () => { + const original = new Error('original error'); + const error = new SorobanError('test message', original); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('SorobanError'); + expect(error.message).toBe('test message'); + expect(error.original).toBe(original); + }); + + it('can be thrown and caught', () => { + const error = new SorobanError('test', null); + + expect(() => { + throw error; + }).toThrow(SorobanError); + }); + }); + + describe('SorobanRpcError', () => { + it('is subclass of SorobanError', () => { + const error = new SorobanRpcError('RPC failed', new Error('network')); + + expect(error).toBeInstanceOf(SorobanError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('SorobanRpcError'); + expect(error.message).toBe('RPC failed'); + }); + }); + + describe('SorobanTransactionError', () => { + it('is subclass of SorobanError', () => { + const error = new SorobanTransactionError('TX failed', new Error('gas')); + + expect(error).toBeInstanceOf(SorobanError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('SorobanTransactionError'); + expect(error.message).toBe('TX failed'); + }); + + it('preserves original error', () => { + const originalError = new Error('insufficient fee'); + const error = new SorobanTransactionError('Mint failed', originalError); + + expect(error.original).toBe(originalError); + expect(error.original.message).toBe('insufficient fee'); + }); + }); + + describe('SorobanSimulationError', () => { + it('is subclass of SorobanError', () => { + const error = new SorobanSimulationError('Simulation failed', new Error('contract')); + + expect(error).toBeInstanceOf(SorobanError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('SorobanSimulationError'); + expect(error.message).toBe('Simulation failed'); + }); + + it('can be caught as SorobanError', () => { + const error = new SorobanSimulationError('Verify failed', null); + + expect(() => { + throw error; + }).toThrow(SorobanError); + }); + }); + + describe('Error Hierarchy', () => { + it('all custom errors inherit from Error', () => { + const errors = [ + new SorobanError('e1', null), + new SorobanRpcError('e2', null), + new SorobanTransactionError('e3', null), + new SorobanSimulationError('e4', null), + ]; + + errors.forEach((err) => { + expect(err).toBeInstanceOf(Error); + }); + }); + + it('typed errors can be distinguished', () => { + const rpc = new SorobanRpcError('rpc', null); + const tx = new SorobanTransactionError('tx', null); + const sim = new SorobanSimulationError('sim', null); + + expect(rpc.name).toBe('SorobanRpcError'); + expect(tx.name).toBe('SorobanTransactionError'); + expect(sim.name).toBe('SorobanSimulationError'); + }); + }); + + describe('Typed error catching', () => { + it('instanceof checks work for error filtering', () => { + const errors = [ + new SorobanTransactionError('tx error', null), + new SorobanSimulationError('sim error', null), + new Error('generic error'), + ]; + + const txErrors = errors.filter((e) => e instanceof SorobanTransactionError); + const simErrors = errors.filter((e) => e instanceof SorobanSimulationError); + const sorobanErrors = errors.filter((e) => e instanceof SorobanError); + + expect(txErrors).toHaveLength(1); + expect(simErrors).toHaveLength(1); + expect(sorobanErrors).toHaveLength(2); + }); + }); +});