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
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ module.exports = {
transform: {
...tsJestTransformCfg,
},
// Set mongodb-memory-server env vars before any test file is loaded.
// setupFiles runs inside each worker process, so env vars are visible to MMS.
// This pins the binary to MongoDB 7.0 / ubuntu2204 to avoid glibc
// compatibility issues with the default 6.0.9 build on this machine.
setupFiles: ['./jest.setup.js'],
// Individual test timeout — generous enough for the in-memory MongoDB to
// start on first run (binary download already done after that).
testTimeout: 30_000,
};
4 changes: 4 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Tell mongodb-memory-server to use MongoDB 7.0.14.
// The 6.0.9 binary downloaded previously was corrupt (truncated download),
// causing a SIGSEGV on startup. 7.0.14 is verified to work on this machine.
process.env.MONGOMS_VERSION = '7.0.14';
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
"format": "prettier --write .",
"test": "jest"
},
"mongodbMemoryServer": {
"version": "7.0.14"
},
"dependencies": {
"bcryptjs": "2.4.3",
"compression": "1.7.4",
Expand Down
75 changes: 75 additions & 0 deletions src/controllers/adminController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import { suspendUser as suspendUserService } from '../services/adminService';
import AppError from '../utils/AppError';

// ─── Request body type ─────────────────────────────────────────────────────────

interface SuspendUserBody {
reason?: unknown;
ban?: unknown;
}

// ─── Controller ────────────────────────────────────────────────────────────────

/**
* PUT /api/v1/admin/users/:id/suspend
*
* Suspends (or permanently bans) a user or driver account.
* The route is protected by `authenticate` + `requireRole(UserRole.ADMIN)`.
*
* Body:
* - reason {string} Required — audit trail description.
* - ban {boolean} Optional — true applies a permanent ban instead of suspension.
*
* Responds:
* 200 — success, returns the updated user document.
*/
export const suspendUser = async (
req: Request<{ id: string }, unknown, SuspendUserBody>,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
// req.user is guaranteed by the authenticate middleware
if (!req.user) {
throw new AppError(
'Authentication required.',
StatusCodes.UNAUTHORIZED,
);
}

const { id: targetUserId } = req.params;
const { reason, ban } = req.body;

// Input validation
if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {
throw new AppError(
'A reason is required to suspend or ban a user.',
StatusCodes.BAD_REQUEST,
);
}

if (ban !== undefined && typeof ban !== 'boolean') {
throw new AppError(
'"ban" must be a boolean value.',
StatusCodes.BAD_REQUEST,
);
}

const { user, action } = await suspendUserService({
targetUserId,
adminId: req.user._id.toString(),
reason: reason.trim(),
ban: ban === true,
});

res.status(StatusCodes.OK).json({
status: 'success',
message: `User has been ${action} successfully.`,
data: { user },
});
} catch (error) {
next(error);
}
};
144 changes: 144 additions & 0 deletions src/controllers/deliveryController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import * as deliveryService from '../services/deliveryService';
import { CreateDeliveryInput } from '../services/deliveryService';
import AppError from '../utils/AppError';

// ─── Request body type ─────────────────────────────────────────────────────────

type CreateDeliveryBody = CreateDeliveryInput;

// ─── Validation helpers ────────────────────────────────────────────────────────

/**
* Returns a list of missing required field paths for an address object.
*/
const validateAddress = (address: unknown, prefix: string): string[] => {
const errors: string[] = [];
const required = ['street', 'city', 'state', 'postalCode', 'country'] as const;

if (typeof address !== 'object' || address === null) {
return [`${prefix} is required and must be an object`];
}

const addr = address as Record<string, unknown>;
for (const field of required) {
if (!addr[field] || typeof addr[field] !== 'string') {
errors.push(`${prefix}.${field} is required`);
}
}
return errors;
};

/**
* Returns a list of missing / invalid fields for a sender or recipient object.
*/
const validateParty = (party: unknown, role: 'sender' | 'recipient'): string[] => {
const errors: string[] = [];

if (typeof party !== 'object' || party === null) {
return [`${role} is required and must be an object`];
}

const p = party as Record<string, unknown>;

if (!p.name || typeof p.name !== 'string') errors.push(`${role}.name is required`);
if (!p.email || typeof p.email !== 'string') errors.push(`${role}.email is required`);
if (!p.phone || typeof p.phone !== 'string') errors.push(`${role}.phone is required`);
if (!p.stellarAddress || typeof p.stellarAddress !== 'string') {
errors.push(`${role}.stellarAddress is required`);
}

errors.push(...validateAddress(p.address, `${role}.address`));

return errors;
};

