diff --git a/package-lock.json b/package-lock.json index e6ab68e..fd91f76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "stellar-tags-root", "version": "1.0.0", + "dependencies": { + "bad-words": "^3.0.4", + "xss": "^1.0.15" + }, "devDependencies": { "@eslint/js": "^10.0.1", "artillery": "^2.0.33", @@ -1039,6 +1043,7 @@ "integrity": "sha512-PpLsoDQ3AMmKZ0VU+0GrmqMxgp/sExjlVm4R+nLWngeoEGAzOIPVifaxKGU5gMv+nWELUoHfvrolWD+ZS/nFJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -2295,6 +2300,7 @@ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3300,6 +3306,7 @@ "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~8.3.0" } @@ -3356,6 +3363,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3777,6 +3785,24 @@ "dev": true, "license": "MIT" }, + "node_modules/bad-words": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/bad-words/-/bad-words-3.0.4.tgz", + "integrity": "sha512-v/Q9uRPH4+yzDVLL4vR1+S9KoFgOEUl5s4axd6NIAq8SV2mradgi4E8lma/Y0cw1ltVdvyegCQQKffCPRCp8fg==", + "license": "MIT", + "dependencies": { + "badwords-list": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/badwords-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-1.0.0.tgz", + "integrity": "sha512-oWhaSG67e+HQj3OGHQt2ucP+vAPm1wTbdp2aDHeuh4xlGXBdWwzZ//pfu6swf5gZ8iX0b7JgmSo8BhgybbqszA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4450,6 +4476,12 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4572,6 +4604,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssfilter": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cssfilter/-/cssfilter-0.0.10.tgz", + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==", + "license": "MIT" + }, "node_modules/csv-parse": { "version": "4.16.3", "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz", @@ -5064,6 +5102,7 @@ "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -6220,6 +6259,7 @@ "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -7282,6 +7322,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8304,6 +8345,22 @@ "node": ">=0.4.0" } }, + "node_modules/xss": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "license": "MIT", + "dependencies": { + "commander": "^2.20.3", + "cssfilter": "0.0.10" + }, + "bin": { + "xss": "bin/xss" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index d432d36..61fa25f 100644 --- a/package.json +++ b/package.json @@ -18,5 +18,9 @@ "lint-staged": { "stellar-payment-platform/**/*.js": "eslint", "payment-dashboard/**/*.{js,jsx}": "npm --prefix payment-dashboard run lint" + }, + "dependencies": { + "bad-words": "^3.0.4", + "xss": "^1.0.15" } } diff --git a/stellar-payment-platform/package-lock.json b/stellar-payment-platform/package-lock.json index b8fb2c0..6667cba 100644 --- a/stellar-payment-platform/package-lock.json +++ b/stellar-payment-platform/package-lock.json @@ -12,6 +12,7 @@ "@faker-js/faker": "^10.5.0", "@prisma/client": "^6.1.0", "@stellar/stellar-sdk": "^16.0.1", + "bad-words": "^3.0.4", "compression": "^1.8.1", "connect-timeout": "^1.9.1", "cors": "^2.8.5", @@ -63,6 +64,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1152,6 +1154,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -1756,6 +1759,24 @@ "@babel/core": "^7.0.0" } }, + "node_modules/bad-words": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/bad-words/-/bad-words-3.0.4.tgz", + "integrity": "sha512-v/Q9uRPH4+yzDVLL4vR1+S9KoFgOEUl5s4axd6NIAq8SV2mradgi4E8lma/Y0cw1ltVdvyegCQQKffCPRCp8fg==", + "license": "MIT", + "dependencies": { + "badwords-list": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/badwords-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/badwords-list/-/badwords-list-1.0.0.tgz", + "integrity": "sha512-oWhaSG67e+HQj3OGHQt2ucP+vAPm1wTbdp2aDHeuh4xlGXBdWwzZ//pfu6swf5gZ8iX0b7JgmSo8BhgybbqszA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1941,6 +1962,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3226,6 +3248,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -3272,6 +3295,7 @@ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 16" }, @@ -6232,6 +6256,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "6.19.3", "@prisma/engines": "6.19.3" diff --git a/stellar-payment-platform/package.json b/stellar-payment-platform/package.json index 3ade911..4b5f5ac 100644 --- a/stellar-payment-platform/package.json +++ b/stellar-payment-platform/package.json @@ -23,6 +23,7 @@ "@faker-js/faker": "^10.5.0", "@prisma/client": "^6.1.0", "@stellar/stellar-sdk": "^16.0.1", + "bad-words": "^3.0.4", "compression": "^1.8.1", "connect-timeout": "^1.9.1", "cors": "^2.8.5", diff --git a/stellar-payment-platform/receipts.test.js b/stellar-payment-platform/receipts.test.js index 60ed232..70ab6fd 100644 --- a/stellar-payment-platform/receipts.test.js +++ b/stellar-payment-platform/receipts.test.js @@ -34,6 +34,13 @@ jest.mock('dotenv', () => ({ config: jest.fn() })); jest.mock('./src/cleanup-cron', () => ({ scheduleCleanupJob: jest.fn() })); +// bad-words ships as ESM; Jest runs in CJS mode — mock it to avoid transform errors. +jest.mock('bad-words', () => { + return jest.fn().mockImplementation(() => ({ + isProfane: jest.fn(() => false), + })); +}); + // The receipts endpoint never touches the database, but loading server.js does // instantiate the Prisma client — mock it so no real connection is created. jest.mock('./prismaClient', () => ({ diff --git a/stellar-payment-platform/server.js b/stellar-payment-platform/server.js index 8e0ef5c..0b996a7 100644 --- a/stellar-payment-platform/server.js +++ b/stellar-payment-platform/server.js @@ -6,11 +6,16 @@ const RedisStore = require('rate-limit-redis'); const { createClient } = require('redis'); const { prisma } = require('./prismaClient'); const { scheduleCleanupJob } = require('./src/cleanup-cron'); +const Filter = require('bad-words'); +const dotenv = require('dotenv'); const timeout = require('connect-timeout'); const compression = require('compression'); const v1Router = require('./src/routes/v1'); +const {verifyMultiSignerThreshold,} = require('./src/multisigner-verifier'); +const xss = require('xss'); +const { StrKey } = require('@stellar/stellar-sdk'); -require('dotenv').config(); +dotenv.config(); const app = express(); @@ -98,6 +103,394 @@ app.use(compression({ threshold: 1024 })); scheduleCleanupJob(prisma); +const USER_DATABASE = { + 'client*localhost': 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', + 'lekan*localhost': 'GAPUQZH3WZUXHEMUGZN5ZYU4D4GHCFEMOGUINU6MF345GBD2QXNYYIEQ', +}; + +const DEFAULT_FEDERATION_DOMAIN = 'localhost'; + +const normalizeNameTag = (value) => { + const trimmed = typeof value === 'string' ? value.trim() : ''; + if (!trimmed) { + return ''; + } + return trimmed.includes('*') ? trimmed : `${trimmed}*${DEFAULT_FEDERATION_DOMAIN}`; +}; + +// --------------------------------------------------------------------------- +// #51 — ETag Caching Middleware for Federation Endpoint +// --------------------------------------------------------------------------- +const etagCache = (req, res, next) => { + const originalJson = res.json.bind(res); + + res.json = (body) => { + const bodyString = JSON.stringify(body); + const hash = crypto.createHash('sha256').update(bodyString).digest('hex'); + const etag = `"${hash}"`; + + res.set('ETag', etag); + + const clientEtag = req.get('If-None-Match'); + if (clientEtag && clientEtag === etag) { + return res.status(304).end(); + } + + return originalJson(body); + }; + + next(); +}; + +app.get('/federation', etagCache, async (req, res, next) => { + const { q, type } = req.query; + const queryValue = typeof q === 'string' ? q.trim() : ''; + + if (!queryValue) { + const error = new Error("Missing 'q' parameter"); + error.statusCode = 400; + return next(error); + } + + try { + if (type === 'id') { + const row = await prisma.user.findFirst({ + where: { address: { equals: queryValue, mode: 'insensitive' } }, + select: { username: true, address: true, memoType: true, memo: true }, + }); + + if (!row) { + const notFoundError = new Error('Address not found'); + notFoundError.statusCode = 404; + return next(notFoundError); + } + const response = { + stellar_address: `${row.username}*${process.env.DOMAIN || 'localhost'}`, + account_id: row.address, + }; + if (row.memoType) { + response.memo_type = row.memoType; + response.memo = row.memo; + } + return res.json(response); + } else if (type === 'name' || !type) { + const nameTag = normalizeNameTag(queryValue); + const queryName = nameTag.toLowerCase(); + + const row = await prisma.user.findUnique({ + where: { username: queryName }, + select: { address: true, memoType: true, memo: true }, + }); + + const address = row?.address || USER_DATABASE[queryName]; + + if (!address) { + const notFoundError = new Error('Name tag not found'); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + const response = { + stellar_address: address, + account_id: address, + }; + if (row?.memoType) { + response.memo_type = row.memoType; + response.memo = row.memo; + } + return res.json(response); + } else { + return res.status(400).json({ + error: "Unsupported query type. Supported types: 'id', 'name'", + }); + } + } catch { + const dbError = new Error('Database lookup failed'); + dbError.statusCode = 500; + return next(dbError); + } +}); + +// Initialise profanity filter once at module load (reused across requests). +const profanityFilter = new Filter(); +const VALID_MEMO_TYPES = ['text', 'id', 'hash']; +const MEMO_ID_RE = /^\d+$/; +const MEMO_HASH_RE = /^[0-9a-fA-F]{64}$/; + +const validateMemo = (memoType, memo) => { + if (!memoType && !memo) return null; + if (memoType && !memo) return 'memo is required when memo_type is provided.'; + if (!memoType && memo) return 'memo_type is required when memo is provided.'; + if (!VALID_MEMO_TYPES.includes(memoType)) { + return `memo_type must be one of: ${VALID_MEMO_TYPES.join(', ')}.`; + } + if (memoType === 'text' && Buffer.byteLength(memo, 'utf8') > 28) { + return 'memo of type text must not exceed 28 bytes.'; + } + if (memoType === 'id') { + if (!MEMO_ID_RE.test(memo) || BigInt(memo) > 18446744073709551615n) { + return 'memo of type id must be a valid 64-bit unsigned integer.'; + } + } + if (memoType === 'hash' && !MEMO_HASH_RE.test(memo)) { + return 'memo of type hash must be a 64-character hex string (32 bytes).'; + } + return null; +}; + +/** + * Registration endpoint with multi-signer threshold verification + * + * For single-signer accounts: + * - Signature must be the account's public key or a registered signer + * - Basic validation of address format + * + * For multi-signer accounts (enterprise): + * - Fetches account signers and thresholds from Horizon + * - Validates that provided signature(s) meet minimum threshold + * - Ensures authorization requirements are satisfied + */ +app.post('/register', async (req, res, next) => { + if (!req.is('application/json')) { + return res.status(415).json({ error: "Unsupported Media Type. Please send application/json" }); + } + const safeUsername = xss(req.body.username); + const username = normalizeNameTag(safeUsername); + const address = typeof req.body.address === 'string' ? req.body.address.trim() : ''; + const memoType = typeof req.body.memo_type === 'string' ? req.body.memo_type.trim() : undefined; + const memo = typeof req.body.memo === 'string' ? req.body.memo.trim() : undefined; + const signature = typeof req.body.signature === 'string' ? req.body.signature.trim() : ''; + + if (address.toUpperCase().startsWith('S')) { + return res.status(400).json({ error: "Never share your Secret Key. Please register using your Public Key (starts with G)." }); + } + + if (!username || !address) { + return res.status(400).json({ error: 'Missing required fields: username and address are both required.' }); + } + + // Extract the username part before the * for profanity check and length validation + const usernameLocalPart = username.includes('*') ? username.split('*')[0] : username; + + if (usernameLocalPart.length < 3) { + return res.status(400).json({ error: "Username must be at least 3 characters long." }); + } + + // Reject usernames containing profanity or offensive words. + if (profanityFilter.isProfane(usernameLocalPart)) { + return res.status(400).json({ error: 'Username contains restricted words' }); + } + + if (!StrKey.isValidEd25519PublicKey(address)) { + const error = new Error('Invalid Stellar Public Key format.'); + error.statusCode = 400; + return next(error); + } + + const memoError = validateMemo(memoType, memo); + if (memoError) { + return res.status(400).json({ error: memoError }); + } + + // Signature is optional for legacy single-signer registrations. + // If provided, validate its format and run multi-signer verification. + if (signature && !StrKey.isValidEd25519PublicKey(signature)) { + const error = new Error('Invalid Stellar Public Key format.'); + error.statusCode = 400; + return next(error); + } + + const normalizedUsername = username.toLowerCase(); + + const RESERVED_NAMES = ['admin', 'root', 'support', 'system', 'stellar', 'api', 'help']; + if (RESERVED_NAMES.includes(normalizedUsername)) { + return res.status(403).json({ error: "This username is reserved and cannot be registered." }); + } + + try { + const existing = await prisma.user.findUnique({ + where: { address } + }); + + if (existing) { + const conflictError = new Error('Address already registered'); + conflictError.statusCode = 409; + return next(conflictError); + } + let verificationResult = null; + if (signature) { + verificationResult = await verifyMultiSignerThreshold(address, [signature], { + operationType: 'management', + }); + + if (!verificationResult.success) { + const verificationError = new Error( + verificationResult.errorMessage || 'Signature verification failed' + ); + verificationError.statusCode = 401; + throw verificationError; + } + } + + await prisma.user.create({ + data: { + username: normalizedUsername, + address, + ...(memoType && { memoType, memo }), + }, + }); + + return res.status(201).json({ + ok: true, + username: normalizedUsername, + address, + federation_address: `${normalizedUsername}*${process.env.DOMAIN || 'localhost'}`, + ...(verificationResult && { + verification: { + accountId: verificationResult.accountId, + signerCount: verificationResult.signerCount, + thresholdMet: verificationResult.success, + requiredThreshold: verificationResult.requiredThreshold, + providedWeight: verificationResult.totalWeight, + }, + }), + ...(memoType && { memo_type: memoType, memo }), + }); + } catch (error) { + if (error.code === 'SQLITE_CONSTRAINT' || (error.message && error.message.includes('UNIQUE'))) { + return res.status(409).json({ error: 'Username is already taken. Please choose another.' }); + } + + // Handle verification errors + if (error.message && error.message.includes('Account not found')) { + const notFoundError = new Error(`Account not found on Horizon: ${address}`); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + // Handle signature verification errors + if (error.statusCode === 401) { + return next(error); + } + + // Handle other errors + console.error('Registration error:', error.message); + const registrationError = new Error(`Registration verification failed: ${error.message}`); + registrationError.statusCode = 500; + return next(registrationError); + } +}); + +app.all('/register', (req, res) => res.status(405).json({ error: "Method Not Allowed" })); + +app.get('/lookup', async (req, res, next) => { + const address = typeof req.query.address === 'string' ? req.query.address.trim() : ''; + const search = typeof req.query.search === 'string' ? req.query.search.trim() : ''; + + if (!address && !search) { + const error = new Error("Missing required parameter: provide 'address' for exact lookup or 'search' for paginated search"); + error.statusCode = 400; + return next(error); + } + + if (address) { + try { + const row = await prisma.user.findUnique({ + where: { address }, + select: { username: true }, + }); + + if (!row) { + const notFoundError = new Error('Username not found for this address'); + notFoundError.statusCode = 404; + return next(notFoundError); + } + + return res.json({ username: row.username, address }); + } catch { + const dbError = new Error('Database lookup failed'); + dbError.statusCode = 500; + return next(dbError); + } + } + + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10)); + const skip = (page - 1) * limit; + + const where = { + OR: [ + { username: { contains: search, mode: 'insensitive' } }, + { address: { contains: search, mode: 'insensitive' } }, + ], + }; + + try { + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalCount / limit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt.toISOString(), + })); + + return res.json({ data, totalCount, totalPages, currentPage: page }); + } catch { + const dbError = new Error('Database lookup failed'); + dbError.statusCode = 500; + return next(dbError); + } +}); + +app.get('/users', async (req, res, next) => { + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(100, Math.max(1, parseInt(req.query.limit) || 10)); + const search = typeof req.query.search === 'string' ? req.query.search : null; + const skip = (page - 1) * limit; + + const where = search + ? { + OR: [ + { username: { contains: search, mode: 'insensitive' } }, + { address: { contains: search, mode: 'insensitive' } }, + ], + } + : {}; + + try { + const [totalCount, rows] = await prisma.$transaction([ + prisma.user.count({ where }), + prisma.user.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]); + + const totalPages = Math.ceil(totalCount / limit); + const data = rows.map((user) => ({ + username: user.username, + address: user.address, + created_at: user.createdAt.toISOString(), + })); + + res.json({ data, totalCount, totalPages, currentPage: page }); + } catch { + const dbError = new Error('Database error'); + dbError.statusCode = 500; + return next(dbError); + } +}); // Mount v1 router for both legacy paths and explicit API versioning app.use('/', v1Router); app.use('/api/v1', v1Router); @@ -171,6 +564,7 @@ const gracefulShutdown = (server, pool, signal) => { app.use((err, req, res) => { console.error(err.stack); const statusCode = err.statusCode || 500; + res.status(statusCode).json({ success: false, message: err.message || 'Internal Server Error', diff --git a/stellar-payment-platform/server.test.js b/stellar-payment-platform/server.test.js index 6065f61..db57a0f 100644 --- a/stellar-payment-platform/server.test.js +++ b/stellar-payment-platform/server.test.js @@ -13,6 +13,18 @@ jest.mock('pdfkit', () => jest.fn()); // test process does not register a real timer. jest.mock('./src/cleanup-cron', () => ({ scheduleCleanupJob: jest.fn() })); +// bad-words ships as ESM; Jest runs in CJS mode — mock the module so the +// test suite can require server.js without a transform error. +jest.mock('bad-words', () => { + return jest.fn().mockImplementation(() => ({ + isProfane: jest.fn(() => false), + })); +}); +jest.mock('@prisma/client', () => ({ + Prisma: { PrismaClientKnownRequestError: class extends Error {} }, +})); + +// Prisma is mocked so the suite never touches a real database. jest.mock('./prismaClient', () => ({ prisma: { user: { diff --git a/stellar-payment-platform/sql-injection.test.js b/stellar-payment-platform/sql-injection.test.js index 8976a26..084bf10 100644 --- a/stellar-payment-platform/sql-injection.test.js +++ b/stellar-payment-platform/sql-injection.test.js @@ -25,6 +25,13 @@ jest.mock('@stellar/stellar-sdk', () => ({ })); jest.mock('./src/cleanup-cron', () => ({ scheduleCleanupJob: jest.fn() })); +// bad-words ships as ESM; Jest runs in CJS mode — mock to avoid transform errors. +jest.mock('bad-words', () => { + return jest.fn().mockImplementation(() => ({ + isProfane: jest.fn(() => false), + })); +}); + jest.mock('./prismaClient', () => ({ prisma: { user: {