Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ function login(req, res) {
const token = jwt.sign(
{ username: ADMIN_USERNAME },
ADMIN_JWT_SECRET,
{ expiresIn: ADMIN_JWT_EXPIRES_IN }
{ expiresIn: ADMIN_JWT_EXPIRES_IN, algorithm: 'HS256' }
);

return res.json({ success: true, token, expiresIn: ADMIN_JWT_EXPIRES_IN });
Expand Down
28 changes: 27 additions & 1 deletion api/controllers/orderController.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,28 @@ async function createOrder(req, res) {
? Math.round(clientTotal * 100) / 100
: Math.round(computedTotal * 100) / 100;

// ── DUPLICATE PROTECTION ────────────────────────────────────────────────────
const duplicateWindowMs = 2 * 60 * 1000;
if (isDbReady()) {
const duplicate = await Order.findOne({
phone: phoneDigits.slice(0, 15),
total: finalTotal,
created_at: { $gt: new Date(Date.now() - duplicateWindowMs) }
});
if (duplicate) {
return res.status(409).json({ success: false, message: 'Duplicate order detected. Please wait before placing another order.' });
}
} else {
const duplicate = memoryOrders.find(o =>
o.phone === phoneDigits.slice(0, 15) &&
o.total === finalTotal &&
new Date(o.created_at).getTime() > Date.now() - duplicateWindowMs
);
if (duplicate) {
return res.status(409).json({ success: false, message: 'Duplicate order detected. Please wait before placing another order.' });
}
}

const order_id = generateOrderId();
const orderDoc = {
order_id,
Expand Down Expand Up @@ -373,7 +395,9 @@ async function createOrder(req, res) {

async function getAllOrders(req, res) {
try {
const { status, search, from, to } = req.query;
const status = req.query.status ? String(req.query.status) : undefined;
const search = req.query.search ? String(req.query.search) : undefined;
const { from, to } = req.query;

if (!isDbReady()) {
let list = [...memoryOrders];
Expand Down Expand Up @@ -540,6 +564,8 @@ async function confirmPayment(req, res) {

async function updateOrderStatus(req, res) {
try {
const status = String(req.body.status || '').trim();

if (!ALLOWED_ORDER_STATUSES.includes(status)) {
return res.status(400).json({
success: false,
Expand Down
29 changes: 24 additions & 5 deletions api/controllers/otpController.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,24 @@ function generateOTP() {

async function sendOtp(req, res) {
try {
const { phone } = req.body;
const phone = String(req.body.phone || '').trim();

if (!phone || phone.length < 10) {
return res.status(400).json({ success: false, message: 'Invalid phone number' });
}

// DB-level rate limiting: prevent generating an OTP if one was created in the last 1 minute
const recentOtp = await Otp.findOne({ phone, created_at: { $gt: new Date(Date.now() - 60 * 1000) } });
if (recentOtp) {
return res.status(429).json({ success: false, message: 'Please wait before requesting a new OTP' });
}

await Otp.updateMany({ phone, used: false }, { used: true });

const otp = generateOTP();
const expires_at = new Date(Date.now() + 5 * 60 * 1000);

await Otp.create({ phone, otp, expires_at });
await Otp.create({ phone, otp, expires_at, attempts: 0 });

const apiKey = process.env.FAST2SMS_API_KEY;
if (apiKey && apiKey !== 'your_actual_api_key_here') {
Expand All @@ -44,17 +50,30 @@ async function sendOtp(req, res) {

async function verifyOtp(req, res) {
try {
const { phone, otp } = req.body;
const phone = String(req.body.phone || '').trim();
const otp = String(req.body.otp || '').trim();

// Find the most recent active OTP for this phone
const record = await Otp.findOne({
phone,
otp,
used: false,
expires_at: { $gt: new Date() },
}).sort({ created_at: -1 });

if (!record) {
return res.status(400).json({ success: false, message: 'Invalid or expired OTP' });
return res.status(400).json({ success: false, message: 'No valid OTP found or OTP expired' });
}

if (record.attempts >= 3) {
record.used = true; // invalidate it
await record.save();
return res.status(429).json({ success: false, message: 'Too many failed attempts, please request a new OTP' });
}

if (record.otp !== otp) {
record.attempts += 1;
await record.save();
return res.status(400).json({ success: false, message: 'Invalid OTP' });
}

record.used = true;
Expand Down
9 changes: 7 additions & 2 deletions api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ app.get('*', (req, res) => {
// ─── GLOBAL ERROR HANDLER ──────────────────────────────────────────────────────
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ success: false, message: 'Something went wrong!' });
const isProduction = process.env.NODE_ENV === 'production';
res.status(err.status || 500).json({
success: false,
message: err.message || 'Something went wrong!',
...( !isProduction && { stack: err.stack } )
});
});

// ─── LOCAL SERVER ───────────────────────────────────────────────────────────────
Expand All @@ -88,7 +93,7 @@ function startServer(port) {
});
}

if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') {
startServer(PORT);
}

Expand Down
4 changes: 3 additions & 1 deletion api/routes/adminRoutes.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const express = require('express');
const router = express.Router();
const { login } = require('../controllers/adminController');
const validate = require('../middlewares/validate');
const { adminLoginSchema } = require('../validators/adminValidator');

router.post('/login', login);
router.post('/login', validate(adminLoginSchema), login);

module.exports = router;
6 changes: 4 additions & 2 deletions api/routes/otpRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const { sendOtp, verifyOtp } = require('../controllers/otpController');
const validate = require('../middlewares/validate');
const { sendOtpSchema, verifyOtpSchema } = require('../validators/otpValidator');

const otpRateLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
Expand All @@ -11,7 +13,7 @@ const otpRateLimiter = rateLimit({
message: { success: false, message: 'Too many requests from this IP, please try again after 15 minutes' },
});

router.post('/send-otp', otpRateLimiter, sendOtp);
router.post('/verify-otp', verifyOtp);
router.post('/send-otp', otpRateLimiter, validate(sendOtpSchema), sendOtp);
router.post('/verify-otp', validate(verifyOtpSchema), verifyOtp);

module.exports = router;
17 changes: 17 additions & 0 deletions api/validators/adminValidator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
const { z } = require('zod');

const adminLoginSchema = z.object({
body: z.object({
username: z
.string({ required_error: 'Username is required' })
.trim()
.min(1, 'Username cannot be empty'),
password: z
.string({ required_error: 'Password is required' })
.min(1, 'Password cannot be empty'),
}),
});

module.exports = {
adminLoginSchema,
};
26 changes: 26 additions & 0 deletions api/validators/otpValidator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const { z } = require('zod');

const sendOtpSchema = z.object({
body: z.object({
phone: z
.string({ required_error: 'Phone number is required' })
.regex(/^[6-9]\d{9}$/, 'Invalid phone number format'),
}),
});

const verifyOtpSchema = z.object({
body: z.object({
phone: z
.string({ required_error: 'Phone number is required' })
.regex(/^[6-9]\d{9}$/, 'Invalid phone number format'),
otp: z
.string({ required_error: 'OTP is required' })
.length(6, 'OTP must be exactly 6 digits')
.regex(/^\d{6}$/, 'OTP must contain only numbers'),
}),
});

module.exports = {
sendOtpSchema,
verifyOtpSchema,
};
2 changes: 1 addition & 1 deletion middlewares/adminAuth.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function adminAuth(req, res, next) {
}

try {
const payload = jwt.verify(token, ADMIN_JWT_SECRET);
const payload = jwt.verify(token, ADMIN_JWT_SECRET, { algorithms: ['HS256'] });
req.admin = payload;
return next();
} catch (err) {
Expand Down
1 change: 1 addition & 0 deletions models/Otp.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const otpSchema = new mongoose.Schema({
otp: { type: String, required: true },
expires_at: { type: Date, required: true },
used: { type: Boolean, default: false },
attempts: { type: Number, default: 0 },
}, { timestamps: { createdAt: 'created_at' } });

// Auto-delete OTP documents after they expire (TTL index)
Expand Down
18 changes: 16 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"nodemailer": "^6.10.1",
"serverless-http": "^3.2.0",
"twilio": "^5.12.2",
"zod": "^3.22.4"
"zod": "^3.25.76"
},
"devDependencies": {
"jest": "^29.7.0",
Expand Down
4 changes: 2 additions & 2 deletions tests/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ process.env.ADMIN_JWT_SECRET = 'secret_test_key_123';
process.env.NODE_ENV = 'test';

// Load the express app
const { app } = require('../api/index');
const app = require('../api/index');

describe('Brownie-Bliss API Security & Endpoint Integration Tests', () => {
// Clear mock history before each test
Expand Down Expand Up @@ -71,7 +71,7 @@ describe('Brownie-Bliss API Security & Endpoint Integration Tests', () => {
.expect(400);

expect(res.body.success).toBe(false);
expect(res.body.message).toContain('required');
expect(JSON.stringify(res.body.errors)).toContain('required');
});

it('should reject invalid credentials with HTTP 401', async () => {
Expand Down