diff --git a/.docker/Dockerfile b/.docker/Dockerfile index 52938c8..c162a8d 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -22,6 +22,7 @@ COPY . . RUN chmod +x ./scripts/*.sh || true # Set up git RUN apk add git + ADD git-config.txt ${WORKDIR} EXPOSE 7000 \ No newline at end of file diff --git a/src/__tests__/utils/validatePhoneNumber.test.js b/src/__tests__/utils/validatePhoneNumber.test.js new file mode 100644 index 0000000..24e5d3e --- /dev/null +++ b/src/__tests__/utils/validatePhoneNumber.test.js @@ -0,0 +1,194 @@ +const validatePhoneNumber = require('../../utils/validatePhoneNumber'); + +describe('validatePhoneNumber', () => { + let mockHelpers; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + + // Mock Joi helpers + mockHelpers = { + error: jest.fn((code) => ({ code, message: `Validation error: ${code}` })) + }; + }); + + describe('Valid phone numbers', () => { + it('should accept valid US/Canada phone numbers with exactly 10 digits after +1', () => { + const validNumbers = [ + '+12345678901', + '+15551234567', + '+19876543210', + '+11234567890' + ]; + + validNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toBe(number); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + + it('should reject phone numbers with less than or more than 10 digits after +1', () => { + const invalidNumbers = [ + '+123456789', // 9 digits + '+123456789012', // 12 digits + '+1', // only +1 + '+12', // only +1 and 1 digit + '+123', // only +1 and 2 digits + '+1234', // only +1 and 3 digits + '+12345', // only +1 and 4 digits + '+123456', // only +1 and 5 digits + '+1234567', // only +1 and 6 digits + '+12345678', // only +1 and 7 digits + '+123456789' // only +1 and 9 digits + ]; + + invalidNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + }); + + describe('Invalid phone numbers', () => { + it('should reject phone numbers that do not start with +', () => { + const invalidNumbers = [ + '1234567890', + '1555123456', + '1987654321', + '1123456789' + ]; + + invalidNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + + it('should reject phone numbers that do not start with +1', () => { + const invalidNumbers = [ + '+21234567890', // +2 + '+31234567890', // +3 + '+41234567890', // +4 + '+51234567890', // +5 + '+61234567890', // +6 + '+71234567890', // +7 + '+81234567890', // +8 + '+91234567890' // +9 + ]; + + invalidNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + + it('should reject phone numbers with non-digit characters after +1', () => { + const invalidNumbers = [ + '+1a23456789', + '+1-234-567-8900', + '+1 (234) 567-8900', + '+1.234.567.8900', + '+1_234_567_8900', + '+1 234 567 8900' + ]; + + invalidNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + + it('should reject phone numbers that are too short', () => { + const invalidNumbers = [ + '+1', // Only +1, no digits + '+', // Only + + '' // Empty string + ]; + + invalidNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + }); + + describe('Edge cases', () => { + it('should handle null and undefined values', () => { + const invalidValues = [null, undefined]; + + invalidValues.forEach(value => { + const result = validatePhoneNumber(value, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + + it('should handle non-string values', () => { + const invalidValues = [123, {}, [], true, false]; + + invalidValues.forEach(value => { + const result = validatePhoneNumber(value, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + + it('should handle strings with leading/trailing whitespace', () => { + const invalidNumbers = [ + ' +1234567890', + '+1234567890 ', + ' +1234567890 ', + '\t+1234567890', + '+1234567890\n' + ]; + + invalidNumbers.forEach(number => { + const result = validatePhoneNumber(number, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + }); + }); + + describe('Function behavior', () => { + it('should return the original value for valid phone numbers', () => { + const validNumber = '+12345678901'; + const result = validatePhoneNumber(validNumber, mockHelpers); + + expect(result).toBe(validNumber); + expect(typeof result).toBe('string'); + }); + + it('should call helpers.error with correct error code for invalid numbers', () => { + const invalidNumber = '1234567890'; + + validatePhoneNumber(invalidNumber, mockHelpers); + + expect(mockHelpers.error).toHaveBeenCalledTimes(1); + expect(mockHelpers.error).toHaveBeenCalledWith('any.invalid'); + }); + + it('should return error object from helpers.error for invalid numbers', () => { + const invalidNumber = '1234567890'; + const result = validatePhoneNumber(invalidNumber, mockHelpers); + + expect(result).toEqual({ code: 'any.invalid', message: 'Validation error: any.invalid' }); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/utils/validateSafeString.test.js b/src/__tests__/utils/validateSafeString.test.js new file mode 100644 index 0000000..be0cf84 --- /dev/null +++ b/src/__tests__/utils/validateSafeString.test.js @@ -0,0 +1,296 @@ +const validateSafeString = require('../../utils/validateSafeString'); + +describe('validateSafeString', () => { + let mockHelpers; + + beforeEach(() => { + // Mock Joi helpers + mockHelpers = { + error: jest.fn((code) => ({ code, message: `Validation error: ${code}` })) + }; + }); + + describe('Safe strings', () => { + it('should accept normal text strings', () => { + const safeStrings = [ + 'Hello, world!', + 'This is a normal message', + '1234567890', + 'Special characters: !@#$%^&*()', + 'Unicode: ñáéíóú', + 'Mixed case: Hello World', + 'Numbers and text: 123abc456def' + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + + it('should accept empty strings', () => { + const result = validateSafeString('', mockHelpers); + + expect(result).toBe(''); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + + it('should accept strings with spaces and punctuation', () => { + const safeStrings = [ + ' spaces ', + 'multiple spaces', + 'punctuation: . , ! ? ; : " \' ( ) [ ] { }', + 'newlines\nand\ttabs', + 'quotes: "double" and \'single\'' + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + }); + + describe('SQL Injection patterns', () => { + it('should allow strings with single quotes', () => { + const safeStrings = [ + "O'Connor", + "user's data", + "don't do this", + "it's dangerous", + "can't validate" + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + + it('should allow strings with escaped single quotes', () => { + const safeStrings = [ + "O\\'Connor", + "user\\'s data", + "don\\'t do this" + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + + it('should allow strings with semicolons', () => { + const safeStrings = [ + 'SELECT is a verb;', + 'multiple; statements; here;', + 'normal text; followed by semicolon' + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + + it('should reject strings with escaped semicolons', () => { + const dangerousStrings = [ + 'SELECT * FROM users\\;', + 'DROP TABLE users\\;' + ]; + + dangerousStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + + it('should reject strings with SQL comments', () => { + const dangerousStrings = [ + 'SELECT * FROM users -- comment', + 'DROP TABLE users--', + 'normal text -- followed by comment', + '-- start with comment', + 'text -- comment -- another comment' + ]; + + dangerousStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + + it('should reject strings with SQL keywords and other SQL syntax', () => { + const dangerousStrings = [ + 'DROP TABLE users', + 'DELETE FROM users', + 'INSERT INTO users', + 'UPDATE users SET', + 'SELECT * FROM users', + 'UNION SELECT something FROM somewhere', + 'CREATE TABLE test', + 'ALTER TABLE test', + 'EXECUTE procedure' + ]; + + dangerousStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + + it('should allow strings with SQL keywords in normal text', () => { + const safeStrings = [ + 'drop is a verb', + 'DELETE is a word', + 'insert is a verb', + 'update is a verb', + 'select is a verb', + 'union is a word', + 'create is a verb', + 'alter is a verb', + 'exec is short for execute', + 'execute is a word' + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + }); + + describe('XSS patterns', () => { + it('should reject strings with script tags', () => { + const dangerousStrings = [ + '', + '', + 'normal text ', + '', + '' + ]; + + dangerousStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + + it('should reject strings with javascript protocol', () => { + const dangerousStrings = [ + 'javascript:alert("xss")', + 'JAVASCRIPT:alert("xss")', + 'normal text javascript:alert("xss")', + 'javascript:document.cookie', + 'javascript:void(0)' + ]; + + dangerousStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + }); + + describe('Edge cases', () => { + it('should handle null and undefined values', () => { + const invalidValues = [null, undefined]; + + invalidValues.forEach(value => { + const result = validateSafeString(value, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + + it('should handle non-string values', () => { + const invalidValues = [123, {}, [], true, false]; + + invalidValues.forEach(value => { + const result = validateSafeString(value, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); + + it('should handle strings that contain safe parts of dangerous patterns', () => { + const safeStrings = [ + 'script is a word', + 'javascript is a programming language', + 'SELECT is a verb', + 'DROP is a verb', + 'INSERT is a verb', + 'UPDATE is a verb', + 'CREATE is a verb', + 'ALTER is a verb', + 'EXEC is short for execute', + 'UNION is a word' + ]; + + safeStrings.forEach(str => { + const result = validateSafeString(str, mockHelpers); + + expect(result).toBe(str); + expect(mockHelpers.error).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Function behavior', () => { + it('should return the original value for safe strings', () => { + const safeString = 'Hello, world!'; + const result = validateSafeString(safeString, mockHelpers); + + expect(result).toBe(safeString); + expect(typeof result).toBe('string'); + }); + + it('should call helpers.error with correct error code for dangerous strings', () => { + const dangerousString = 'SELECT * FROM users'; + + validateSafeString(dangerousString, mockHelpers); + + expect(mockHelpers.error).toHaveBeenCalledTimes(1); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + + it('should return error object from helpers.error for dangerous strings', () => { + const dangerousString = 'SELECT * FROM users'; + const result = validateSafeString(dangerousString, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + }); + + it('should detect the first dangerous pattern in a string', () => { + const dangerousString = 'normal text more text'; + const result = validateSafeString(dangerousString, mockHelpers); + + expect(result).toEqual({ code: 'string.unsafe', message: 'Validation error: string.unsafe' }); + expect(mockHelpers.error).toHaveBeenCalledWith('string.unsafe'); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/utils/withRetryAndCatch.test.js b/src/__tests__/utils/withRetryAndCatch.test.js index eba3452..9c1c2f9 100644 --- a/src/__tests__/utils/withRetryAndCatch.test.js +++ b/src/__tests__/utils/withRetryAndCatch.test.js @@ -11,8 +11,7 @@ describe('withRetryAndCatch', () => { jest.resetModules(); jest.doMock('../../utils/withRetry', () => { return jest.fn((fn) => { - // Just return the handler as-is for test purposes - + // Return the handler as-is for test purposes return fn; }); }); @@ -24,17 +23,14 @@ describe('withRetryAndCatch', () => { const maybePromise = fn(...args); if (maybePromise && typeof maybePromise.catch === 'function') { - return maybePromise.catch(next); } return maybePromise; } catch (err) { if (typeof next === 'function') { - next(err); } else { - throw err; } } @@ -256,7 +252,9 @@ describe('withRetryAndCatch', () => { it('should work with async/await handlers', async () => { const asyncHandler = jest.fn(async (req, res) => { - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise(resolve => { + setTimeout(() => resolve(), 10); + }); return { processed: true }; }); diff --git a/src/middlewares/webhook.middleware.js b/src/middlewares/webhook.middleware.js new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/messaging.routes.js b/src/routes/messaging.routes.js index 272e90c..36341a6 100644 --- a/src/routes/messaging.routes.js +++ b/src/routes/messaging.routes.js @@ -1,9 +1,11 @@ const express = require('express'); const { messagingController } = require('../controllers'); +const validate = require('../middlewares/validate'); +const { incomingMessageValidation } = require('../validations'); const router = express.Router(); -router.post('/webhook/incoming-message', messagingController.handleIncomingMessage); +router.post('/webhook/incoming-message', validate(incomingMessageValidation), messagingController.handleIncomingMessage); router.get('/', (req, res) => { res.send('📨 Messaging API is working'); diff --git a/src/server.js b/src/server.js index 9b7733d..05952e9 100644 --- a/src/server.js +++ b/src/server.js @@ -4,10 +4,9 @@ const app = express(); const httpStatus = require('http-status'); const morgan = require('./config/morgan'); const { errorHandler, errorConverter } = require('./middlewares/error'); - +const chatbotRouter = require('./routes/chatbot.routes'); const authRoutes = require('./routes/auth.routes'); -const chatbotRouter = require('./routes/chatbot.route'); const edxRoutes = require('./routes/edx.routes'); const messagingRouter = require('./routes/messaging.routes'); diff --git a/src/services/databases/database.service.js b/src/services/databases/database.service.js index 146752e..ee4ee99 100644 --- a/src/services/databases/database.service.js +++ b/src/services/databases/database.service.js @@ -1,11 +1,12 @@ const { pool } = require('../../config/database'); +const logger = require('../../config/logger'); async function connectDB() { try { await pool.query('SELECT NOW()'); - console.log('PostgreSQL connected'); + logger.log('PostgreSQL connected'); } catch (error) { - console.error('PostgreSQL connection error', error); + logger.error('PostgreSQL connection error', error); process.exit(1); } } diff --git a/src/services/index.js b/src/services/index.js index b94f916..a3551b4 100644 --- a/src/services/index.js +++ b/src/services/index.js @@ -1,2 +1 @@ -// module.exports.blogService = require('./blog.service'); module.exports.messagingService = require('./messaging/messaging.service'); diff --git a/src/services/messaging/messaging.service.js b/src/services/messaging/messaging.service.js index 668e22d..f8fe3d4 100644 --- a/src/services/messaging/messaging.service.js +++ b/src/services/messaging/messaging.service.js @@ -1,7 +1,10 @@ +const { env } = require('../../config/config'); const logger = require('../../config/logger'); const twilioClient = require('../../config/twilio'); const SOURCES = require('../../utils/constants'); +const shouldValidate = env !== 'test'; + /** * * @param {Express.Request} req @@ -11,7 +14,7 @@ function processMessage(req, requestId) { const source = identifySource(req); if (source === SOURCES.TWILIO) { - twilioClient.webhook()(req); + twilioClient.webhook({ validate: shouldValidate })(req); logger.info('Received Twilio Message', { requestId, diff --git a/src/utils/validatePhoneNumber.js b/src/utils/validatePhoneNumber.js new file mode 100644 index 0000000..dbc4435 --- /dev/null +++ b/src/utils/validatePhoneNumber.js @@ -0,0 +1,22 @@ +/** + * @param {String} value + * @param {import('joi').CustomValidator} helpers + * @returns {import('joi').ErrorReport|String} + */ +function validatePhoneNumber(value, helpers) { + // Must be a string + if (typeof value !== 'string') { + return helpers.error('any.invalid'); + } + // Must start with +1 followed by exactly 10 digits (US/Canada NANP format) + const phoneRegex = /^\+1\d{10}$/; + + if (!phoneRegex.test(value)) { + return helpers.error('any.invalid'); + } + + return value; +}; + +module.exports = validatePhoneNumber; + diff --git a/src/utils/validateSafeString.js b/src/utils/validateSafeString.js new file mode 100644 index 0000000..499bfd7 --- /dev/null +++ b/src/utils/validateSafeString.js @@ -0,0 +1,30 @@ +/** + * @param {String} value + * @param {import('joi').CustomHelpers} helpers + * @returns {import('joi').ErrorReport|String} + */ +function validateSafeString(value, helpers) { + // Check if value is a string + if (typeof value !== 'string') { + return helpers.error('string.unsafe'); + } + + // Check for common SQL injection and XSS patterns + const dangerousPatterns = [ + /--/, // SQL comments: any occurrence of -- + // SQL keywords with other SQL syntax (e.g., SELECT ... FROM, DROP TABLE, etc.) + /\b(SELECT|DROP|DELETE|INSERT|UPDATE|UNION|CREATE|ALTER|EXEC|EXECUTE)\b\s+.*\b(FROM|TABLE|INTO|SET|PROCEDURE|VALUES|WHERE|JOIN|ON|BY)\b/i, + /