From 17eb38a4c84ff9b7625bb32c51a1f43d8f40aa75 Mon Sep 17 00:00:00 2001 From: Tboy123-emm Date: Tue, 26 May 2026 17:59:29 +0000 Subject: [PATCH 1/3] test: add validateEnv unit tests and refactor config for testability --- backend/package-lock.json | 5 +---- backend/src/config.js | 29 +++++++++++++++++++-------- backend/tests/config.test.js | 39 ++++++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 backend/tests/config.test.js diff --git a/backend/package-lock.json b/backend/package-lock.json index e8e2aa7..fb63d01 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -22,8 +22,8 @@ "zod": "^4.3.6" }, "devDependencies": { - "c8": "^11.0.0", "@faker-js/faker": "^8.4.1", + "c8": "^11.0.0", "jest": "^29.7.0", "nodemon": "^3.0.2", "supertest": "^6.3.4" @@ -784,7 +784,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2958,7 +2957,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3994,7 +3992,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", diff --git a/backend/src/config.js b/backend/src/config.js index 9b6def3..2f66b2d 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -12,7 +12,9 @@ const schema = z.object({ VACCINATIONS_CONTRACT_ID: z.string().min(1), // Backend - ADMIN_SECRET_KEY: z.string().min(1), + ADMIN_SECRET_KEY: z.string().min(1).refine(v => v.startsWith('S'), { + message: 'ADMIN_SECRET_KEY must start with S', + }), SEP10_SERVER_KEY: z.string().min(1), JWT_SECRET: z.string().min(1), PORT: z.coerce.number().int().positive().default(4000), @@ -36,12 +38,23 @@ const schema = z.object({ DATABASE_PATH: z.string().default('/data/vaccichain.db'), }); -const result = schema.safeParse(process.env); - -if (!result.success) { - const missing = result.error.issues.map(i => ` ${i.path[0]}: ${i.message}`).join('\n'); - console.error(`[config] Missing or invalid environment variables:\n${missing}`); - process.exit(1); +function validateEnv(env) { + const result = schema.safeParse(env); + if (!result.success) { + const missing = result.error.issues.map(i => ` ${i.path[0]}: ${i.message}`).join('\n'); + throw new Error(`[config] Missing or invalid environment variables:\n${missing}`); + } + return result.data; } -module.exports = result.data; +const config = (() => { + try { + return validateEnv(process.env); + } catch (err) { + console.error(err.message); + process.exit(1); + } +})(); + +module.exports = config; +module.exports.validateEnv = validateEnv; diff --git a/backend/tests/config.test.js b/backend/tests/config.test.js new file mode 100644 index 0000000..d49b512 --- /dev/null +++ b/backend/tests/config.test.js @@ -0,0 +1,39 @@ +const { validateEnv } = require('../src/config'); + +const valid = { + HORIZON_URL: 'https://horizon-testnet.stellar.org', + SOROBAN_RPC_URL: 'https://soroban-testnet.stellar.org', + STELLAR_NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', + VACCINATIONS_CONTRACT_ID: 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4', + ADMIN_SECRET_KEY: 'STEST000000000000000000000000000000000000000000000000000', + SEP10_SERVER_KEY: 'STEST000000000000000000000000000000000000000000000000001', + JWT_SECRET: 'test-jwt-secret', +}; + +describe('validateEnv', () => { + it('passes with all required variables present', () => { + expect(() => validateEnv(valid)).not.toThrow(); + }); + + it('throws with the variable name when a required variable is missing', () => { + const { HORIZON_URL: _, ...env } = valid; + expect(() => validateEnv(env)).toThrow('HORIZON_URL'); + }); + + it('throws a format error when ADMIN_SECRET_KEY does not start with S', () => { + expect(() => validateEnv({ ...valid, ADMIN_SECRET_KEY: 'XBADKEY' })).toThrow( + 'ADMIN_SECRET_KEY' + ); + }); + + it('throws when STELLAR_NETWORK is set to an invalid value', () => { + expect(() => validateEnv({ ...valid, STELLAR_NETWORK: 'devnet' })).toThrow('STELLAR_NETWORK'); + }); + + it('is callable without side effects (no process.exit)', () => { + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); + expect(() => validateEnv({ ...valid, HORIZON_URL: 'not-a-url' })).toThrow(); + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); +}); From 4fa4b2578770aceef2f01dc433ba0809be3e97cb Mon Sep 17 00:00:00 2001 From: Tboy123-emm Date: Tue, 26 May 2026 18:12:10 +0000 Subject: [PATCH 2/3] test: add SEP-10 HTTP integration tests; fix app.js and jwt.sign bugs --- backend/src/app.js | 2 +- backend/src/routes/auth.js | 2 +- backend/tests/auth-sep10-integration.test.js | 128 +++++++++++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) create mode 100644 backend/tests/auth-sep10-integration.test.js diff --git a/backend/src/app.js b/backend/src/app.js index dc1b836..8b864ed 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,9 +17,9 @@ const eventsRoutes = require('./routes/events'); const patientRoutes = require('./routes/patient'); const consentRoutes = require('./routes/consent'); -const eventsRoutes = require('./routes/events'); const onboardingRoutes = require('./routes/onboarding'); const apiVersion = require('./middleware/apiVersion'); +const securityHeaders = require('./middleware/securityHeaders'); const { getRpcServer } = require('./stellar/soroban'); const requestId = require('./middleware/requestId'); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 26992dc..af41420 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -80,12 +80,12 @@ router.post('/verify', validate(verifySchema), bruteForceGuard, (req, res) => { const signingKey = getSigningKey(); const token = jwt.sign( - { sub: publicKey, wallet: publicKey, publicKey, role }, { sub: publicKey, iss: process.env.HOME_DOMAIN || 'localhost', iat: now, wallet: publicKey, + publicKey, role, }, signingKey.secret, diff --git a/backend/tests/auth-sep10-integration.test.js b/backend/tests/auth-sep10-integration.test.js new file mode 100644 index 0000000..27cab52 --- /dev/null +++ b/backend/tests/auth-sep10-integration.test.js @@ -0,0 +1,128 @@ +'use strict'; + +const request = require('supertest'); +const StellarSdk = require('@stellar/stellar-sdk'); + +// ── Keypairs ────────────────────────────────────────────────────────────────── +const clientKeypair = StellarSdk.Keypair.random(); +const serverKeypair = StellarSdk.Keypair.random(); +const VALID_PUBLIC_KEY = clientKeypair.publicKey(); +const NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; + +// ── Mocks (declared before any require of app/routes) ──────────────────────── + +jest.mock('../src/stellar/sep10', () => ({ + buildChallenge: jest.fn(), + verifyChallenge: jest.fn(), + NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', +})); + +jest.mock('../src/jwtKeys', () => ({ + getSigningKey: () => ({ secret: 'test-jwt-secret', kid: 'test-kid' }), +})); + +// ── Imports (after mocks are registered) ───────────────────────────────────── +const app = require('../src/app'); +const sep10 = require('../src/stellar/sep10'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── +const MOCK_NONCE = 'dGVzdG5vbmNlMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI='; + +function buildSignedChallenge() { + const account = new StellarSdk.Account(serverKeypair.publicKey(), '-1'); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: NETWORK_PASSPHRASE, + }) + .addOperation( + StellarSdk.Operation.manageData({ + name: 'localhost auth', + value: MOCK_NONCE, + source: VALID_PUBLIC_KEY, + }) + ) + .setTimeout(300) + .build(); + tx.sign(serverKeypair); + tx.sign(clientKeypair); + return tx.toXDR(); +} + +const SIGNED_TX = buildSignedChallenge(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('POST /v1/auth/sep10', () => { + it('returns a challenge transaction for a valid public key', async () => { + sep10.buildChallenge.mockResolvedValue({ + transaction: SIGNED_TX, + nonce: MOCK_NONCE, + network_passphrase: NETWORK_PASSPHRASE, + }); + + const res = await request(app) + .post('/v1/auth/sep10') + .send({ public_key: VALID_PUBLIC_KEY }); + + expect(res.status).toBe(200); + expect(typeof res.body.transaction).toBe('string'); + expect(res.body.nonce).toBe(MOCK_NONCE); + expect(sep10.buildChallenge).toHaveBeenCalledWith(VALID_PUBLIC_KEY); + }); + + it('returns 400 when public_key is missing', async () => { + const res = await request(app).post('/v1/auth/sep10').send({}); + expect(res.status).toBe(400); + }); + + it('returns 400 when public_key is not a valid Stellar key', async () => { + const res = await request(app) + .post('/v1/auth/sep10') + .send({ public_key: 'not-a-stellar-key' }); + expect(res.status).toBe(400); + }); +}); + +describe('POST /v1/auth/verify', () => { + it('returns a JWT for a valid signed transaction', async () => { + sep10.verifyChallenge.mockReturnValue(VALID_PUBLIC_KEY); + + const res = await request(app) + .post('/v1/auth/verify') + .send({ transaction: SIGNED_TX, nonce: MOCK_NONCE }); + + expect(res.status).toBe(200); + expect(typeof res.body.token).toBe('string'); + expect(res.body.wallet).toBe(VALID_PUBLIC_KEY); + }); + + it('returns 401 when the signature is invalid', async () => { + sep10.verifyChallenge.mockImplementation(() => { + throw new Error('Client signature missing or invalid'); + }); + + const res = await request(app) + .post('/v1/auth/verify') + .send({ transaction: SIGNED_TX, nonce: MOCK_NONCE }); + + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/signature/i); + }); + + it('returns 401 when the challenge has expired', async () => { + sep10.verifyChallenge.mockImplementation(() => { + throw new Error('Challenge transaction has expired'); + }); + + const res = await request(app) + .post('/v1/auth/verify') + .send({ transaction: SIGNED_TX, nonce: MOCK_NONCE }); + + expect(res.status).toBe(401); + expect(res.body.error).toMatch(/expired/i); + }); +}); From 2c2d7bed114d439e08c59c778b35a0904def9333 Mon Sep 17 00:00:00 2001 From: Tboy123-emm Date: Tue, 26 May 2026 18:25:33 +0000 Subject: [PATCH 3/3] Revert "test: add SEP-10 HTTP integration tests; fix app.js and jwt.sign bugs" This reverts commit 4fa4b2578770aceef2f01dc433ba0809be3e97cb. --- backend/src/app.js | 2 +- backend/src/routes/auth.js | 2 +- backend/tests/auth-sep10-integration.test.js | 128 ------------------- 3 files changed, 2 insertions(+), 130 deletions(-) delete mode 100644 backend/tests/auth-sep10-integration.test.js diff --git a/backend/src/app.js b/backend/src/app.js index 8b864ed..dc1b836 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -17,9 +17,9 @@ const eventsRoutes = require('./routes/events'); const patientRoutes = require('./routes/patient'); const consentRoutes = require('./routes/consent'); +const eventsRoutes = require('./routes/events'); const onboardingRoutes = require('./routes/onboarding'); const apiVersion = require('./middleware/apiVersion'); -const securityHeaders = require('./middleware/securityHeaders'); const { getRpcServer } = require('./stellar/soroban'); const requestId = require('./middleware/requestId'); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index af41420..26992dc 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -80,12 +80,12 @@ router.post('/verify', validate(verifySchema), bruteForceGuard, (req, res) => { const signingKey = getSigningKey(); const token = jwt.sign( + { sub: publicKey, wallet: publicKey, publicKey, role }, { sub: publicKey, iss: process.env.HOME_DOMAIN || 'localhost', iat: now, wallet: publicKey, - publicKey, role, }, signingKey.secret, diff --git a/backend/tests/auth-sep10-integration.test.js b/backend/tests/auth-sep10-integration.test.js deleted file mode 100644 index 27cab52..0000000 --- a/backend/tests/auth-sep10-integration.test.js +++ /dev/null @@ -1,128 +0,0 @@ -'use strict'; - -const request = require('supertest'); -const StellarSdk = require('@stellar/stellar-sdk'); - -// ── Keypairs ────────────────────────────────────────────────────────────────── -const clientKeypair = StellarSdk.Keypair.random(); -const serverKeypair = StellarSdk.Keypair.random(); -const VALID_PUBLIC_KEY = clientKeypair.publicKey(); -const NETWORK_PASSPHRASE = 'Test SDF Network ; September 2015'; - -// ── Mocks (declared before any require of app/routes) ──────────────────────── - -jest.mock('../src/stellar/sep10', () => ({ - buildChallenge: jest.fn(), - verifyChallenge: jest.fn(), - NETWORK_PASSPHRASE: 'Test SDF Network ; September 2015', -})); - -jest.mock('../src/jwtKeys', () => ({ - getSigningKey: () => ({ secret: 'test-jwt-secret', kid: 'test-kid' }), -})); - -// ── Imports (after mocks are registered) ───────────────────────────────────── -const app = require('../src/app'); -const sep10 = require('../src/stellar/sep10'); - -// ── Helpers ─────────────────────────────────────────────────────────────────── -const MOCK_NONCE = 'dGVzdG5vbmNlMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI='; - -function buildSignedChallenge() { - const account = new StellarSdk.Account(serverKeypair.publicKey(), '-1'); - const tx = new StellarSdk.TransactionBuilder(account, { - fee: StellarSdk.BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }) - .addOperation( - StellarSdk.Operation.manageData({ - name: 'localhost auth', - value: MOCK_NONCE, - source: VALID_PUBLIC_KEY, - }) - ) - .setTimeout(300) - .build(); - tx.sign(serverKeypair); - tx.sign(clientKeypair); - return tx.toXDR(); -} - -const SIGNED_TX = buildSignedChallenge(); - -beforeEach(() => { - jest.clearAllMocks(); -}); - -// ── Tests ───────────────────────────────────────────────────────────────────── - -describe('POST /v1/auth/sep10', () => { - it('returns a challenge transaction for a valid public key', async () => { - sep10.buildChallenge.mockResolvedValue({ - transaction: SIGNED_TX, - nonce: MOCK_NONCE, - network_passphrase: NETWORK_PASSPHRASE, - }); - - const res = await request(app) - .post('/v1/auth/sep10') - .send({ public_key: VALID_PUBLIC_KEY }); - - expect(res.status).toBe(200); - expect(typeof res.body.transaction).toBe('string'); - expect(res.body.nonce).toBe(MOCK_NONCE); - expect(sep10.buildChallenge).toHaveBeenCalledWith(VALID_PUBLIC_KEY); - }); - - it('returns 400 when public_key is missing', async () => { - const res = await request(app).post('/v1/auth/sep10').send({}); - expect(res.status).toBe(400); - }); - - it('returns 400 when public_key is not a valid Stellar key', async () => { - const res = await request(app) - .post('/v1/auth/sep10') - .send({ public_key: 'not-a-stellar-key' }); - expect(res.status).toBe(400); - }); -}); - -describe('POST /v1/auth/verify', () => { - it('returns a JWT for a valid signed transaction', async () => { - sep10.verifyChallenge.mockReturnValue(VALID_PUBLIC_KEY); - - const res = await request(app) - .post('/v1/auth/verify') - .send({ transaction: SIGNED_TX, nonce: MOCK_NONCE }); - - expect(res.status).toBe(200); - expect(typeof res.body.token).toBe('string'); - expect(res.body.wallet).toBe(VALID_PUBLIC_KEY); - }); - - it('returns 401 when the signature is invalid', async () => { - sep10.verifyChallenge.mockImplementation(() => { - throw new Error('Client signature missing or invalid'); - }); - - const res = await request(app) - .post('/v1/auth/verify') - .send({ transaction: SIGNED_TX, nonce: MOCK_NONCE }); - - expect(res.status).toBe(401); - expect(res.body.error).toMatch(/signature/i); - }); - - it('returns 401 when the challenge has expired', async () => { - sep10.verifyChallenge.mockImplementation(() => { - throw new Error('Challenge transaction has expired'); - }); - - const res = await request(app) - .post('/v1/auth/verify') - .send({ transaction: SIGNED_TX, nonce: MOCK_NONCE }); - - expect(res.status).toBe(401); - expect(res.body.error).toMatch(/expired/i); - }); -});