From 30b19b23092d16ddcb01430e19505c16302da101 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:13:56 +0100 Subject: [PATCH 01/20] feat: add request validation middleware with per-endpoint rate limiting and account lockout --- src/middleware/rateLimiter.ts | 117 ++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 src/middleware/rateLimiter.ts diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..e1ef373 --- /dev/null +++ b/src/middleware/rateLimiter.ts @@ -0,0 +1,117 @@ +import type { Request, Response, NextFunction } from "express"; +import rateLimit from "express-rate-limit"; + +interface RateLimitStore { + hits: Map; + failures: Map; +} + +const store: RateLimitStore = { + hits: new Map(), + failures: new Map(), +}; + +const LOCKOUT_THRESHOLD = 5; +const LOCKOUT_DURATION = 15 * 60 * 1000; + +function getOrCreateFailure(key: string) { + let failure = store.failures.get(key); + if (!failure || Date.now() > failure.lockedUntil) { + failure = { count: 0, lockedUntil: 0 }; + store.failures.set(key, failure); + } + return failure; +} + +export function recordFailedAttempt(key: string): { locked: boolean; attemptsRemaining: number } { + const failure = getOrCreateFailure(key); + failure.count += 1; + + if (failure.count >= LOCKOUT_THRESHOLD) { + failure.lockedUntil = Date.now() + LOCKOUT_DURATION; + return { locked: true, attemptsRemaining: 0 }; + } + + return { locked: false, attemptsRemaining: LOCKOUT_THRESHOLD - failure.count }; +} + +export function resetFailedAttempts(key: string): void { + store.failures.delete(key); +} + +export function isLockedOut(key: string): boolean { + const failure = store.failures.get(key); + if (!failure) return false; + if (Date.now() > failure.lockedUntil) { + store.failures.delete(key); + return false; + } + return true; +} + +export function getRemainingLockoutTime(key: string): number { + const failure = store.failures.get(key); + if (!failure) return 0; + return Math.max(0, failure.lockedUntil - Date.now()); +} + +export const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 200, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many requests, please try again later" }, + keyGenerator: (req) => req.ip ?? req.socket.remoteAddress ?? "unknown", +}); + +export const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many authentication attempts, please try again later" }, + keyGenerator: (req) => req.ip ?? req.socket.remoteAddress ?? "unknown", +}); + +export const strictAuthLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many login attempts, account temporarily locked" }, + keyGenerator: (req) => { + const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; + const email = req.body?.email as string | undefined; + return email ? `${ip}:${email}` : ip; + }, +}); + +export const apiLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: "Too many API requests, please try again later" }, + keyGenerator: (req) => { + const userId = (req as { user?: { userId?: string } }).user?.userId; + return userId ?? (req.ip ?? req.socket.remoteAddress ?? "unknown"); + }, +}); + +export function lockoutMiddleware(req: Request, res: Response, next: NextFunction): void { + const ip = req.ip ?? req.socket?.remoteAddress ?? "unknown"; + const email = req.body?.email as string | undefined; + const key = email ? `${ip}:${email}` : ip; + + if (isLockedOut(key)) { + const remainingMs = getRemainingLockoutTime(key); + const remainingMinutes = Math.ceil(remainingMs / 60000); + res.status(429).json({ + success: false, + error: `Account temporarily locked. Try again in ${remainingMinutes} minute(s)`, + }); + return; + } + + next(); +} From 311e1975b740e455680f67c025b1aee767233b81 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:05 +0100 Subject: [PATCH 02/20] feat: add comprehensive input sanitization middleware for XSS protection and URL validation --- src/middleware/sanitize.ts | 135 +++++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 src/middleware/sanitize.ts diff --git a/src/middleware/sanitize.ts b/src/middleware/sanitize.ts new file mode 100644 index 0000000..e627791 --- /dev/null +++ b/src/middleware/sanitize.ts @@ -0,0 +1,135 @@ +import type { Request, Response, NextFunction } from "express"; +import sanitizeHtml from "sanitize-html"; + +const sanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: [], + allowedAttributes: {}, + disallowedTagsMode: "discard", +}; + +function sanitizeString(value: string): string { + return sanitizeHtml(value, sanitizeOptions).trim(); +} + +function sanitizeValue(value: unknown): unknown { + if (typeof value === "string") { + return sanitizeString(value); + } + if (Array.isArray(value)) { + return value.map(sanitizeValue); + } + if (value !== null && typeof value === "object") { + return sanitizeObject(value as Record); + } + return value; +} + +function sanitizeObject(obj: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(obj)) { + sanitized[key] = sanitizeValue(value); + } + return sanitized; +} + +const DANGEROUS_URL_PATTERNS = [ + /^javascript:/i, + /^data:/i, + /^vbscript:/i, + /^file:/i, +]; + +function sanitizeUrl(url: string): string { + const trimmed = url.trim(); + for (const pattern of DANGEROUS_URL_PATTERNS) { + if (pattern.test(trimmed)) { + return ""; + } + } + return trimmed; +} + +export function sanitizeBody(req: Request, _res: Response, next: NextFunction): void { + if (req.body && typeof req.body === "object") { + req.body = sanitizeObject(req.body as Record); + } + next(); +} + +export function sanitizeQuery(req: Request, _res: Response, next: NextFunction): void { + if (req.query && typeof req.query === "object") { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(req.query)) { + const sanitizedValue = sanitizeValue(value); + sanitized[key] = typeof sanitizedValue === "string" ? sanitizedValue : String(sanitizedValue ?? ""); + } + req.query = sanitized; + } + next(); +} + +export function sanitizeParams(req: Request, _res: Response, next: NextFunction): void { + if (req.params && typeof req.params === "object") { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(req.params)) { + sanitized[key] = sanitizeValue(value); + } + req.params = sanitized as Record; + } + next(); +} + +export function validateUrlField(fieldName: string) { + return (req: Request, res: Response, next: NextFunction): void => { + const value = req.body?.[fieldName] as string | undefined; + if (value) { + const sanitized = sanitizeUrl(value); + if (!sanitized) { + res.status(400).json({ + success: false, + error: `Invalid URL provided for ${fieldName}`, + }); + return; + } + req.body[fieldName] = sanitized; + } + next(); + }; +} + +export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; + +export function validateFileSize(maxSize: number = MAX_UPLOAD_SIZE) { + return (req: Request, res: Response, next: NextFunction): void => { + const contentLength = req.headers["content-length"]; + if (contentLength && parseInt(contentLength, 10) > maxSize) { + res.status(413).json({ + success: false, + error: `File too large. Maximum size is ${Math.round(maxSize / 1024 / 1024)}MB`, + }); + return; + } + next(); + }; +} + +export const ALLOWED_CONTENT_TYPES = [ + "application/json", + "application/x-www-form-urlencoded", +]; + +export function validateContentType(allowedTypes: string[] = ALLOWED_CONTENT_TYPES) { + return (req: Request, res: Response, next: NextFunction): void => { + const contentType = req.headers["content-type"]; + if (contentType && !allowedTypes.some((type) => contentType.includes(type))) { + res.status(415).json({ + success: false, + error: `Unsupported content type: ${contentType}`, + }); + return; + } + next(); + }; +} + +export { sanitizeString, sanitizeUrl }; From 8c404e78040d4506c9ca2d3943f21c714427a7af Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:06 +0100 Subject: [PATCH 03/20] feat: add OpenAPI/Swagger configuration with comprehensive API documentation schemas --- src/config/swagger.ts | 184 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 src/config/swagger.ts diff --git a/src/config/swagger.ts b/src/config/swagger.ts new file mode 100644 index 0000000..10eb987 --- /dev/null +++ b/src/config/swagger.ts @@ -0,0 +1,184 @@ +import swaggerJsdoc from "swagger-jsdoc"; +import type { Options } from "swagger-jsdoc"; + +const options: Options = { + definition: { + openapi: "3.0.0", + info: { + title: "StellFlow Backend API", + version: "1.0.0", + description: "StellFlow Backend API - Express 5 + Prisma + PostgreSQL with Stellar blockchain integration", + contact: { + name: "StellFlow Team", + }, + }, + servers: [ + { + url: "http://localhost:3001", + description: "Development server", + }, + { + url: "https://api.stellflow.io", + description: "Production server", + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "Enter your JWT access token", + }, + cookieAuth: { + type: "apiKey", + in: "cookie", + name: "refreshToken", + description: "Refresh token stored in HTTP-only cookie", + }, + }, + schemas: { + User: { + type: "object", + properties: { + id: { type: "string", description: "User ID (cuid)" }, + fullname: { type: "string", description: "Full name" }, + email: { type: "string", format: "email", description: "Email address" }, + role: { type: "string", enum: ["FREELANCER", "CLIENT", "ADMIN"], description: "User role" }, + walletAddress: { type: "string", nullable: true, description: "Stellar wallet address" }, + profileImage: { type: "string", nullable: true, description: "Profile image URL" }, + country: { type: "string", nullable: true, description: "Country" }, + isVerified: { type: "boolean", description: "Email verification status" }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + Invoice: { + type: "object", + properties: { + id: { type: "string" }, + creatorId: { type: "string" }, + recipientId: { type: "string" }, + title: { type: "string" }, + description: { type: "string" }, + amount: { type: "number" }, + currency: { type: "string", default: "USDC" }, + status: { type: "string", enum: ["DRAFT", "PENDING", "FUNDED", "IN_ESCROW", "COMPLETED", "CANCELLED", "DISPUTED"] }, + dueDate: { type: "string", format: "date-time", nullable: true }, + paidAt: { type: "string", format: "date-time", nullable: true }, + txHash: { type: "string", nullable: true }, + contractId: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + Escrow: { + type: "object", + properties: { + id: { type: "string" }, + invoiceId: { type: "string" }, + clientId: { type: "string" }, + freelancerId: { type: "string" }, + contractId: { type: "string" }, + txHash: { type: "string", nullable: true }, + amount: { type: "number" }, + currency: { type: "string", default: "USDC" }, + status: { type: "string", enum: ["PENDING", "FUNDED", "RELEASED", "REFUNDED", "DISPUTED"] }, + fundedAt: { type: "string", format: "date-time", nullable: true }, + releasedAt: { type: "string", format: "date-time", nullable: true }, + refundedAt: { type: "string", format: "date-time", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + updatedAt: { type: "string", format: "date-time" }, + }, + }, + ErrorResponse: { + type: "object", + properties: { + success: { type: "boolean", example: false }, + error: { type: "string" }, + }, + }, + SuccessResponse: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + data: { type: "object" }, + }, + }, + PaginationMeta: { + type: "object", + properties: { + page: { type: "integer" }, + limit: { type: "integer" }, + total: { type: "integer" }, + totalPages: { type: "integer" }, + }, + }, + RegisterRequest: { + type: "object", + required: ["fullname", "email", "password", "role"], + properties: { + fullname: { type: "string", minLength: 2, description: "Full name" }, + email: { type: "string", format: "email", description: "Email address" }, + password: { type: "string", minLength: 8, description: "Password (min 8 characters)" }, + role: { type: "string", enum: ["FREELANCER", "CLIENT"], description: "User role" }, + walletAddress: { type: "string", description: "Stellar wallet address (optional)" }, + country: { type: "string", description: "Country (optional)" }, + }, + }, + LoginRequest: { + type: "object", + required: ["email", "password"], + properties: { + email: { type: "string", format: "email" }, + password: { type: "string", minLength: 1 }, + }, + }, + AuthResponse: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + data: { + type: "object", + properties: { + user: { $ref: "#/components/schemas/User" }, + accessToken: { type: "string", description: "JWT access token" }, + refreshToken: { type: "string", description: "JWT refresh token" }, + }, + }, + }, + }, + CreateInvoiceRequest: { + type: "object", + required: ["recipientId", "title", "description", "amount"], + properties: { + recipientId: { type: "string", description: "Recipient user ID" }, + title: { type: "string", description: "Invoice title" }, + description: { type: "string", description: "Invoice description" }, + amount: { type: "number", exclusiveMinimum: 0, description: "Amount" }, + currency: { type: "string", default: "USDC", description: "Currency code" }, + dueDate: { type: "string", format: "date-time", description: "Due date (ISO 8601)" }, + }, + }, + CreateEscrowRequest: { + type: "object", + required: ["invoiceId", "contractId"], + properties: { + invoiceId: { type: "string", description: "Invoice ID" }, + contractId: { type: "string", description: "Stellar contract ID" }, + }, + }, + FundEscrowRequest: { + type: "object", + required: ["txHash"], + properties: { + txHash: { type: "string", description: "Stellar transaction hash" }, + }, + }, + }, + }, + }, + apis: ["./src/routes/*.ts"], +}; + +export const swaggerSpec = swaggerJsdoc(options); From ac0e52b5c1fba0adda697dbbe476cdf0bb1d1ca5 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:07 +0100 Subject: [PATCH 04/20] feat: add Swagger JSDoc annotations and rate limiting to auth routes --- src/routes/auth.routes.ts | 114 +++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 3 deletions(-) diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts index bdff257..0f0e961 100644 --- a/src/routes/auth.routes.ts +++ b/src/routes/auth.routes.ts @@ -3,13 +3,121 @@ import { register, login, logout, refreshToken, getMe } from "../controllers/aut import { validate } from "../middleware/validate.js"; import { authenticate } from "../middleware/auth.js"; import { registerSchema, loginSchema } from "../schemas/auth.schema.js"; +import { authLimiter, strictAuthLimiter, lockoutMiddleware } from "../middleware/rateLimiter.js"; const router = Router(); -router.post("/register", validate(registerSchema), register); -router.post("/login", validate(loginSchema), login); +/** + * @openapi + * /api/auth/register: + * post: + * tags: [Auth] + * summary: Register a new user + * description: Create a new user account with email and password + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RegisterRequest' + * responses: + * 201: + * description: User registered successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 400: + * description: Validation error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 409: + * description: Email already registered + * 429: + * description: Too many requests + */ +router.post("/register", authLimiter, validate(registerSchema), register); + +/** + * @openapi + * /api/auth/login: + * post: + * tags: [Auth] + * summary: Login to an account + * description: Authenticate with email and password. Account locks after 5 failed attempts. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginRequest' + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/AuthResponse' + * 401: + * description: Invalid credentials + * 429: + * description: Account locked due to too many failed attempts + */ +router.post("/login", strictAuthLimiter, lockoutMiddleware, validate(loginSchema), login); + +/** + * @openapi + * /api/auth/logout: + * post: + * tags: [Auth] + * summary: Logout + * description: Clear the refresh token cookie + * responses: + * 200: + * description: Logged out successfully + */ router.post("/logout", logout); -router.post("/refresh-token", refreshToken); + +/** + * @openapi + * /api/auth/refresh-token: + * post: + * tags: [Auth] + * summary: Refresh access token + * description: Exchange refresh token for a new access token + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * refreshToken: + * type: string + * responses: + * 200: + * description: Token refreshed successfully + * 401: + * description: Invalid or expired refresh token + */ +router.post("/refresh-token", authLimiter, refreshToken); + +/** + * @openapi + * /api/auth/me: + * get: + * tags: [Auth] + * summary: Get current user + * description: Get the authenticated user's profile + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User profile + * 401: + * description: Unauthorized + */ router.get("/me", authenticate, getMe); export default router; From edb0dfe440d1cac7b375dc9a5c468fe4e35b2913 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:07 +0100 Subject: [PATCH 05/20] feat: add Swagger JSDoc annotations and URL sanitization to user routes --- src/routes/user.routes.ts | 108 +++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/src/routes/user.routes.ts b/src/routes/user.routes.ts index 72820eb..8d76a7a 100644 --- a/src/routes/user.routes.ts +++ b/src/routes/user.routes.ts @@ -3,14 +3,120 @@ import { getProfile, updateProfile, deleteProfile, uploadAvatar } from "../contr import { validate } from "../middleware/validate.js"; import { authenticate } from "../middleware/auth.js"; import { updateProfileSchema, avatarUploadSchema } from "../schemas/user.schema.js"; +import { validateUrlField } from "../middleware/sanitize.js"; const router = Router(); router.use(authenticate); +/** + * @openapi + * /api/users/profile: + * get: + * tags: [Users] + * summary: Get user profile + * description: Get the authenticated user's full profile with completeness score + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: User profile with completeness + * 401: + * description: Unauthorized + * 404: + * description: User not found + */ router.get("/profile", getProfile); + +/** + * @openapi + * /api/users/profile: + * put: + * tags: [Users] + * summary: Update user profile + * description: Update the authenticated user's profile fields + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * fullname: + * type: string + * minLength: 2 + * email: + * type: string + * format: email + * country: + * type: string + * walletAddress: + * type: string + * profileImage: + * type: string + * format: uri + * responses: + * 200: + * description: Profile updated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 403: + * description: Email or wallet already in use + */ router.put("/profile", validate(updateProfileSchema), updateProfile); + +/** + * @openapi + * /api/users/profile: + * delete: + * tags: [Users] + * summary: Delete user profile + * description: Permanently delete the authenticated user's account + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Profile deleted + * 401: + * description: Unauthorized + * 404: + * description: User not found + */ router.delete("/profile", deleteProfile); -router.post("/avatar", validate(avatarUploadSchema), uploadAvatar); + +/** + * @openapi + * /api/users/avatar: + * post: + * tags: [Users] + * summary: Upload avatar + * description: Set or update the user's profile image URL + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [profileImage] + * properties: + * profileImage: + * type: string + * format: uri + * description: URL of the profile image + * responses: + * 200: + * description: Avatar updated + * 400: + * description: Invalid image URL + * 401: + * description: Unauthorized + */ +router.post("/avatar", validate(avatarUploadSchema), validateUrlField("profileImage"), uploadAvatar); export default router; From 3e44ad94a7cfe1a4f2f8798478612c0356d0ee4f Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:08 +0100 Subject: [PATCH 06/20] feat: add Swagger JSDoc annotations to invoice routes with full endpoint documentation --- src/routes/invoice.routes.ts | 155 +++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 7ad84ce..6bb4419 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -14,10 +14,165 @@ const router = Router(); router.use(authenticate); +/** + * @openapi + * /api/invoices: + * post: + * tags: [Invoices] + * summary: Create an invoice + * description: Create a new invoice (freelancers only) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateInvoiceRequest' + * responses: + * 201: + * description: Invoice created + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 403: + * description: Only freelancers can create invoices + */ router.post("/", validate(createInvoiceSchema), createInvoice); + +/** + * @openapi + * /api/invoices: + * get: + * tags: [Invoices] + * summary: List invoices + * description: Get paginated list of invoices for the authenticated user + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * default: 1 + * description: Page number + * - in: query + * name: limit + * schema: + * type: integer + * default: 20 + * maximum: 100 + * description: Items per page + * - in: query + * name: status + * schema: + * type: string + * enum: [DRAFT, PENDING, FUNDED, IN_ESCROW, COMPLETED, CANCELLED, DISPUTED] + * description: Filter by status + * - in: query + * name: search + * schema: + * type: string + * description: Search in title and description + * responses: + * 200: + * description: Paginated invoice list + * 401: + * description: Unauthorized + */ router.get("/", getInvoices); + +/** + * @openapi + * /api/invoices/{id}: + * get: + * tags: [Invoices] + * summary: Get invoice by ID + * description: Get a single invoice with details + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Invoice ID + * responses: + * 200: + * description: Invoice details + * 401: + * description: Unauthorized + * 403: + * description: No access to this invoice + * 404: + * description: Invoice not found + */ router.get("/:id", getInvoice); + +/** + * @openapi + * /api/invoices/{id}: + * put: + * tags: [Invoices] + * summary: Update an invoice + * description: Update invoice fields (creator only). Status transitions are validated. + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateInvoiceRequest' + * responses: + * 200: + * description: Invoice updated + * 400: + * description: Invalid status transition + * 401: + * description: Unauthorized + * 403: + * description: Only creator can update + * 404: + * description: Invoice not found + */ router.put("/:id", validate(updateInvoiceSchema), updateInvoice); + +/** + * @openapi + * /api/invoices/{id}: + * delete: + * tags: [Invoices] + * summary: Delete an invoice + * description: Delete a draft invoice (creator only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Invoice deleted + * 400: + * description: Only draft invoices can be deleted + * 401: + * description: Unauthorized + * 403: + * description: Only creator can delete + * 404: + * description: Invoice not found + */ router.delete("/:id", deleteInvoice); export default router; From 5454cba261817967d555ac5dbc77af5cc8f84b3b Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:09 +0100 Subject: [PATCH 07/20] feat: add Swagger JSDoc annotations to escrow routes with full endpoint documentation --- src/routes/escrow.routes.ts | 156 ++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/src/routes/escrow.routes.ts b/src/routes/escrow.routes.ts index af12595..243959c 100644 --- a/src/routes/escrow.routes.ts +++ b/src/routes/escrow.routes.ts @@ -19,10 +19,166 @@ const router = Router(); router.use(authenticate); +/** + * @openapi + * /api/escrows: + * post: + * tags: [Escrows] + * summary: Create an escrow + * description: Create a new escrow contract for an invoice (clients only) + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CreateEscrowRequest' + * responses: + * 201: + * description: Escrow created + * 400: + * description: Validation error or escrow already exists + * 401: + * description: Unauthorized + * 403: + * description: Only clients can create escrows + * 404: + * description: Invoice not found + */ router.post("/", validate(createEscrowSchema), createEscrow); + +/** + * @openapi + * /api/escrows/{id}: + * get: + * tags: [Escrows] + * summary: Get escrow by ID + * description: Get escrow details including payment history + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Escrow ID + * responses: + * 200: + * description: Escrow details + * 401: + * description: Unauthorized + * 403: + * description: No access to this escrow + * 404: + * description: Escrow not found + */ router.get("/:id", getEscrow); + +/** + * @openapi + * /api/escrows/{id}/fund: + * post: + * tags: [Escrows] + * summary: Fund an escrow + * description: Fund an escrow with a Stellar transaction (client only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FundEscrowRequest' + * responses: + * 200: + * description: Escrow funded + * 400: + * description: Cannot fund in current status + * 401: + * description: Unauthorized + * 403: + * description: Only the client can fund + * 404: + * description: Escrow not found + */ router.post("/:id/fund", validate(fundEscrowSchema), fundEscrow); + +/** + * @openapi + * /api/escrows/{id}/release: + * post: + * tags: [Escrows] + * summary: Release escrow funds + * description: Release escrow funds to the freelancer (freelancer or admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FundEscrowRequest' + * responses: + * 200: + * description: Escrow released + * 400: + * description: Cannot release in current status + * 401: + * description: Unauthorized + * 403: + * description: Only freelancer or admin can release + * 404: + * description: Escrow not found + */ router.post("/:id/release", validate(releaseEscrowSchema), releaseEscrow); + +/** + * @openapi + * /api/escrows/{id}/refund: + * post: + * tags: [Escrows] + * summary: Refund escrow + * description: Refund escrow funds to the client (admin only) + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/FundEscrowRequest' + * responses: + * 200: + * description: Escrow refunded + * 400: + * description: Cannot refund in current status + * 401: + * description: Unauthorized + * 403: + * description: Only admins can refund + * 404: + * description: Escrow not found + */ router.post("/:id/refund", validate(refundEscrowSchema), refundEscrow); export default router; From f2940a883274f60b156861ebade6b612d20fabe4 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:09 +0100 Subject: [PATCH 08/20] feat: integrate rate limiting, input sanitization, and Swagger UI into Express app --- src/index.ts | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index d1fb554..508ea82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,13 +3,16 @@ import express from "express"; import helmet from "helmet"; import cors from "cors"; import morgan from "morgan"; -import rateLimit from "express-rate-limit"; import cookieParser from "cookie-parser"; +import swaggerUi from "swagger-ui-express"; +import { swaggerSpec } from "./config/swagger.js"; import authRoutes from "./routes/auth.routes.js"; import userRoutes from "./routes/user.routes.js"; import invoiceRoutes from "./routes/invoice.routes.js"; import escrowRoutes from "./routes/escrow.routes.js"; import { errorHandler } from "./middleware/errorHandler.js"; +import { globalLimiter, apiLimiter } from "./middleware/rateLimiter.js"; +import { sanitizeBody, sanitizeQuery, sanitizeParams, validateContentType } from "./middleware/sanitize.js"; const app = express(); const PORT = process.env["PORT"] ?? 3001; @@ -20,27 +23,35 @@ app.use(morgan("dev")); app.use(express.json({ limit: "10mb" })); app.use(cookieParser()); -const limiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - standardHeaders: true, - legacyHeaders: false, +app.use(globalLimiter); +app.use(sanitizeBody); +app.use(sanitizeQuery); +app.use(sanitizeParams); +app.use(validateContentType()); + +app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, { + customCss: ".swagger-ui .topbar { display: none }", + customSiteTitle: "StellFlow API Documentation", +})); + +app.get("/api-docs.json", (_req, res) => { + res.json(swaggerSpec); }); -app.use("/api/", limiter); app.get("/health", (_req, res) => { res.json({ status: "ok", timestamp: new Date().toISOString() }); }); app.use("/api/auth", authRoutes); -app.use("/api/users", userRoutes); -app.use("/api/invoices", invoiceRoutes); -app.use("/api/escrows", escrowRoutes); +app.use("/api/users", apiLimiter, userRoutes); +app.use("/api/invoices", apiLimiter, invoiceRoutes); +app.use("/api/escrows", apiLimiter, escrowRoutes); app.use(errorHandler); app.listen(PORT, () => { console.log(`StellFlow API running on port ${PORT}`); + console.log(`API docs available at http://localhost:${PORT}/api-docs`); }); export default app; From 37c02f3f26f25cdf121ff0f5e2c4980a7f5e8ad8 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:11 +0100 Subject: [PATCH 09/20] feat: add Vitest configuration for test suite --- vitest.config.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 vitest.config.ts diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fba8461 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + setupFiles: ["./src/__tests__/setup.ts"], + include: ["src/__tests__/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: ["src/__tests__/**", "src/types/**"], + }, + }, + resolve: { + alias: { + "@": "/src", + }, + }, +}); From 1595ae5260bc8553768f0be886c4987dbd4af90f Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:11 +0100 Subject: [PATCH 10/20] feat: add test fixtures and factories for users, invoices, escrows, and payments --- src/__tests__/fixtures/index.ts | 87 +++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 src/__tests__/fixtures/index.ts diff --git a/src/__tests__/fixtures/index.ts b/src/__tests__/fixtures/index.ts new file mode 100644 index 0000000..9a36dd9 --- /dev/null +++ b/src/__tests__/fixtures/index.ts @@ -0,0 +1,87 @@ +export const mockUser = { + id: "clx1234567890", + fullname: "John Doe", + email: "john@example.com", + password: "$2a$12$hashedpassword", + walletAddress: "GCKFBEIYVYQV6FYNUWZV6MO7VVI7RMUO6ESLO45S73JQ27LHJKR2", + profileImage: null, + country: "US", + role: "FREELANCER" as const, + isVerified: true, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), +}; + +export const mockClient = { + ...mockUser, + id: "clx1234567891", + fullname: "Jane Smith", + email: "jane@example.com", + role: "CLIENT" as const, +}; + +export const mockAdmin = { + ...mockUser, + id: "clx1234567892", + fullname: "Admin User", + email: "admin@example.com", + role: "ADMIN" as const, +}; + +export const mockInvoice = { + id: "clx_inv_001", + creatorId: "clx1234567890", + recipientId: "clx1234567891", + title: "Web Development Services", + description: "Frontend development for e-commerce site", + amount: 1500, + currency: "USDC", + status: "DRAFT" as const, + dueDate: new Date("2024-02-01"), + paidAt: null, + txHash: null, + contractId: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + creator: { id: "clx1234567890", fullname: "John Doe", email: "john@example.com" }, + recipient: { id: "clx1234567891", fullname: "Jane Smith", email: "jane@example.com" }, + escrow: null, +}; + +export const mockEscrow = { + id: "clx_esc_001", + invoiceId: "clx_inv_001", + clientId: "clx1234567891", + freelancerId: "clx1234567890", + contractId: "contract_mock_123", + txHash: null, + amount: 1500, + currency: "USDC", + status: "PENDING" as const, + fundedAt: null, + releasedAt: null, + refundedAt: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + invoice: { id: "clx_inv_001", title: "Web Development Services", amount: 1500, status: "IN_ESCROW" as const }, + client: { id: "clx1234567891", fullname: "Jane Smith", email: "jane@example.com" }, + freelancer: { id: "clx1234567890", fullname: "John Doe", email: "john@example.com" }, + payments: [], +}; + +export const mockPayment = { + id: "clx_pay_001", + userId: "clx1234567891", + escrowId: "clx_esc_001", + amount: 1500, + currency: "USDC", + txHash: "tx_mock_fund", + status: "SUCCESS" as const, + description: "Funded escrow for invoice", + createdAt: new Date("2024-01-01"), +}; + +export const mockTokens = { + accessToken: "eyJhbGciOiJIUzI1NiJ9.mock.access.token", + refreshToken: "eyJhbGciOiJIUzI1NiJ9.mock.refresh.token", +}; From a1dc550596a3e8a046c8a597130509beadb2364c Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:13 +0100 Subject: [PATCH 11/20] test: add unit tests for config loading and default values --- src/__tests__/unit/config.test.ts | 36 +++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/__tests__/unit/config.test.ts diff --git a/src/__tests__/unit/config.test.ts b/src/__tests__/unit/config.test.ts new file mode 100644 index 0000000..1fdeb6d --- /dev/null +++ b/src/__tests__/unit/config.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { config } from "../../config/index.js"; + +describe("Config", () => { + it("should export config object with required fields", () => { + expect(config).toBeDefined(); + expect(config.port).toBeDefined(); + expect(typeof config.port).toBe("number"); + }); + + it("should have correct default port", () => { + expect(config.port).toBe(3001); + }); + + it("should have jwt config", () => { + expect(config.jwt).toBeDefined(); + expect(typeof config.jwt.secret).toBe("string"); + expect(typeof config.jwt.expiresIn).toBe("number"); + expect(typeof config.jwt.refreshExpiresIn).toBe("number"); + }); + + it("should have stellar config", () => { + expect(config.stellar).toBeDefined(); + expect(config.stellar.network).toBe("testnet"); + expect(config.stellar.horizonUrl).toContain("stellar.org"); + }); + + it("should have cors config", () => { + expect(config.cors).toBeDefined(); + expect(config.cors.origin).toBeDefined(); + }); + + it("should have nodeEnv default to development", () => { + expect(config.nodeEnv).toBeDefined(); + }); +}); From 5c50eb4358c6466298dd994013d3a86d1c325520 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:13 +0100 Subject: [PATCH 12/20] test: add unit tests for all Zod validation schemas --- src/__tests__/unit/validation.test.ts | 258 ++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 src/__tests__/unit/validation.test.ts diff --git a/src/__tests__/unit/validation.test.ts b/src/__tests__/unit/validation.test.ts new file mode 100644 index 0000000..cda7ba5 --- /dev/null +++ b/src/__tests__/unit/validation.test.ts @@ -0,0 +1,258 @@ +import { describe, it, expect } from "vitest"; +import { registerSchema, loginSchema, refreshTokenSchema } from "../../schemas/auth.schema.js"; +import { updateProfileSchema, avatarUploadSchema } from "../../schemas/user.schema.js"; +import { createInvoiceSchema, updateInvoiceSchema, listInvoicesSchema } from "../../schemas/invoice.schema.js"; +import { createEscrowSchema, fundEscrowSchema, releaseEscrowSchema, refundEscrowSchema } from "../../schemas/escrow.schema.js"; + +describe("Auth Schemas", () => { + describe("registerSchema", () => { + it("should accept valid registration data", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept registration with optional fields", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "CLIENT" as const, + walletAddress: "GCKFBEIYVYQV6FYNUWZV6MO7VVI7RMUO6ESLO45S73JQ27LHJKR2", + country: "US", + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject short name", () => { + const data = { + fullname: "J", + email: "john@example.com", + password: "password123", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid email", () => { + const data = { + fullname: "John Doe", + email: "not-an-email", + password: "password123", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject short password", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "1234567", + role: "FREELANCER" as const, + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid role", () => { + const data = { + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "ADMIN", + }; + const result = registerSchema.safeParse(data); + expect(result.success).toBe(false); + }); + }); + + describe("loginSchema", () => { + it("should accept valid login data", () => { + const data = { email: "john@example.com", password: "password123" }; + const result = loginSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject empty password", () => { + const data = { email: "john@example.com", password: "" }; + const result = loginSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject invalid email", () => { + const data = { email: "invalid", password: "password123" }; + const result = loginSchema.safeParse(data); + expect(result.success).toBe(false); + }); + }); + + describe("refreshTokenSchema", () => { + it("should accept valid refresh token", () => { + const result = refreshTokenSchema.safeParse({ refreshToken: "valid-token" }); + expect(result.success).toBe(true); + }); + + it("should reject empty refresh token", () => { + const result = refreshTokenSchema.safeParse({ refreshToken: "" }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("User Schemas", () => { + describe("updateProfileSchema", () => { + it("should accept valid update data", () => { + const data = { fullname: "Jane Doe", country: "UK" }; + const result = updateProfileSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should accept empty update", () => { + const result = updateProfileSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it("should reject invalid email format", () => { + const result = updateProfileSchema.safeParse({ email: "not-email" }); + expect(result.success).toBe(false); + }); + + it("should reject invalid URL for profileImage", () => { + const result = updateProfileSchema.safeParse({ profileImage: "not-a-url" }); + expect(result.success).toBe(false); + }); + }); + + describe("avatarUploadSchema", () => { + it("should accept valid URL", () => { + const result = avatarUploadSchema.safeParse({ profileImage: "https://example.com/avatar.png" }); + expect(result.success).toBe(true); + }); + + it("should reject invalid URL", () => { + const result = avatarUploadSchema.safeParse({ profileImage: "not-a-url" }); + expect(result.success).toBe(false); + }); + }); +}); + +describe("Invoice Schemas", () => { + describe("createInvoiceSchema", () => { + it("should accept valid invoice data", () => { + const data = { + recipientId: "clx123", + title: "Test Invoice", + description: "Test description", + amount: 100, + }; + const result = createInvoiceSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject negative amount", () => { + const data = { + recipientId: "clx123", + title: "Test Invoice", + description: "Test description", + amount: -100, + }; + const result = createInvoiceSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject zero amount", () => { + const data = { + recipientId: "clx123", + title: "Test Invoice", + description: "Test description", + amount: 0, + }; + const result = createInvoiceSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it("should reject missing required fields", () => { + const result = createInvoiceSchema.safeParse({ title: "Test" }); + expect(result.success).toBe(false); + }); + }); + + describe("updateInvoiceSchema", () => { + it("should accept partial update", () => { + const result = updateInvoiceSchema.safeParse({ title: "Updated Title" }); + expect(result.success).toBe(true); + }); + + it("should accept status update", () => { + const result = updateInvoiceSchema.safeParse({ status: "PENDING" }); + expect(result.success).toBe(true); + }); + + it("should reject invalid status", () => { + const result = updateInvoiceSchema.safeParse({ status: "INVALID_STATUS" }); + expect(result.success).toBe(false); + }); + }); + + describe("listInvoicesSchema", () => { + it("should accept valid query params", () => { + const result = listInvoicesSchema.safeParse({ page: "1", limit: "20" }); + expect(result.success).toBe(true); + }); + + it("should accept empty query", () => { + const result = listInvoicesSchema.safeParse({}); + expect(result.success).toBe(true); + }); + }); +}); + +describe("Escrow Schemas", () => { + describe("createEscrowSchema", () => { + it("should accept valid escrow data", () => { + const data = { invoiceId: "clx123", contractId: "contract_123" }; + const result = createEscrowSchema.safeParse(data); + expect(result.success).toBe(true); + }); + + it("should reject missing invoiceId", () => { + const result = createEscrowSchema.safeParse({ contractId: "contract_123" }); + expect(result.success).toBe(false); + }); + }); + + describe("fundEscrowSchema", () => { + it("should accept valid txHash", () => { + const result = fundEscrowSchema.safeParse({ txHash: "tx_abc123" }); + expect(result.success).toBe(true); + }); + + it("should reject empty txHash", () => { + const result = fundEscrowSchema.safeParse({ txHash: "" }); + expect(result.success).toBe(false); + }); + }); + + describe("releaseEscrowSchema", () => { + it("should accept valid txHash", () => { + const result = releaseEscrowSchema.safeParse({ txHash: "tx_abc123" }); + expect(result.success).toBe(true); + }); + }); + + describe("refundEscrowSchema", () => { + it("should accept valid txHash", () => { + const result = refundEscrowSchema.safeParse({ txHash: "tx_abc123" }); + expect(result.success).toBe(true); + }); + }); +}); From 1c1483ad49e408eb1e15b40893bbbda1fc454a99 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:15 +0100 Subject: [PATCH 13/20] test: add unit tests for pagination utility functions --- src/__tests__/unit/pagination.test.ts | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/__tests__/unit/pagination.test.ts diff --git a/src/__tests__/unit/pagination.test.ts b/src/__tests__/unit/pagination.test.ts new file mode 100644 index 0000000..0345e58 --- /dev/null +++ b/src/__tests__/unit/pagination.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { getPaginationParams, getPaginationMeta, getSkipTake } from "../../utils/pagination.js"; + +describe("Pagination Utils", () => { + describe("getPaginationParams", () => { + it("should return defaults for empty query", () => { + const result = getPaginationParams({}); + expect(result).toEqual({ page: 1, limit: 20 }); + }); + + it("should parse page and limit from query", () => { + const result = getPaginationParams({ page: "3", limit: "50" }); + expect(result).toEqual({ page: 3, limit: 50 }); + }); + + it("should enforce minimum page of 1", () => { + const result = getPaginationParams({ page: "0" }); + expect(result.page).toBe(1); + }); + + it("should enforce negative page to 1", () => { + const result = getPaginationParams({ page: "-5" }); + expect(result.page).toBe(1); + }); + + it("should enforce maximum limit of 100", () => { + const result = getPaginationParams({ limit: "200" }); + expect(result.limit).toBe(100); + }); + + it("should default limit to 20 for zero value", () => { + const result = getPaginationParams({ limit: "0" }); + expect(result.limit).toBe(20); + }); + + it("should handle non-numeric strings", () => { + const result = getPaginationParams({ page: "abc", limit: "xyz" }); + expect(result).toEqual({ page: 1, limit: 20 }); + }); + }); + + describe("getPaginationMeta", () => { + it("should calculate correct total pages", () => { + const result = getPaginationMeta(1, 10, 55); + expect(result).toEqual({ + page: 1, + limit: 10, + total: 55, + totalPages: 6, + }); + }); + + it("should handle exact division", () => { + const result = getPaginationMeta(2, 10, 20); + expect(result.totalPages).toBe(2); + }); + + it("should handle zero total", () => { + const result = getPaginationMeta(1, 20, 0); + expect(result.totalPages).toBe(0); + }); + + it("should handle single item", () => { + const result = getPaginationMeta(1, 20, 1); + expect(result.totalPages).toBe(1); + }); + }); + + describe("getSkipTake", () => { + it("should calculate skip for first page", () => { + const result = getSkipTake(1, 20); + expect(result).toEqual({ skip: 0, take: 20 }); + }); + + it("should calculate skip for second page", () => { + const result = getSkipTake(2, 20); + expect(result).toEqual({ skip: 20, take: 20 }); + }); + + it("should calculate skip for third page with custom limit", () => { + const result = getSkipTake(3, 10); + expect(result).toEqual({ skip: 20, take: 10 }); + }); + }); +}); From dfca2a9ef3d8251dd7f0eaf1865b77f80da5fd70 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:15 +0100 Subject: [PATCH 14/20] test: add unit tests for custom error classes --- src/__tests__/unit/errors.test.ts | 91 +++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/__tests__/unit/errors.test.ts diff --git a/src/__tests__/unit/errors.test.ts b/src/__tests__/unit/errors.test.ts new file mode 100644 index 0000000..39a02fc --- /dev/null +++ b/src/__tests__/unit/errors.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from "vitest"; +import { + AppError, + NotFoundError, + UnauthorizedError, + ForbiddenError, + BadRequestError, + ConflictError, +} from "../../utils/errors.js"; + +describe("Error Classes", () => { + describe("AppError", () => { + it("should create error with message and status code", () => { + const error = new AppError("Test error", 500); + expect(error.message).toBe("Test error"); + expect(error.statusCode).toBe(500); + expect(error.isOperational).toBe(true); + expect(error).toBeInstanceOf(Error); + }); + + it("should set isOperational to false when specified", () => { + const error = new AppError("System error", 500, false); + expect(error.isOperational).toBe(false); + }); + }); + + describe("NotFoundError", () => { + it("should have status 404 and default message", () => { + const error = new NotFoundError(); + expect(error.statusCode).toBe(404); + expect(error.message).toBe("Resource not found"); + }); + + it("should accept custom message", () => { + const error = new NotFoundError("User not found"); + expect(error.message).toBe("User not found"); + }); + }); + + describe("UnauthorizedError", () => { + it("should have status 401 and default message", () => { + const error = new UnauthorizedError(); + expect(error.statusCode).toBe(401); + expect(error.message).toBe("Unauthorized"); + }); + + it("should accept custom message", () => { + const error = new UnauthorizedError("Invalid token"); + expect(error.message).toBe("Invalid token"); + }); + }); + + describe("ForbiddenError", () => { + it("should have status 403 and default message", () => { + const error = new ForbiddenError(); + expect(error.statusCode).toBe(403); + expect(error.message).toBe("Forbidden"); + }); + + it("should accept custom message", () => { + const error = new ForbiddenError("Insufficient permissions"); + expect(error.message).toBe("Insufficient permissions"); + }); + }); + + describe("BadRequestError", () => { + it("should have status 400 and default message", () => { + const error = new BadRequestError(); + expect(error.statusCode).toBe(400); + expect(error.message).toBe("Bad request"); + }); + + it("should accept custom message", () => { + const error = new BadRequestError("Invalid input"); + expect(error.message).toBe("Invalid input"); + }); + }); + + describe("ConflictError", () => { + it("should have status 409 and default message", () => { + const error = new ConflictError(); + expect(error.statusCode).toBe(409); + expect(error.message).toBe("Resource already exists"); + }); + + it("should accept custom message", () => { + const error = new ConflictError("Email already registered"); + expect(error.message).toBe("Email already registered"); + }); + }); +}); From e48778d122642123ee800b65cd04a6e9de134d7a Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:15 +0100 Subject: [PATCH 15/20] test: add unit tests for input sanitization and URL validation --- src/__tests__/unit/sanitize.test.ts | 75 +++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/__tests__/unit/sanitize.test.ts diff --git a/src/__tests__/unit/sanitize.test.ts b/src/__tests__/unit/sanitize.test.ts new file mode 100644 index 0000000..cb53cc8 --- /dev/null +++ b/src/__tests__/unit/sanitize.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "vitest"; +import { sanitizeString, sanitizeUrl } from "../../middleware/sanitize.js"; + +describe("Sanitize Middleware", () => { + describe("sanitizeString", () => { + it("should remove HTML tags", () => { + const result = sanitizeString("Hello"); + expect(result).toBe("Hello"); + expect(result).not.toContain(""); + expect(result).toBe(""); + }); + + it("should block vbscript: URLs", () => { + const result = sanitizeUrl("vbscript:MsgBox('xss')"); + expect(result).toBe(""); + }); + + it("should block file: URLs", () => { + const result = sanitizeUrl("file:///etc/passwd"); + expect(result).toBe(""); + }); + + it("should trim whitespace from URLs", () => { + const result = sanitizeUrl(" https://example.com "); + expect(result).toBe("https://example.com"); + }); + + it("should handle http URLs", () => { + const result = sanitizeUrl("http://example.com"); + expect(result).toBe("http://example.com"); + }); + }); +}); From 241e97998f4a662fa24c8c3b0f65a83a43457c11 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:16 +0100 Subject: [PATCH 16/20] test: add integration tests for auth flows including register, login, logout, and getMe --- src/__tests__/integration/auth.test.ts | 182 +++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 src/__tests__/integration/auth.test.ts diff --git a/src/__tests__/integration/auth.test.ts b/src/__tests__/integration/auth.test.ts new file mode 100644 index 0000000..2fc29f4 --- /dev/null +++ b/src/__tests__/integration/auth.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import bcrypt from "bcryptjs"; +import { prisma } from "../../config/prisma.js"; +import * as authController from "../../controllers/auth.controller.js"; +import { mockUser } from "../fixtures/index.js"; + +vi.mock("jsonwebtoken", () => ({ + default: { + sign: vi.fn().mockReturnValue("mock-jwt-token"), + verify: vi.fn().mockReturnValue({ userId: "clx1234567890", email: "john@example.com", role: "FREELANCER" }), + TokenExpiredError: class TokenExpiredError extends Error {}, + JsonWebTokenError: class JsonWebTokenError extends Error {}, + }, +})); + +function mockReq(body: Record = {}, cookies: Record = {}): Request { + return { + body, + cookies, + headers: {}, + params: {}, + query: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as Request; +} + +function mockRes(): Response { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + cookie: vi.fn().mockReturnThis(), + clearCookie: vi.fn().mockReturnThis(), + } as unknown as Response; + return res; +} + +describe("Auth Controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("register", () => { + it("should register a new user", async () => { + const hashedPassword = await bcrypt.hash("password123", 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.create).mockResolvedValue({ + ...mockUser, + password: hashedPassword, + }); + + const req = mockReq({ + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "FREELANCER", + }); + const res = mockRes(); + + await authController.register(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + user: expect.objectContaining({ email: "john@example.com" }), + }), + }) + ); + }); + + it("should throw ConflictError for existing email", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never); + + const req = mockReq({ + fullname: "John Doe", + email: "john@example.com", + password: "password123", + role: "FREELANCER", + }); + const res = mockRes(); + + await expect(authController.register(req, res)).rejects.toThrow("Email already registered"); + }); + }); + + describe("login", () => { + it("should login with valid credentials", async () => { + const hashedPassword = await bcrypt.hash("password123", 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + password: hashedPassword, + }); + + const req = mockReq({ email: "john@example.com", password: "password123" }); + const res = mockRes(); + + await authController.login(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + accessToken: expect.any(String), + }), + }) + ); + }); + + it("should throw UnauthorizedError for invalid email", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + + const req = mockReq({ email: "nonexistent@example.com", password: "password123" }); + const res = mockRes(); + + await expect(authController.login(req, res)).rejects.toThrow("Invalid email or password"); + }); + + it("should throw UnauthorizedError for wrong password", async () => { + const hashedPassword = await bcrypt.hash("wrongpassword", 12); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + password: hashedPassword, + }); + + const req = mockReq({ email: "john@example.com", password: "password123" }); + const res = mockRes(); + + await expect(authController.login(req, res)).rejects.toThrow("Invalid email or password"); + }); + }); + + describe("logout", () => { + it("should clear refresh token cookie", () => { + const req = mockReq(); + const res = mockRes(); + + authController.logout(req, res); + + expect(res.clearCookie).toHaveBeenCalledWith("refreshToken", expect.any(Object)); + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { message: "Logged out successfully" }, + }); + }); + }); + + describe("getMe", () => { + it("should return current user profile", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never); + + const req = mockReq(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId: "clx1234567890" }; + const res = mockRes(); + + await authController.getMe(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + user: expect.objectContaining({ email: "john@example.com" }), + }), + }) + ); + }); + + it("should throw NotFoundError for non-existent user", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + + const req = mockReq(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId: "nonexistent" }; + const res = mockRes(); + + await expect(authController.getMe(req, res)).rejects.toThrow("User not found"); + }); + }); +}); From e5d302ddb58e1ad8c7035c1859db82e21b3ba3bd Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:17 +0100 Subject: [PATCH 17/20] test: add integration tests for invoice CRUD operations --- src/__tests__/integration/invoice.test.ts | 211 ++++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 src/__tests__/integration/invoice.test.ts diff --git a/src/__tests__/integration/invoice.test.ts b/src/__tests__/integration/invoice.test.ts new file mode 100644 index 0000000..82c57b7 --- /dev/null +++ b/src/__tests__/integration/invoice.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { prisma } from "../../config/prisma.js"; +import * as invoiceController from "../../controllers/invoice.controller.js"; +import { mockUser, mockClient, mockInvoice } from "../fixtures/index.js"; + +function mockReq(body: Record = {}, params: Record = {}, query: Record = {}): Request { + return { + body, + params, + query, + headers: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as Request; +} + +function mockRes(): Response { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +function withUser(req: Request, userId: string, role: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId, role }; +} + +describe("Invoice Controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createInvoice", () => { + it("should create an invoice as freelancer", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockClient as never); + vi.mocked(prisma.invoice.create).mockResolvedValue(mockInvoice as never); + + const req = mockReq({ + recipientId: "clx1234567891", + title: "Web Development", + description: "Frontend work", + amount: 1500, + }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.createInvoice(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ invoice: expect.any(Object) }), + }) + ); + }); + + it("should reject invoice creation by client", async () => { + const req = mockReq({ + recipientId: "clx1234567890", + title: "Web Development", + description: "Frontend work", + amount: 1500, + }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(invoiceController.createInvoice(req, res)).rejects.toThrow("Only freelancers can create invoices"); + }); + + it("should reject self-invoice", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(mockUser as never); + + const req = mockReq({ + recipientId: "clx1234567890", + title: "Self Invoice", + description: "Test", + amount: 100, + }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(invoiceController.createInvoice(req, res)).rejects.toThrow("Cannot create invoice for yourself"); + }); + }); + + describe("getInvoices", () => { + it("should return paginated invoices for freelancer", async () => { + vi.mocked(prisma.invoice.findMany).mockResolvedValue([mockInvoice] as never); + vi.mocked(prisma.invoice.count).mockResolvedValue(1); + + const req = mockReq({}, {}, { page: "1", limit: "20" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.getInvoices(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + invoices: expect.any(Array), + pagination: expect.objectContaining({ + page: 1, + limit: 20, + total: 1, + totalPages: 1, + }), + }), + }) + ); + }); + }); + + describe("getInvoice", () => { + it("should return invoice by ID for creator", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.getInvoice(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ invoice: expect.any(Object) }), + }) + ); + }); + + it("should deny access to non-participant", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx_unauthorized", "FREELANCER"); + const res = mockRes(); + + await expect(invoiceController.getInvoice(req, res)).rejects.toThrow("You don't have access to this invoice"); + }); + }); + + describe("updateInvoice", () => { + it("should update invoice title", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({ + ...mockInvoice, + title: "Updated Title", + } as never); + + const req = mockReq({ title: "Updated Title" }, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.updateInvoice(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ + invoice: expect.objectContaining({ title: "Updated Title" }), + }), + }) + ); + }); + + it("should reject update by non-creator", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + + const req = mockReq({ title: "Hacked" }, { id: "clx_inv_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(invoiceController.updateInvoice(req, res)).rejects.toThrow("Only the invoice creator can update it"); + }); + }); + + describe("deleteInvoice", () => { + it("should delete draft invoice", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue(mockInvoice as never); + vi.mocked(prisma.invoice.delete).mockResolvedValue(mockInvoice as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await invoiceController.deleteInvoice(req, res); + + expect(res.json).toHaveBeenCalledWith({ + success: true, + data: { message: "Invoice deleted successfully" }, + }); + }); + + it("should reject deleting non-draft invoice", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue({ + ...mockInvoice, + status: "PENDING", + } as never); + + const req = mockReq({}, { id: "clx_inv_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(invoiceController.deleteInvoice(req, res)).rejects.toThrow("Only draft invoices can be deleted"); + }); + }); +}); From 247a58d6ef6054114b714c4e0dd14e7d593f1bc6 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:18 +0100 Subject: [PATCH 18/20] test: add integration tests for escrow lifecycle operations --- src/__tests__/integration/escrow.test.ts | 249 +++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 src/__tests__/integration/escrow.test.ts diff --git a/src/__tests__/integration/escrow.test.ts b/src/__tests__/integration/escrow.test.ts new file mode 100644 index 0000000..b1ec963 --- /dev/null +++ b/src/__tests__/integration/escrow.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Request, Response } from "express"; +import { prisma } from "../../config/prisma.js"; +import * as stellarService from "../../services/stellar.service.js"; +import * as escrowController from "../../controllers/escrow.controller.js"; +import { mockInvoice, mockEscrow } from "../fixtures/index.js"; + +function mockReq(body: Record = {}, params: Record = {}): Request { + return { + body, + params, + query: {}, + headers: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as Request; +} + +function mockRes(): Response { + return { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response; +} + +function withUser(req: Request, userId: string, role: string): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (req as any).user = { userId, role }; +} + +describe("Escrow Controller", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("createEscrow", () => { + it("should create escrow as client", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue({ + ...mockInvoice, + status: "PENDING", + } as never); + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(null); + vi.mocked(stellarService.createEscrowContract).mockResolvedValue({ + success: true, + txHash: "tx_123", + contractId: "contract_123", + }); + vi.mocked(prisma.escrow.create).mockResolvedValue(mockEscrow as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ invoiceId: "clx_inv_001", contractId: "contract_123" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await escrowController.createEscrow(req, res); + + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject escrow creation by freelancer", async () => { + const req = mockReq({ invoiceId: "clx_inv_001", contractId: "contract_123" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(escrowController.createEscrow(req, res)).rejects.toThrow("Only clients can create escrows"); + }); + + it("should reject duplicate escrow", async () => { + vi.mocked(prisma.invoice.findUnique).mockResolvedValue({ + ...mockInvoice, + status: "PENDING", + } as never); + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({ invoiceId: "clx_inv_001", contractId: "contract_123" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(escrowController.createEscrow(req, res)).rejects.toThrow("Escrow already exists for this invoice"); + }); + }); + + describe("fundEscrow", () => { + it("should fund escrow as client", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + vi.mocked(stellarService.fundEscrow).mockResolvedValue({ + success: true, + txHash: "tx_fund_123", + }); + vi.mocked(prisma.escrow.update).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + fundedAt: new Date(), + } as never); + vi.mocked(prisma.payment.create).mockResolvedValue({} as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ txHash: "tx_fund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await escrowController.fundEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject funding by non-client", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({ txHash: "tx_fund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await expect(escrowController.fundEscrow(req, res)).rejects.toThrow("Only the client can fund this escrow"); + }); + }); + + describe("releaseEscrow", () => { + it("should release escrow as freelancer", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + vi.mocked(stellarService.releaseEscrowFunds).mockResolvedValue({ + success: true, + txHash: "tx_release_123", + }); + vi.mocked(prisma.escrow.update).mockResolvedValue({ + ...mockEscrow, + status: "RELEASED", + releasedAt: new Date(), + } as never); + vi.mocked(prisma.payment.create).mockResolvedValue({} as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ txHash: "tx_release_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567890", "FREELANCER"); + const res = mockRes(); + + await escrowController.releaseEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject release by client", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + + const req = mockReq({ txHash: "tx_release_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(escrowController.releaseEscrow(req, res)).rejects.toThrow("Only the freelancer or admin can release escrow"); + }); + }); + + describe("refundEscrow", () => { + it("should refund escrow as admin", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + vi.mocked(stellarService.refundEscrowFunds).mockResolvedValue({ + success: true, + txHash: "tx_refund_123", + }); + vi.mocked(prisma.escrow.update).mockResolvedValue({ + ...mockEscrow, + status: "REFUNDED", + refundedAt: new Date(), + } as never); + vi.mocked(prisma.payment.create).mockResolvedValue({} as never); + vi.mocked(prisma.invoice.update).mockResolvedValue({} as never); + + const req = mockReq({ txHash: "tx_refund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx_admin", "ADMIN"); + const res = mockRes(); + + await escrowController.refundEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should reject refund by non-admin", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue({ + ...mockEscrow, + status: "FUNDED", + } as never); + + const req = mockReq({ txHash: "tx_refund_123" }, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await expect(escrowController.refundEscrow(req, res)).rejects.toThrow("Only admins can refund escrow"); + }); + }); + + describe("getEscrow", () => { + it("should return escrow for authorized user", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({}, { id: "clx_esc_001" }); + withUser(req, "clx1234567891", "CLIENT"); + const res = mockRes(); + + await escrowController.getEscrow(req, res); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + data: expect.objectContaining({ escrow: expect.any(Object) }), + }) + ); + }); + + it("should deny access to non-participant", async () => { + vi.mocked(prisma.escrow.findUnique).mockResolvedValue(mockEscrow as never); + + const req = mockReq({}, { id: "clx_esc_001" }); + withUser(req, "clx_unauthorized", "FREELANCER"); + const res = mockRes(); + + await expect(escrowController.getEscrow(req, res)).rejects.toThrow("You don't have access to this escrow"); + }); + }); +}); From c952bce906d420520e343c594edebf37574c9f5d Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:19 +0100 Subject: [PATCH 19/20] chore: add sanitize-html, swagger-jsdoc, swagger-ui-express, and type definitions --- package-lock.json | 623 ++++++++++++++++++++++++++++++++++++++++++++-- package.json | 8 +- 2 files changed, 608 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9e0b6c..dac57f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,10 @@ "jsonwebtoken": "~9.0.3", "morgan": "~1.10.1", "pg": "~8.20.0", + "sanitize-html": "~2.17.6", "stellar-sdk": "~13.3.0", + "swagger-jsdoc": "~6.3.0", + "swagger-ui-express": "~5.0.1", "zod": "~4.4.3" }, "devDependencies": { @@ -32,6 +35,9 @@ "@types/jsonwebtoken": "~9.0.10", "@types/morgan": "~1.9.9", "@types/node": "~25.8.0", + "@types/sanitize-html": "~2.16.1", + "@types/swagger-jsdoc": "~6.0.4", + "@types/swagger-ui-express": "~4.1.8", "eslint": "~9.18.0", "prisma": "~7.8.0", "tsx": "~4.22.0", @@ -39,6 +45,54 @@ "vitest": "~3.2.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz", + "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz", + "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "14.0.1", + "@apidevtools/openapi-schemas": "^2.1.0", + "@apidevtools/swagger-methods": "^3.0.2", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "call-me-maybe": "^1.0.2" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", @@ -768,6 +822,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -1469,6 +1532,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1611,7 +1681,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -1677,6 +1746,121 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/sanitize-html": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", + "integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "htmlparser2": "^10.1" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/@types/sanitize-html/node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@types/sanitize-html/node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -1698,6 +1882,24 @@ "@types/node": "*" } }, + "node_modules/@types/swagger-jsdoc": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/swagger-jsdoc/-/swagger-jsdoc-6.0.4.tgz", + "integrity": "sha512-W+Xw5epcOZrF/AooUM/PccNMSAFOKWZA5dasNyMujTwsBkU74njSJBpvCCJhHAJ95XRMzQrrW844Btu0uoetwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/swagger-ui-express": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", + "integrity": "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/serve-static": "*" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -1865,7 +2067,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1878,6 +2079,20 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -1898,7 +2113,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/assertion-error": { @@ -2237,6 +2451,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2352,6 +2572,15 @@ "node": ">= 0.8" } }, + "node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2446,7 +2675,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2465,6 +2693,12 @@ "license": "MIT", "peer": true }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2499,6 +2733,15 @@ "dev": true, "license": "MIT" }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -2568,6 +2811,85 @@ "devOptional": true, "license": "MIT" }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -2639,6 +2961,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -2756,7 +3090,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -2960,7 +3293,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -3089,7 +3421,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { @@ -3110,7 +3441,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "devOptional": true, "funding": [ { "type": "github", @@ -3243,7 +3573,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -3399,6 +3728,66 @@ "giget": "dist/cli.mjs" } }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -3525,6 +3914,28 @@ "node": ">=16.9.0" } }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -3697,6 +4108,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3747,9 +4167,23 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "devOptional": true, "license": "ISC" }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3771,7 +4205,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, "funding": [ { "type": "github", @@ -3801,7 +4234,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { @@ -3864,6 +4296,15 @@ "json-buffer": "3.0.1" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3937,6 +4378,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -3957,6 +4404,15 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lru.min": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", @@ -4051,6 +4507,15 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/morgan": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", @@ -4138,7 +4603,6 @@ "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, "funding": [ { "type": "github", @@ -4227,6 +4691,13 @@ "wrappy": "1" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4277,6 +4748,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -4290,6 +4767,12 @@ "node": ">=6" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4313,12 +4796,27 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -4446,7 +4944,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/pkg-types": { @@ -4474,7 +4971,6 @@ "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -4789,7 +5285,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4902,6 +5397,24 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.6", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz", + "integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5020,7 +5533,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5033,7 +5545,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -5122,7 +5633,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -5145,7 +5655,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -5239,6 +5748,59 @@ "node": ">=8" } }, + "node_modules/swagger-jsdoc": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz", + "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "^12.1.0", + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "11.1.0", + "lodash.mergewith": "^4.6.2", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5732,7 +6294,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5807,6 +6368,24 @@ "node": ">=0.4" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "optional": true, + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index cd0a6eb..ee374bd 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,14 @@ "jsonwebtoken": "~9.0.3", "morgan": "~1.10.1", "pg": "~8.20.0", + "sanitize-html": "~2.17.6", "stellar-sdk": "~13.3.0", + "swagger-jsdoc": "~6.3.0", + "swagger-ui-express": "~5.0.1", "zod": "~4.4.3" }, "devDependencies": { + "@eslint/js": "~9.18.0", "@types/bcryptjs": "~2.4.6", "@types/cookie-parser": "~1.4.10", "@types/cors": "~2.8.19", @@ -43,8 +47,10 @@ "@types/jsonwebtoken": "~9.0.10", "@types/morgan": "~1.9.9", "@types/node": "~25.8.0", + "@types/sanitize-html": "~2.16.1", + "@types/swagger-jsdoc": "~6.0.4", + "@types/swagger-ui-express": "~4.1.8", "eslint": "~9.18.0", - "@eslint/js": "~9.18.0", "prisma": "~7.8.0", "tsx": "~4.22.0", "typescript": "~6.0.3", From 1e1223c2400f698ca59eaccd3541b8c3c8105800 Mon Sep 17 00:00:00 2001 From: SweetBoy-eth Date: Sat, 11 Jul 2026 09:14:28 +0100 Subject: [PATCH 20/20] feat: add test setup with Prisma and Stellar service mocks --- src/__tests__/setup.ts | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/__tests__/setup.ts diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts new file mode 100644 index 0000000..aa72c73 --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,58 @@ +import { vi } from "vitest"; + +vi.mock("../config/prisma.js", () => ({ + prisma: { + user: { + findUnique: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + invoice: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + escrow: { + findUnique: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + count: vi.fn(), + }, + payment: { + create: vi.fn(), + findMany: vi.fn(), + }, + notification: { + create: vi.fn(), + findMany: vi.fn(), + }, + }, +})); + +vi.mock("../services/stellar.service.js", () => ({ + createEscrowContract: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_123", + contractId: "contract_mock_123", + }), + fundEscrow: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_fund", + }), + releaseEscrowFunds: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_release", + }), + refundEscrowFunds: vi.fn().mockResolvedValue({ + success: true, + txHash: "tx_mock_refund", + }), +}));