/**
* Validates the top-level request body and returns an array of error messages.
* An empty array means the body is valid.
*/
const validateCreateDeliveryBody = (body: Partial<CreateDeliveryBody>): string[] => {
const errors: string[] = [];

errors.push(...validateParty(body.sender, 'sender'));
errors.push(...validateParty(body.recipient, 'recipient'));

// packageDetails
if (typeof body.packageDetails !== 'object' || body.packageDetails === null) {
errors.push('packageDetails is required and must be an object');
} else {
// Cast through unknown to allow dynamic key access on the typed sub-object
const pkg = body.packageDetails as unknown as Record<string, unknown>;
if (typeof pkg['weight'] !== 'number' || (pkg['weight'] as number) < 0) {
errors.push('packageDetails.weight must be a non-negative number');
}
if (!pkg['description'] || typeof pkg['description'] !== 'string') {
errors.push('packageDetails.description is required');
}
if (typeof pkg['fragile'] !== 'boolean') {
errors.push('packageDetails.fragile must be a boolean');
}
if (pkg['dimensions'] !== undefined) {
const dims = pkg['dimensions'] as Record<string, unknown>;
for (const dim of ['length', 'width', 'height']) {
if (typeof dims[dim] !== 'number' || (dims[dim] as number) < 0) {
errors.push(`packageDetails.dimensions.${dim} must be a non-negative number`);
}
}
}
}

// escrow
if (typeof body.escrow !== 'object' || body.escrow === null) {
errors.push('escrow is required and must be an object');
} else {
// Cast through unknown to allow dynamic key access on the typed sub-object
const escrow = body.escrow as unknown as Record<string, unknown>;
if (typeof escrow['amount'] !== 'number' || (escrow['amount'] as number) < 0) {
errors.push('escrow.amount must be a non-negative number');
}
if (escrow['stellarAsset'] !== undefined && typeof escrow['stellarAsset'] !== 'string') {
errors.push('escrow.stellarAsset must be a string');
}
}

return errors;
};

// ─── Controller ────────────────────────────────────────────────────────────────

/**
* POST /api/v1/deliveries
*
* Creates a new delivery record (off-chain metadata only).
* The on-chain Soroban contract interaction happens in a subsequent step.
*/
export const createDelivery = async (
req: Request<unknown, unknown, CreateDeliveryBody>,
res: Response,
next: NextFunction,
): Promise<void> => {
try {
const validationErrors = validateCreateDeliveryBody(req.body);

if (validationErrors.length > 0) {
throw new AppError(
`Validation failed: ${validationErrors.join('; ')}`,
StatusCodes.BAD_REQUEST,
);
}

const delivery = await deliveryService.createDelivery(req.body);

res.status(StatusCodes.CREATED).json({
status: 'success',
message: 'Delivery created successfully',
data: {
delivery,
},
});
} catch (error) {
next(error);
}
};
95 changes: 95 additions & 0 deletions src/middleware/authenticate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { StatusCodes } from 'http-status-codes';
import User, { IUser } from '../models/User';
import AppError from '../utils/AppError';

// ─── Augment Express Request ───────────────────────────────────────────────────

declare global {
namespace Express {
interface Request {
/** Populated by the `authenticate` middleware after token verification. */
user?: IUser;
}
}
}

// ─── JWT payload shape ────────────────────────────────────────────────────────

interface JwtPayload {
id: string;
iat?: number;
exp?: number;
}

// ─── Middleware ───────────────────────────────────────────────────────────────

/**
* Verifies the `Authorization: Bearer <token>` header, loads the matching
* User document from the database, and attaches it to `req.user`.
*
* Throws 401 if the token is missing, malformed, expired, or references a
* user that no longer exists.
* Throws 403 if the user account has been suspended or banned.
*/
const authenticate = async (
req: Request,
_res: Response,
next: NextFunction,
): Promise<void> => {
try {
// 1. Extract token from Authorization header
const authHeader = req.headers.authorization;

if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new AppError(
'Authentication required. Please provide a valid Bearer token.',
StatusCodes.UNAUTHORIZED,
);
}

const token = authHeader.split(' ')[1];

// 2. Verify and decode the JWT
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new AppError('Server misconfiguration: JWT secret not set.', StatusCodes.INTERNAL_SERVER_ERROR);
}

let decoded: JwtPayload;
try {
decoded = jwt.verify(token, secret) as JwtPayload;
} catch {
throw new AppError(
'Invalid or expired token. Please log in again.',
StatusCodes.UNAUTHORIZED,
);
}

// 3. Load the user from DB (re-validates they still exist)
const user = await User.findById(decoded.id).select('+password');
if (!user) {
throw new AppError(
'The user associated with this token no longer exists.',
StatusCodes.UNAUTHORIZED,
);
}

// 4. Block suspended or banned accounts
if (user.status === 'suspended' || user.status === 'banned') {
throw new AppError(
`Your account has been ${user.status}. Please contact support.`,
StatusCodes.FORBIDDEN,
);
}

// 5. Attach user to request for downstream middleware/controllers
req.user = user;
next();
} catch (error) {
next(error);
}
};

export default authenticate;
42 changes: 42 additions & 0 deletions src/middleware/requireRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Request, Response, NextFunction } from 'express';
import { StatusCodes } from 'http-status-codes';
import { UserRole } from '../models/User';
import AppError from '../utils/AppError';

/**
* Role-based access control middleware factory.
*
* Returns a middleware that allows the request to proceed only when
* `req.user.role` is included in the provided `allowedRoles` list.
*
* Must be used **after** the `authenticate` middleware so `req.user` is set.
*
* @example
* router.put('/users/:id/suspend', authenticate, requireRole(UserRole.ADMIN), suspendUser);
*/
const requireRole = (...allowedRoles: UserRole[]) => {
return (req: Request, _res: Response, next: NextFunction): void => {
if (!req.user) {
// Defensive: authenticate should always run first
return next(
new AppError(
'Authentication required. Please provide a valid Bearer token.',
StatusCodes.UNAUTHORIZED,
),
);
}

if (!allowedRoles.includes(req.user.role as UserRole)) {
return next(
new AppError(
'Access denied. You do not have permission to perform this action.',
StatusCodes.FORBIDDEN,
),
);
}

next();
};
};

export default requireRole;
Loading