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,
+ /