diff --git a/package.json b/package.json index cf54de4..0aefc50 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,6 @@ "@stellar/stellar-sdk": "^13.3.0", "@types/multer": "^1.4.12", "@types/rate-limit-redis": "^1.7.4", - "auth0-js": "^9.28.0", "axios": "^1.8.1", "bcryptjs": "^2.4.3", diff --git a/src/app.ts b/src/app.ts index 6fb46ef..b4289b4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,6 +40,8 @@ import { oauthConfig } from "./config/auth0Config"; import { auth } from "express-openid-connect"; import { auditMiddleware } from "./middlewares/auditMiddleware"; import routes from "./routes"; +import intelligentRateLimiter from "./middleware/rateLimiter"; +import rateLimitMonitoringService from "./services/rateLimitMonitoring.service"; // Initialize express app const app = express(); @@ -76,6 +78,8 @@ app.use( ); app.use(globalRateLimiter as RequestHandler); app.use(requestLogger as RequestHandler); +app.use(intelligentRateLimiter); +app.use(rateLimitMonitoringService.createRateLimitMonitoringMiddleware()); // Add timeout configurations app.use((req, res, next) => { diff --git a/src/config/db.ts b/src/config/db.ts index bf2030c..cbad86e 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -15,7 +15,8 @@ import { ReferralReward } from "../entities/ReferralReward"; import { ReferralProgram } from "../entities/ReferralProgram"; import { AuditLog } from "../entities/AuditLog"; import { AuditSubscriber } from "../subscribers/AuditSubscriber"; -import { Wallet } from "src/entities/Wallet"; +import { Wallet } from "../entities/Wallet"; +import { Transaction } from "../entities/Transaction"; dotenv.config(); @@ -45,6 +46,7 @@ const AppDataSource = new DataSource({ ReferralProgram, AuditLog, Wallet, + Transaction, ], subscribers: [AuditSubscriber], migrations: ["src/migrations/*.ts"], diff --git a/src/config/rateLimitConfig.ts b/src/config/rateLimitConfig.ts new file mode 100644 index 0000000..5c87114 --- /dev/null +++ b/src/config/rateLimitConfig.ts @@ -0,0 +1,82 @@ +import { UserRole } from "../enums/UserRole"; + +export interface RateLimitTier { + name: string; + description: string; + limits: { + requestsPerSecond: number; + requestsPerMinute: number; + requestsPerHour: number; + requestsPerDay: number; + }; + burstMultiplier: number; + burstDurationSeconds: number; +} + +export interface MerchantTypeRateLimits { + [key: string]: RateLimitTier; +} + +export interface UserRoleRateLimits { + [key: string]: RateLimitTier; +} + +//////////////////////////////////// +// Default rate limit tiers +//////////////////////////////////// +export const DEFAULT_RATE_LIMIT_TIERS: Record = { + basic: { + name: "Basic", + description: "Default rate limits for standard users", + limits: { + requestsPerSecond: 5, + requestsPerMinute: 60, + requestsPerHour: 1000, + requestsPerDay: 10000, + }, + burstMultiplier: 2, + burstDurationSeconds: 30, + }, + premium: { + name: "Premium", + description: "Higher limits for premium users", + limits: { + requestsPerSecond: 10, + requestsPerMinute: 300, + requestsPerHour: 3000, + requestsPerDay: 50000, + }, + burstMultiplier: 2, + burstDurationSeconds: 30, + }, + enterprise: { + name: "Enterprise", + description: "Highest limits for enterprise users", + limits: { + requestsPerSecond: 50, + requestsPerMinute: 1000, + requestsPerHour: 10000, + requestsPerDay: 100000, + }, + burstMultiplier: 2, + burstDurationSeconds: 30, + }, +}; + +//////////////////////////////////// +// Default rate limits by user role +//////////////////////////////////// + +export const DEFAULT_USER_ROLE_LIMITS: UserRoleRateLimits = { + [UserRole.USER]: DEFAULT_RATE_LIMIT_TIERS.basic, + [UserRole.ADMIN]: DEFAULT_RATE_LIMIT_TIERS.premium, +}; + +//////////////////////////////////// +// Default rate limits by merchant type +//////////////////////////////////// +export const DEFAULT_MERCHANT_TYPE_LIMITS: MerchantTypeRateLimits = { + standard: DEFAULT_RATE_LIMIT_TIERS.basic, + premium: DEFAULT_RATE_LIMIT_TIERS.premium, + enterprise: DEFAULT_RATE_LIMIT_TIERS.enterprise, +}; diff --git a/src/config/swagger.ts b/src/config/swagger.ts index 3a673c0..d9fb79c 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -146,7 +146,6 @@ const options: swaggerJsdoc.Options = { }, }, }, - // Payment related schemas Payment: { type: "object", @@ -216,7 +215,6 @@ const options: swaggerJsdoc.Options = { }, required: ["id", "title", "amount", "currency"], }, - // Merchant related schemas Merchant: { type: "object", @@ -269,7 +267,6 @@ const options: swaggerJsdoc.Options = { }, required: ["name", "email"], }, - // Authentication schemas LoginRequest: { type: "object", @@ -310,7 +307,6 @@ const options: swaggerJsdoc.Options = { }, required: ["accessToken", "refreshToken", "user"], }, - // Wallet verification schemas WalletVerification: { type: "object", @@ -341,7 +337,6 @@ const options: swaggerJsdoc.Options = { }, required: ["id", "walletAddress", "status"], }, - // Referral related schemas Referral: { type: "object", @@ -377,7 +372,6 @@ const options: swaggerJsdoc.Options = { }, required: ["id", "referrerId", "referredId", "status"], }, - // Error schemas Error: { type: "object", @@ -410,7 +404,6 @@ const options: swaggerJsdoc.Options = { }, required: ["error", "message"], }, - // Success response schemas SuccessResponse: { type: "object", @@ -421,7 +414,6 @@ const options: swaggerJsdoc.Options = { }, required: ["success", "message"], }, - // Pagination schemas PaginatedResponse: { type: "object", @@ -450,6 +442,357 @@ const options: swaggerJsdoc.Options = { }, required: ["data", "pagination"], }, + // ==================================================================== + // NEW Rate Limiting Schemas + // ==================================================================== + RateLimitConfig: { + type: "object", + required: [ + "merchantId", + "businessType", + "requestsPerSecond", + "requestsPerMinute", + "requestsPerHour", + "requestsPerDay", + "burstMultiplier", + "burstDurationSeconds", + ], + properties: { + id: { + type: "string", + format: "uuid", + description: "Unique identifier for the configuration.", + }, + merchantId: { + type: "string", + description: + "The ID of the merchant this configuration applies to.", + }, + businessType: { + type: "string", + enum: ["standard", "premium", "enterprise"], + description: + "The business type of the merchant, influencing default limits.", + }, + requestsPerSecond: { + type: "number", + description: "Maximum requests allowed per second.", + }, + requestsPerMinute: { + type: "number", + description: "Maximum requests allowed per minute.", + }, + requestsPerHour: { + type: "number", + description: "Maximum requests allowed per hour.", + }, + requestsPerDay: { + type: "number", + description: "Maximum requests allowed per day.", + }, + burstMultiplier: { + type: "number", + format: "float", + description: + "Multiplier for burst allowance (e.g., 1.5 for 50% more requests).", + }, + burstDurationSeconds: { + type: "number", + description: + "Duration in seconds for which burst mode is active.", + }, + createdAt: { + type: "string", + format: "date-time", + description: "Timestamp when the configuration was created.", + }, + updatedAt: { + type: "string", + format: "date-time", + description: "Timestamp when the configuration was last updated.", + }, + }, + }, + RateLimitHistory: { + type: "object", + properties: { + id: { + type: "string", + format: "uuid", + }, + userId: { + type: "string", + nullable: true, + }, + userRole: { + type: "string", + nullable: true, + }, + merchantId: { + type: "string", + nullable: true, + }, + merchantType: { + type: "string", + nullable: true, + }, + endpoint: { + type: "string", + }, + ip: { + type: "string", + }, + requestCount: { + type: "number", + }, + limitUsed: { + type: "number", + nullable: true, + }, + wasThrottled: { + type: "boolean", + }, + wasBurst: { + type: "boolean", + }, + userAgent: { + type: "string", + nullable: true, + }, + timestamp: { + type: "string", + format: "date-time", + }, + }, + }, + RateLimitMetrics: { + type: "object", + properties: { + timeframe: { + type: "string", + enum: ["minute", "hour", "day"], + }, + startTime: { + type: "string", + format: "date-time", + }, + endTime: { + type: "string", + format: "date-time", + }, + totalRequests: { + type: "number", + }, + throttledRequests: { + type: "number", + }, + throttleRate: { + type: "number", + format: "float", + }, + burstRequests: { + type: "number", + }, + burstRate: { + type: "number", + format: "float", + }, + endpointStats: { + type: "object", + additionalProperties: { + type: "object", + properties: { + total: { + type: "number", + }, + throttled: { + type: "number", + }, + burst: { + type: "number", + }, + }, + }, + }, + roleStats: { + type: "object", + additionalProperties: { + type: "object", + properties: { + total: { + type: "number", + }, + throttled: { + type: "number", + }, + burst: { + type: "number", + }, + }, + }, + }, + topThrottledIPs: { + type: "array", + items: { + type: "object", + properties: { + ip: { + type: "string", + }, + throttledCount: { + type: "number", + }, + }, + }, + }, + topThrottledUsers: { + type: "array", + items: { + type: "object", + properties: { + userId: { + type: "string", + }, + throttledCount: { + type: "number", + }, + }, + }, + }, + }, + }, + RealTimeStatus: { + type: "object", + properties: { + activeRequests: { + type: "number", + description: "Number of active requests in the last minute.", + }, + throttledRequests: { + type: "number", + description: "Number of throttled requests in the last minute.", + }, + burstModeActive: { + type: "number", + description: + "Number of requests that utilized burst mode in the last minute.", + }, + activeBurstSessions: { + type: "number", + description: "Number of currently active burst sessions.", + }, + timestamp: { + type: "string", + format: "date-time", + description: "The timestamp of when the status was retrieved.", + }, + recentEvents: { + type: "number", + description: "Number of recent events tracked in memory.", + }, + }, + }, + WhitelistEntry: { + type: "object", + required: ["type", "value"], + properties: { + id: { + type: "string", + format: "uuid", + }, + type: { + type: "string", + enum: ["IP", "USER", "MERCHANT"], + description: "The type of entity being whitelisted.", + }, + value: { + type: "string", + description: + "The actual value (IP address, User ID, Merchant ID).", + }, + reason: { + type: "string", + nullable: true, + description: "Reason for whitelisting.", + }, + addedBy: { + type: "string", + nullable: true, + description: "User who added the entry.", + }, + expiresAt: { + type: "string", + format: "date-time", + nullable: true, + description: "Optional expiration date for the whitelist entry.", + }, + createdAt: { + type: "string", + format: "date-time", + }, + }, + }, + BlacklistEntry: { + type: "object", + required: ["type", "value", "reason"], + properties: { + id: { + type: "string", + format: "uuid", + }, + type: { + type: "string", + enum: ["IP", "USER", "MERCHANT"], + description: "The type of entity being blacklisted.", + }, + value: { + type: "string", + description: + "The actual value (IP address, User ID, Merchant ID).", + }, + reason: { + type: "string", + enum: ["ABUSE", "FRAUD", "MANUAL", "OTHER"], + description: "The reason for blacklisting.", + }, + details: { + type: "string", + nullable: true, + description: "Additional details about the blacklist reason.", + }, + addedBy: { + type: "string", + nullable: true, + description: "User who added the entry.", + }, + expiresAt: { + type: "string", + format: "date-time", + nullable: true, + description: "Optional expiration date for the blacklist entry.", + }, + createdAt: { + type: "string", + format: "date-time", + }, + }, + }, + RateLimitErrorResponse: { + type: "object", + properties: { + status: { + type: "string", + enum: ["error"], + }, + message: { + type: "string", + }, + error: { + type: "string", + nullable: true, + }, + }, + }, }, }, security: [ @@ -460,6 +803,7 @@ const options: swaggerJsdoc.Options = { }, apis: [ "./src/routes/*.ts", + "./src/routes/rateLimitRoutes.ts", "./src/controllers/*.ts", "./src/dtos/*.ts", "./src/entities/*.ts", diff --git a/src/controllers/RateLimitController.ts b/src/controllers/RateLimitController.ts new file mode 100644 index 0000000..a80a81a --- /dev/null +++ b/src/controllers/RateLimitController.ts @@ -0,0 +1,548 @@ +import { Request, Response } from "express"; +import RateLimitMonitoringService from "../services/rateLimitMonitoring.service"; +import rateLimitConfigService from "../services/rateLimitConfigService"; +import whitelistBlacklistService from "../services/whitelistBlacklistService"; +import { WhitelistType } from "src/entities/RateLimitWhiteList"; +import { BlacklistType, BlacklistReason } from "../entities/RateLimitBlacklist"; +import logger from "../utils/logger"; + +class RateLimitController { + // Metrics endpoints + async getMetrics(req: Request, res: Response): Promise { + try { + const { timeframe = "hour" } = req.query; + + const metrics = await RateLimitMonitoringService.getRateLimitMetrics( + timeframe as "minute" | "hour" | "day", + ); + + res.status(200).json({ + status: "success", + data: metrics, + }); + } catch (error) { + logger.error(`Error getting metrics: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve rate limit metrics", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async getMerchantMetrics(req: Request, res: Response): Promise { + try { + const { merchantId } = req.params; + const { timeframe = "hour" } = req.query; + + if (!merchantId) { + res.status(400).json({ + status: "error", + message: "Merchant ID is required", + }); + return; + } + + const metrics = await RateLimitMonitoringService.getRateLimitMetrics( + timeframe as "minute" | "hour" | "day", + merchantId, + ); + + res.status(200).json({ + status: "success", + data: metrics, + }); + } catch (error) { + logger.error(`Error getting merchant metrics: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve merchant rate limit metrics", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async getUserHistory(req: Request, res: Response): Promise { + try { + const { userId } = req.params; + const { limit = "100" } = req.query; + + if (!userId) { + res.status(400).json({ + status: "error", + message: "User ID is required", + }); + return; + } + + const parsedLimit = parseInt(limit as string, 10); + if (isNaN(parsedLimit) || parsedLimit < 1 || parsedLimit > 1000) { + res.status(400).json({ + status: "error", + message: "Limit must be a number between 1 and 1000", + }); + return; + } + + const history = await RateLimitMonitoringService.getUserRateLimitHistory( + userId, + parsedLimit, + ); + + res.status(200).json({ + status: "success", + data: { + userId, + limit: parsedLimit, + count: history.length, + history, + }, + }); + } catch (error) { + logger.error(`Error getting user history: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve user rate limit history", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async getRealTimeStatus(req: Request, res: Response): Promise { + try { + const status = await RateLimitMonitoringService.getRealTimeStatus(); + + res.status(200).json({ + status: "success", + data: status, + }); + } catch (error) { + logger.error(`Error getting real-time status: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve real-time status", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + // Configuration endpoints + async getMerchantConfigs(req: Request, res: Response): Promise { + try { + const { merchantId } = req.params; + + if (!merchantId) { + res.status(400).json({ + status: "error", + message: "Merchant ID is required", + }); + return; + } + + const configs = + await rateLimitConfigService.getAllConfigsForMerchant(merchantId); + + res.status(200).json({ + status: "success", + data: { + merchantId, + count: configs.length, + configs, + }, + }); + } catch (error) { + logger.error(`Error getting merchant configs: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve merchant rate limit configurations", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async createConfig(req: Request, res: Response): Promise { + try { + const configData = req.body; + + // Basic validation + if (!configData.merchantId) { + res.status(400).json({ + status: "error", + message: "Merchant ID is required", + }); + return; + } + + if ( + !configData.requestsPerSecond || + !configData.requestsPerMinute || + !configData.requestsPerHour || + !configData.requestsPerDay + ) { + res.status(400).json({ + status: "error", + message: + "All rate limit values (requestsPerSecond, requestsPerMinute, requestsPerHour, requestsPerDay) are required", + }); + return; + } + + const config = await rateLimitConfigService.createConfig(configData); + + res.status(201).json({ + status: "success", + message: "Rate limit configuration created successfully", + data: config, + }); + } catch (error) { + logger.error(`Error creating config: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to create rate limit configuration", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async updateConfig(req: Request, res: Response): Promise { + try { + const { configId } = req.params; + const updates = req.body; + + if (!configId) { + res.status(400).json({ + status: "error", + message: "Configuration ID is required", + }); + return; + } + + if (Object.keys(updates).length === 0) { + res.status(400).json({ + status: "error", + message: "No updates provided", + }); + return; + } + + const config = await rateLimitConfigService.updateConfig( + configId, + updates, + ); + + res.status(200).json({ + status: "success", + message: "Rate limit configuration updated successfully", + data: config, + }); + } catch (error) { + logger.error(`Error updating config: ${error}`); + + if ((error as Error).message === "Rate limit configuration not found") { + res.status(404).json({ + status: "error", + message: "Rate limit configuration not found", + }); + } else { + res.status(500).json({ + status: "error", + message: "Failed to update rate limit configuration", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + } + + async deleteConfig(req: Request, res: Response): Promise { + try { + const { configId } = req.params; + + if (!configId) { + res.status(400).json({ + status: "error", + message: "Configuration ID is required", + }); + return; + } + + await rateLimitConfigService.deleteConfig(configId); + + res.status(200).json({ + status: "success", + message: "Rate limit configuration deleted successfully", + }); + } catch (error) { + logger.error(`Error deleting config: ${error}`); + + if ((error as Error).message === "Rate limit configuration not found") { + res.status(404).json({ + status: "error", + message: "Rate limit configuration not found", + }); + } else { + res.status(500).json({ + status: "error", + message: "Failed to delete rate limit configuration", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + } + + // Whitelist endpoints + async getWhitelist(req: Request, res: Response): Promise { + try { + const { type } = req.query; + + const whitelist = await whitelistBlacklistService.getWhitelistedEntries( + type as WhitelistType, + ); + + res.status(200).json({ + status: "success", + data: { + type: type || "all", + count: whitelist.length, + entries: whitelist, + }, + }); + } catch (error) { + logger.error(`Error getting whitelist: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve whitelist", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async addToWhitelist(req: Request, res: Response): Promise { + try { + const { type, value, reason, expiresAt } = req.body; + + if (!type || !value) { + res.status(400).json({ + status: "error", + message: "Type and value are required", + }); + return; + } + + if (!Object.values(WhitelistType).includes(type)) { + res.status(400).json({ + status: "error", + message: `Invalid type. Must be one of: ${Object.values(WhitelistType).join(", ")}`, + }); + return; + } + + const whitelist = await whitelistBlacklistService.addToWhitelist( + type, + value, + reason, + req.user?.id?.toString(), + expiresAt ? new Date(expiresAt) : undefined, + ); + + res.status(201).json({ + status: "success", + message: "Entry added to whitelist successfully", + data: whitelist, + }); + } catch (error) { + logger.error(`Error adding to whitelist: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to add to whitelist", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async removeFromWhitelist(req: Request, res: Response): Promise { + try { + const { id } = req.params; + + if (!id) { + res.status(400).json({ + status: "error", + message: "Whitelist entry ID is required", + }); + return; + } + + await whitelistBlacklistService.removeFromWhitelist(id); + + res.status(200).json({ + status: "success", + message: "Removed from whitelist successfully", + }); + } catch (error) { + logger.error(`Error removing from whitelist: ${error}`); + + if ((error as Error).message === "Whitelist entry not found") { + res.status(404).json({ + status: "error", + message: "Whitelist entry not found", + }); + } else { + res.status(500).json({ + status: "error", + message: "Failed to remove from whitelist", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + } + + // Blacklist endpoints + async getBlacklist(req: Request, res: Response): Promise { + try { + const { type } = req.query; + + const blacklist = await whitelistBlacklistService.getBlacklistedEntries( + type as BlacklistType, + ); + + res.status(200).json({ + status: "success", + data: { + type: type || "all", + count: blacklist.length, + entries: blacklist, + }, + }); + } catch (error) { + logger.error(`Error getting blacklist: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to retrieve blacklist", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async addToBlacklist(req: Request, res: Response): Promise { + try { + const { type, value, reason, details, expiresAt } = req.body; + + if (!type || !value) { + res.status(400).json({ + status: "error", + message: "Type and value are required", + }); + return; + } + + if (!Object.values(BlacklistType).includes(type)) { + res.status(400).json({ + status: "error", + message: `Invalid type. Must be one of: ${Object.values(BlacklistType).join(", ")}`, + }); + return; + } + + const blacklist = await whitelistBlacklistService.addToBlacklist( + type, + value, + reason || BlacklistReason.MANUAL, + details, + req.user?.id?.toString(), + expiresAt ? new Date(expiresAt) : undefined, + ); + + res.status(201).json({ + status: "success", + message: "Entry added to blacklist successfully", + data: blacklist, + }); + } catch (error) { + logger.error(`Error adding to blacklist: ${error}`); + res.status(500).json({ + status: "error", + message: "Failed to add to blacklist", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + + async removeFromBlacklist(req: Request, res: Response): Promise { + try { + const { id } = req.params; + + if (!id) { + res.status(400).json({ + status: "error", + message: "Blacklist entry ID is required", + }); + return; + } + + await whitelistBlacklistService.removeFromBlacklist(id); + + res.status(200).json({ + status: "success", + message: "Removed from blacklist successfully", + }); + } catch (error) { + logger.error(`Error removing from blacklist: ${error}`); + + if ((error as Error).message === "Blacklist entry not found") { + res.status(404).json({ + status: "error", + message: "Blacklist entry not found", + }); + } else { + res.status(500).json({ + status: "error", + message: "Failed to remove from blacklist", + error: + process.env.NODE_ENV === "development" + ? (error as Error).message + : undefined, + }); + } + } + } +} + +export default new RateLimitController(); diff --git a/src/controllers/merchant.controller.ts b/src/controllers/merchant.controller.ts index b2a8dd6..338147b 100644 --- a/src/controllers/merchant.controller.ts +++ b/src/controllers/merchant.controller.ts @@ -23,6 +23,8 @@ export class MerchantController { const merchantData: CreateMerchantDTO = { name, email, + secret, + apiKey, isActive: true, }; diff --git a/src/dtos/CreateMerchantDTO.ts b/src/dtos/CreateMerchantDTO.ts index 6e3e66d..0b37adf 100644 --- a/src/dtos/CreateMerchantDTO.ts +++ b/src/dtos/CreateMerchantDTO.ts @@ -14,6 +14,12 @@ export class CreateMerchantDTO { @IsEmail() email: string; + @IsString() + apiKey: string; + + @IsString() + secret: string; + @IsBoolean() @IsOptional() isActive?: boolean; diff --git a/src/entities/RateLimitBlacklist.ts b/src/entities/RateLimitBlacklist.ts new file mode 100644 index 0000000..e088e6d --- /dev/null +++ b/src/entities/RateLimitBlacklist.ts @@ -0,0 +1,62 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +export enum BlacklistType { + IP = "ip", + USER = "user", + MERCHANT = "merchant", +} + +export enum BlacklistReason { + MANUAL = "manual", + ABUSE = "abuse", + FRAUD = "fraud", + EXCESSIVE_USAGE = "excessive_usage", +} + +@Entity("rate_limit_blacklist") +export class RateLimitBlacklist { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ + type: "enum", + enum: BlacklistType, + }) + type: BlacklistType; + + @Column() + @Index({ unique: true }) + value: string; + + @Column({ + type: "enum", + enum: BlacklistReason, + default: BlacklistReason.MANUAL, + }) + reason: BlacklistReason; + + @Column({ nullable: true }) + details: string; + + @Column({ nullable: true }) + addedBy: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true }) + expiresAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entities/RateLimitConfig.ts b/src/entities/RateLimitConfig.ts new file mode 100644 index 0000000..99250a9 --- /dev/null +++ b/src/entities/RateLimitConfig.ts @@ -0,0 +1,56 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { MerchantEntity } from "./Merchant.entity"; + +@Entity("rate_limit_configs") +export class RateLimitConfig { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid" }) + merchantId: string; + + @ManyToOne(() => MerchantEntity) + @JoinColumn({ name: "merchantId" }) + merchant: MerchantEntity; + + @Column({ nullable: true }) + userRole?: string; + + @Column({ nullable: true }) + merchantType?: string; + + @Column({ type: "int" }) + requestsPerSecond: number; + + @Column({ type: "int" }) + requestsPerMinute: number; + + @Column({ type: "int" }) + requestsPerHour: number; + + @Column({ type: "int" }) + requestsPerDay: number; + + @Column({ type: "float", default: 2.0 }) + burstMultiplier: number; + + @Column({ type: "int", default: 30 }) + burstDurationSeconds: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/entities/RateLimitHistory.ts b/src/entities/RateLimitHistory.ts new file mode 100644 index 0000000..aa720f9 --- /dev/null +++ b/src/entities/RateLimitHistory.ts @@ -0,0 +1,55 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from "typeorm"; + +@Entity("rate_limit_history") +export class RateLimitHistory { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ type: "uuid", nullable: true }) + @Index() + userId?: string; + + @Column({ nullable: true }) + @Index() + userRole?: string; + + @Column({ type: "uuid", nullable: true }) + @Index() + merchantId?: string; + + @Column({ nullable: true }) + merchantType?: string; + + @Column() + @Index() + endpoint: string; + + @Column() + @Index() + ip: string; + + @Column({ type: "int" }) + requestCount: number; + + @Column({ type: "int", nullable: true }) + limitUsed: number; + + @Column({ default: false }) + wasThrottled: boolean; + + @Column({ default: false }) + wasBurst: boolean; + + @Column({ nullable: true }) + userAgent?: string; + + @CreateDateColumn() + @Index() + timestamp: Date; +} diff --git a/src/entities/RateLimitWhiteList.ts b/src/entities/RateLimitWhiteList.ts new file mode 100644 index 0000000..916b163 --- /dev/null +++ b/src/entities/RateLimitWhiteList.ts @@ -0,0 +1,48 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; + +export enum WhitelistType { + IP = "ip", + USER = "user", + MERCHANT = "merchant", +} + +@Entity("rate_limit_whitelist") +export class RateLimitWhitelist { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ + type: "enum", + enum: WhitelistType, + }) + type: WhitelistType; + + @Column() + @Index({ unique: true }) + value: string; + + @Column({ nullable: true }) + reason: string; + + @Column({ nullable: true }) + addedBy: string; + + @Column({ default: true }) + isActive: boolean; + + @Column({ nullable: true }) + expiresAt: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/index.ts b/src/index.ts index de769be..660986e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,13 +1,14 @@ import "reflect-metadata"; import app from "./app"; import AppDataSource from "./config/db"; +import adaptiveRateLimitService from "./services/adaptiveRateLimitService"; async function main() { try { // Initialize the database connection await AppDataSource.initialize(); console.log("✅ Database connected successfully"); - + adaptiveRateLimitService.startAdjustment(); // Start the server const PORT = process.env.PORT || 4000; const server = app.listen(PORT, () => { diff --git a/src/interfaces/auth.interfaces.ts b/src/interfaces/auth.interfaces.ts index 5ea27a5..b4c4512 100644 --- a/src/interfaces/auth.interfaces.ts +++ b/src/interfaces/auth.interfaces.ts @@ -6,18 +6,19 @@ export interface UserRegistrationData { export interface UserResponse { id: number; - name: string; + name?: string; email: string; - role: string; - isEmailVerified: boolean; - isWalletVerified: boolean; - createdAt: Date; - updatedAt: Date; + role?: string; + isEmailVerified?: boolean; + isWalletVerified?: boolean; + createdAt?: Date; + updatedAt?: Date; twoFactorAuth?: { - isEnabled: boolean; + isEnabled?: boolean; }; + tokenExp?: number; + jti?: string; } - export interface TokenResponse { accessToken: string; refreshToken: string; diff --git a/src/interfaces/fruadDetection.interface.ts b/src/interfaces/fruadDetection.interface.ts new file mode 100644 index 0000000..1bf17c4 --- /dev/null +++ b/src/interfaces/fruadDetection.interface.ts @@ -0,0 +1,44 @@ +export interface SuspiciousIPResult { + ip: string; + count: string; +} + +export interface SuspiciousUserResult { + userId: string; + count: string; +} + +export interface SuspiciousActivity { + ip: string; + throttledCount: number; +} + +export interface SuspiciousUser { + userId: string; + throttledCount: number; +} + +export interface RiskIndicators { + highRiskIPs: number; + highRiskUsers: number; + averageThrottlePerIP: number; + averageThrottlePerUser: number; +} + +export interface RateLimitFraudStats { + period: { + startDate: Date; + endDate: Date; + days: number; + }; + totalEvents: number; + throttledEvents: number; + burstEvents: number; + throttleRate: number; + burstRate: number; + suspiciousActivity: { + suspiciousIPs: SuspiciousActivity[]; + suspiciousUsers: SuspiciousUser[]; + }; + riskIndicators: RiskIndicators; +} diff --git a/src/middleware/rateLimiter.ts b/src/middleware/rateLimiter.ts index ddcaab2..5ca2f28 100644 --- a/src/middleware/rateLimiter.ts +++ b/src/middleware/rateLimiter.ts @@ -1,4 +1,13 @@ import rateLimit from "express-rate-limit"; +import { Request, Response } from "express"; +import rateLimitConfigService from "../services/rateLimitConfigService"; +import whitelistBlacklistService from "../services/whitelistBlacklistService"; +import { WhitelistType } from "../entities/RateLimitWhiteList"; +import { BlacklistType } from "../entities/RateLimitBlacklist"; +import RateLimitMonitoringService from "../services/rateLimitMonitoring.service"; +import { redisClient } from "../config/redisConfig"; +import logger from "../utils/logger"; +import { Merchant } from "../interfaces/webhook.interfaces"; export const paymentLinkLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes @@ -11,3 +20,346 @@ export const paymentLinkLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, }); + +export const intelligentRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute window + + // Dynamic limit based on user context + max: async (req: Request) => { + try { + // Get IP address + const ip = req.ip || req.socket.remoteAddress || "0.0.0.0"; + + // Check blacklist first - completely block blacklisted entities + if (await whitelistBlacklistService.isBlacklisted(BlacklistType.IP, ip)) { + return 0; // Block completely + } + + if ( + req.user?.id && + (await whitelistBlacklistService.isBlacklisted( + BlacklistType.USER, + req.user.id.toString(), + )) + ) { + return 0; + } + + if ( + req.merchant?.id && + (await whitelistBlacklistService.isBlacklisted( + BlacklistType.MERCHANT, + req.merchant.id, + )) + ) { + return 0; + } + + if (await whitelistBlacklistService.isWhitelisted(WhitelistType.IP, ip)) { + return 0; + } + + if ( + req.user?.id && + (await whitelistBlacklistService.isWhitelisted( + WhitelistType.USER, + req.user.id.toString(), + )) + ) { + return 0; + } + + if ( + req.merchant?.id && + (await whitelistBlacklistService.isWhitelisted( + WhitelistType.MERCHANT, + req.merchant.id, + )) + ) { + return 0; + } + + // Get dynamic limit based on user context + if (req.user?.id && req.merchant?.id && req.user?.role) { + try { + const config = await rateLimitConfigService.getConfigForUser( + req.user.id.toString(), + req.merchant.id, + req.user.role, + ); + + // Check if user is in burst mode + const burstKey = `burst:${req.user.id}:${req.originalUrl}`; + const burstActive = await redisClient.get(burstKey); + + if (burstActive) { + const burstLimit = Math.floor( + config.requestsPerMinute * config.burstMultiplier, + ); + logger.info( + `Burst mode active for user ${req.user.id}: limit ${burstLimit}`, + ); + return burstLimit; + } + + return config.requestsPerMinute; + } catch (error) { + logger.error(`Error getting user-specific rate limit: ${error}`); + } + } + + // Default limits based on authentication status + if (req.user?.id) { + // Authenticated users get higher limits + return req.user.role === "ADMIN" ? 200 : 100; + } + + // Unauthenticated requests get lower limits + return 30; + } catch (error) { + logger.error(`Error determining rate limit: ${error}`); + return 60; + } + }, + + // Custom key generator to handle different contexts + keyGenerator: (req: Request) => { + if (req.user?.id) { + return `user:${req.user.id}:${req.originalUrl}`; + } + + const ip = req.ip || req.socket.remoteAddress || "unknown"; + return `ip:${ip}:${req.originalUrl}`; + }, + + // Custom message with more context + message: (req: Request, res: Response) => { + const limit = res.getHeader("X-RateLimit-Limit"); + const remaining = res.getHeader("X-RateLimit-Remaining"); + const resetTime = res.getHeader("X-RateLimit-Reset"); + + return { + status: "error", + message: "Too many requests, please try again later", + code: "RATE_LIMIT_EXCEEDED", + limit: limit, + remaining: remaining, + resetTime: resetTime, + retryAfter: 60, + }; + }, + + // Enhanced headers + standardHeaders: true, + legacyHeaders: false, + + // Custom handler for rate limit exceeded + handler: async (req: Request, res: Response) => { + try { + const ip = req.ip || req.socket.remoteAddress || "0.0.0.0"; + + // Log the rate limit event using your existing service + await RateLimitMonitoringService.logAdvancedRateLimitEvent({ + ip, + endpoint: req.originalUrl, + userAgent: req.headers["user-agent"], + timestamp: new Date(), + userId: req.user?.id, + email: req.user?.email, + userRole: req.user?.role, + merchantId: req.merchant?.id, + merchantType: determineMerchantType(req.merchant), + wasThrottled: true, + requestCount: 1, + limitUsed: parseInt(res.getHeader("X-RateLimit-Limit") as string) || 0, + }); + + // Check if user should enter burst mode (only for authenticated users) + if (req.user?.id) { + const burstKey = `burst:${req.user.id}:${req.originalUrl}`; + const burstActive = await redisClient.get(burstKey); + + if (!burstActive) { + // Get user's config to determine burst settings + try { + const config = await rateLimitConfigService.getConfigForUser( + req.user.id.toString(), + req.merchant?.id || "default", + req.user.role || "USER", + ); + + // Activate burst mode + await redisClient.set(burstKey, "1", { + EX: config.burstDurationSeconds, + }); + + // Set burst header + res.setHeader("X-RateLimit-Burst", "activated"); + res.setHeader( + "X-RateLimit-Burst-Duration", + config.burstDurationSeconds.toString(), + ); + + logger.info( + `Burst mode activated for user ${req.user.id} on ${req.originalUrl} for ${config.burstDurationSeconds}s`, + ); + } catch (error) { + logger.error(`Error activating burst mode: ${error}`); + } + } else { + res.setHeader("X-RateLimit-Burst", "active"); + } + } + + // Set additional headers + res.setHeader("X-RateLimit-Type", req.user?.id ? "user" : "ip"); + if (req.user?.role) { + res.setHeader("X-RateLimit-User-Role", req.user.role); + } + if (req.merchant?.id) { + res.setHeader("X-RateLimit-Merchant", req.merchant.id); + } + + res.status(429).json({ + status: "error", + message: "Too many requests, please try again later", + code: "RATE_LIMIT_EXCEEDED", + retryAfter: 60, + burstModeAvailable: !!req.user?.id, + context: { + userAuthenticated: !!req.user?.id, + userRole: req.user?.role, + merchantId: req.merchant?.id, + }, + }); + } catch (error) { + logger.error(`Error in rate limit handler: ${error}`); + + // Fallback response + res.status(429).json({ + status: "error", + message: "Too many requests, please try again later", + code: "RATE_LIMIT_EXCEEDED", + retryAfter: 60, + }); + } + }, + + // Skip function for certain paths + skip: (req: Request) => { + const skipPaths = ["/health", "/api-docs", "/favicon.ico"]; + return skipPaths.some((path) => req.path.startsWith(path)); + }, +}); + +// Helper function to determine merchant type +function determineMerchantType(merchant: Merchant | undefined): string { + if (!merchant) return "standard"; + + if ( + merchant.business_name && + merchant.business_name.toLowerCase().includes("enterprise") + ) { + return "enterprise"; + } + + if ( + merchant.business_name && + merchant.business_name.toLowerCase().includes("premium") + ) { + return "premium"; + } + + return "standard"; +} + +export const fraudAlertsRateLimit = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: async (req: Request) => { + // Higher limits for admins + if (req.user?.role === "ADMIN") { + return 200; + } + return 100; + }, + message: { + success: false, + error: "Too many fraud alert requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => { + return req.user?.id + ? `fraud-alerts:user:${req.user.id}` + : `fraud-alerts:ip:${req.ip}`; + }, +}); + +export const fraudConfigRateLimit = rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + max: async (req: Request) => { + // Only admins can update config + if (req.user?.role === "ADMIN") { + return 20; + } + return 0; // Block non-admins + }, + message: { + success: false, + error: "Too many configuration update requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => { + return req.user?.id + ? `fraud-config:user:${req.user.id}` + : `fraud-config:ip:${req.ip}`; + }, +}); + +export const fraudStatsRateLimit = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: async (req: Request) => { + // Higher limits for admins + if (req.user?.role === "ADMIN") { + return 60; + } + return 30; + }, + message: { + success: false, + error: "Too many statistics requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => { + return req.user?.id + ? `fraud-stats:user:${req.user.id}` + : `fraud-stats:ip:${req.ip}`; + }, +}); + +export const fraudReviewRateLimit = rateLimit({ + windowMs: 1 * 60 * 1000, // 1 minute + max: async (req: Request) => { + // Higher limits for admins + if (req.user?.role === "ADMIN") { + return 40; + } + return 20; + }, + message: { + success: false, + error: "Too many review requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req: Request) => { + return req.user?.id + ? `fraud-review:user:${req.user.id}` + : `fraud-review:ip:${req.ip}`; + }, +}); + +// Export the main limiter as default for easy importing +export default intelligentRateLimiter; diff --git a/src/middlewares/authMiddleware.ts b/src/middlewares/authMiddleware.ts index b9196cb..e385375 100644 --- a/src/middlewares/authMiddleware.ts +++ b/src/middlewares/authMiddleware.ts @@ -8,7 +8,6 @@ import { import { UserRole } from "../enums/UserRole"; import { UserService } from "../services/UserService"; import { redisClient } from "../config/redisConfig"; -import { MerchantEntity } from "../entities/Merchant.entity"; export const authMiddleware = async ( req: Request, @@ -64,8 +63,8 @@ export const authMiddleware = async ( } req.user = { - id: decoded.id, - email: decoded.email, + id: decoded.id as number, + email: decoded.email as string, tokenExp: decoded.exp, jti: decoded.jti, }; @@ -156,8 +155,8 @@ export const refreshTokenMiddleware = async ( // Add user info to request req.user = { - id: decoded.id, - email: decoded.email, + id: decoded.id as number, + email: decoded.email as string, tokenExp: decoded.exp, jti: decoded.jti, }; diff --git a/src/middlewares/permissionMiddleware.ts b/src/middlewares/permissionMiddleware.ts index 27818c8..5ca070a 100644 --- a/src/middlewares/permissionMiddleware.ts +++ b/src/middlewares/permissionMiddleware.ts @@ -3,15 +3,10 @@ import { getRBACService } from "../services/RBACService"; import { PermissionResource, PermissionAction } from "../entities/Permission"; import { UserRole } from "../enums/UserRole"; import { MerchantEntity } from "../entities/Merchant.entity"; +import { UserResponse } from "src/interfaces/auth.interfaces"; export interface AuthenticatedRequest extends Request { - user?: { - id: number; - email: string; - tokenExp?: number; - jti?: string; - role?: UserRole; - }; + user?: UserResponse; merchant?: MerchantEntity; } diff --git a/src/routes/index.ts b/src/routes/index.ts index 20606bf..53469e8 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -7,6 +7,7 @@ import auditRoutes from "./audit.routes"; import walletRoutes from "./wallet"; import { subscriptionRouter } from "./subscriptionRoutes"; import teamRoutes from "./teamRoutes"; +import rateLimitRoutes from "./rateLimitRoutes"; const router = Router(); @@ -18,5 +19,6 @@ router.use("/api/team", teamRoutes); router.use("/subscriptions", subscriptionRouter); router.use("/audit", auditRoutes); router.use("/wallet", walletRoutes); +router.use("/rate-limit", rateLimitRoutes); export default router; diff --git a/src/routes/rateLimitRoutes.ts b/src/routes/rateLimitRoutes.ts new file mode 100644 index 0000000..7999860 --- /dev/null +++ b/src/routes/rateLimitRoutes.ts @@ -0,0 +1,1241 @@ +import { Router } from "express"; +import rateLimitController from "../controllers/RateLimitController"; +import { + authMiddleware, + isUserAuthorized, +} from "../middlewares/authMiddleware"; +import { UserRole } from "../enums/UserRole"; + +const rateLimitRouter = Router(); + +/** + * @swagger + * tags: + * name: Rate Limit Monitoring + * description: API for monitoring and retrieving rate limit statistics. + * security: + * - bearerAuth: [] + */ + +/** + * @swagger + * tags: + * name: Rate Limit Configuration + * description: API for managing dynamic rate limit configurations for merchants and users. + * security: + * - bearerAuth: [] + */ + +/** + * @swagger + * tags: + * name: Rate Limit Whitelist + * description: API for managing whitelisted entities (IPs, Users, Merchants) that bypass rate limits. + * security: + * - bearerAuth: [] + */ + +/** + * @swagger + * tags: + * name: Rate Limit Blacklist + * description: API for managing blacklisted entities (IPs, Users, Merchants) that are blocked by rate limits. + * security: + * - bearerAuth: [] + */ + +/** + * @swagger + * components: + * securitySchemes: + * bearerAuth: + * type: http + * scheme: bearer + * bearerFormat: JWT + * schemas: + * RateLimitConfig: + * type: object + * required: + * - merchantId + * - businessType + * - requestsPerSecond + * - requestsPerMinute + * - requestsPerHour + * - requestsPerDay + * - burstMultiplier + * - burstDurationSeconds + * properties: + * id: + * type: string + * format: uuid + * description: Unique identifier for the configuration. + * merchantId: + * type: string + * description: The ID of the merchant this configuration applies to. + * businessType: + * type: string + * enum: [standard, premium, enterprise] + * description: The business type of the merchant, influencing default limits. + * requestsPerSecond: + * type: number + * description: Maximum requests allowed per second. + * requestsPerMinute: + * type: number + * description: Maximum requests allowed per minute. + * requestsPerHour: + * type: number + * description: Maximum requests allowed per hour. + * requestsPerDay: + * type: number + * description: Maximum requests allowed per day. + * burstMultiplier: + * type: number + * format: float + * description: Multiplier for burst allowance (e.g., 1.5 for 50% more requests). + * burstDurationSeconds: + * type: number + * description: Duration in seconds for which burst mode is active. + * createdAt: + * type: string + * format: date-time + * description: Timestamp when the configuration was created. + * updatedAt: + * type: string + * format: date-time + * description: Timestamp when the configuration was last updated. + * RateLimitHistory: + * type: object + * properties: + * id: + * type: string + * format: uuid + * userId: + * type: string + * nullable: true + * userRole: + * type: string + * nullable: true + * merchantId: + * type: string + * nullable: true + * merchantType: + * type: string + * nullable: true + * endpoint: + * type: string + * ip: + * type: string + * requestCount: + * type: number + * limitUsed: + * type: number + * nullable: true + * wasThrottled: + * type: boolean + * wasBurst: + * type: boolean + * userAgent: + * type: string + * nullable: true + * timestamp: + * type: string + * format: date-time + * RateLimitMetrics: + * type: object + * properties: + * timeframe: + * type: string + * enum: [minute, hour, day] + * startTime: + * type: string + * format: date-time + * endTime: + * type: string + * format: date-time + * totalRequests: + * type: number + * throttledRequests: + * type: number + * throttleRate: + * type: number + * format: float + * burstRequests: + * type: number + * burstRate: + * type: number + * format: float + * endpointStats: + * type: object + * additionalProperties: + * type: object + * properties: + * total: + * type: number + * throttled: + * type: number + * burst: + * type: number + * roleStats: + * type: object + * additionalProperties: + * type: object + * properties: + * total: + * type: number + * throttled: + * type: number + * burst: + * type: number + * topThrottledIPs: + * type: array + * items: + * type: object + * properties: + * ip: + * type: string + * throttledCount: + * type: number + * topThrottledUsers: + * type: array + * items: + * type: object + * properties: + * userId: + * type: string + * throttledCount: + * type: number + * RealTimeStatus: + * type: object + * properties: + * activeRequests: + * type: number + * description: Number of active requests in the last minute. + * throttledRequests: + * type: number + * description: Number of throttled requests in the last minute. + * burstModeActive: + * type: number + * description: Number of requests that utilized burst mode in the last minute. + * activeBurstSessions: + * type: number + * description: Number of currently active burst sessions. + * timestamp: + * type: string + * format: date-time + * description: The timestamp of when the status was retrieved. + * recentEvents: + * type: number + * description: Number of recent events tracked in memory. + * WhitelistEntry: + * type: object + * required: + * - type + * - value + * properties: + * id: + * type: string + * format: uuid + * type: + * type: string + * enum: [IP, USER, MERCHANT] + * description: The type of entity being whitelisted. + * value: + * type: string + * description: The actual value (IP address, User ID, Merchant ID). + * reason: + * type: string + * nullable: true + * description: Reason for whitelisting. + * addedBy: + * type: string + * nullable: true + * description: User who added the entry. + * expiresAt: + * type: string + * format: date-time + * nullable: true + * description: Optional expiration date for the whitelist entry. + * createdAt: + * type: string + * format: date-time + * BlacklistEntry: + * type: object + * required: + * - type + * - value + * - reason + * properties: + * id: + * type: string + * format: uuid + * type: + * type: string + * enum: [IP, USER, MERCHANT] + * description: The type of entity being blacklisted. + * value: + * type: string + * description: The actual value (IP address, User ID, Merchant ID). + * reason: + * type: string + * enum: [ABUSE, FRAUD, MANUAL, OTHER] + * description: The reason for blacklisting. + * details: + * type: string + * nullable: true + * description: Additional details about the blacklist reason. + * addedBy: + * type: string + * nullable: true + * description: User who added the entry. + * expiresAt: + * type: string + * format: date-time + * nullable: true + * description: Optional expiration date for the blacklist entry. + * createdAt: + * type: string + * format: date-time + * ErrorResponse: + * type: object + * properties: + * status: + * type: string + * enum: [error] + * message: + * type: string + * error: + * type: string + * nullable: true + */ + +// ==================================================================== +// Rate Limiting API Endpoints +// ==================================================================== + +/** + * @swagger + * /metrics: + * get: + * summary: Get overall rate limit metrics + * tags: [Rate Limit Monitoring] + * description: Retrieves aggregated rate limit metrics across all merchants or users. + * parameters: + * - in: query + * name: timeframe + * schema: + * type: string + * enum: [minute, hour, day] + * default: hour + * description: The time frame for which to retrieve metrics. + * responses: + * 200: + * description: Successfully retrieved rate limit metrics. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * $ref: '#/components/schemas/RateLimitMetrics' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get("/metrics", authMiddleware, rateLimitController.getMetrics); + +/** + * @swagger + * /metrics/merchant/{merchantId}: + * get: + * summary: Get rate limit metrics for a specific merchant + * tags: [Rate Limit Monitoring] + * description: Retrieves aggregated rate limit metrics for a given merchant ID. + * parameters: + * - in: path + * name: merchantId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the merchant. + * - in: query + * name: timeframe + * schema: + * type: string + * enum: [minute, hour, day] + * default: hour + * description: The time frame for which to retrieve metrics. + * responses: + * 200: + * description: Successfully retrieved merchant-specific rate limit metrics. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * $ref: '#/components/schemas/RateLimitMetrics' + * 400: + * description: Bad Request - Merchant ID is missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get( + "/metrics/merchant/:merchantId", + authMiddleware, + rateLimitController.getMerchantMetrics, +); + +/** + * @swagger + * /history/user/{userId}: + * get: + * summary: Get rate limit history for a specific user + * tags: [Rate Limit Monitoring] + * description: Retrieves a detailed history of rate limit events for a given user ID. + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the user. + * - in: query + * name: limit + * schema: + * type: number + * default: 100 + * minimum: 1 + * maximum: 1000 + * description: The maximum number of history entries to return. + * responses: + * 200: + * description: Successfully retrieved user rate limit history. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * type: object + * properties: + * userId: + * type: string + * limit: + * type: number + * count: + * type: number + * history: + * type: array + * items: + * $ref: '#/components/schemas/RateLimitHistory' + * 400: + * description: Bad Request - User ID or limit is missing/invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get( + "/history/user/:userId", + authMiddleware, + rateLimitController.getUserHistory, +); + +/** + * @swagger + * /status: + * get: + * summary: Get real-time rate limit status + * tags: [Rate Limit Monitoring] + * description: Retrieves real-time statistics on active, throttled, and burst requests. + * responses: + * 200: + * description: Successfully retrieved real-time status. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * $ref: '#/components/schemas/RealTimeStatus' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get( + "/status", + authMiddleware, + rateLimitController.getRealTimeStatus, +); + +/** + * @swagger + * /config/merchant/{merchantId}: + * get: + * summary: Get all rate limit configurations for a merchant + * tags: [Rate Limit Configuration] + * description: Retrieves all dynamic rate limit configurations associated with a specific merchant ID. Requires ADMIN role. + * parameters: + * - in: path + * name: merchantId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the merchant. + * responses: + * 200: + * description: Successfully retrieved merchant configurations. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * type: object + * properties: + * merchantId: + * type: string + * count: + * type: number + * configs: + * type: array + * items: + * $ref: '#/components/schemas/RateLimitConfig' + * 400: + * description: Bad Request - Merchant ID is missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get( + "/config/merchant/:merchantId", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.getMerchantConfigs, +); + +/** + * @swagger + * /config: + * post: + * summary: Create a new rate limit configuration + * tags: [Rate Limit Configuration] + * description: Creates a new dynamic rate limit configuration for a merchant. Requires ADMIN role. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RateLimitConfig' + * example: + * merchantId: "a1b2c3d4-e5f6-7890-1234-567890abcdef" + * businessType: "standard" + * requestsPerSecond: 10 + * requestsPerMinute: 500 + * requestsPerHour: 20000 + * requestsPerDay: 100000 + * burstMultiplier: 1.2 + * burstDurationSeconds: 300 + * responses: + * 201: + * description: Rate limit configuration created successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Rate limit configuration created successfully + * data: + * $ref: '#/components/schemas/RateLimitConfig' + * 400: + * description: Bad Request - Missing required fields or invalid data. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.post( + "/config", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.createConfig, +); + +/** + * @swagger + * /config/{configId}: + * put: + * summary: Update an existing rate limit configuration + * tags: [Rate Limit Configuration] + * description: Updates a dynamic rate limit configuration by its ID. Requires ADMIN role. + * parameters: + * - in: path + * name: configId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the rate limit configuration to update. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RateLimitConfig' + * example: + * requestsPerMinute: 600 + * burstMultiplier: 1.3 + * responses: + * 200: + * description: Rate limit configuration updated successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Rate limit configuration updated successfully + * data: + * $ref: '#/components/schemas/RateLimitConfig' + * 400: + * description: Bad Request - No updates provided or invalid data. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: Not Found - Rate limit configuration not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.put( + "/config/:configId", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.updateConfig, +); + +/** + * @swagger + * /config/{configId}: + * delete: + * summary: Delete a rate limit configuration + * tags: [Rate Limit Configuration] + * description: Deletes a dynamic rate limit configuration by its ID. Requires ADMIN role. + * parameters: + * - in: path + * name: configId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the rate limit configuration to delete. + * responses: + * 200: + * description: Rate limit configuration deleted successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Rate limit configuration deleted successfully + * 400: + * description: Bad Request - Configuration ID is missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: Not Found - Rate limit configuration not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.delete( + "/config/:configId", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.deleteConfig, +); + +/** + * @swagger + * /whitelist: + * get: + * summary: Get all whitelisted entries + * tags: [Rate Limit Whitelist] + * description: Retrieves all entries in the rate limit whitelist. Can filter by type. Requires ADMIN role. + * parameters: + * - in: query + * name: type + * schema: + * type: string + * enum: [IP, USER, MERCHANT] + * description: Optional filter to retrieve entries of a specific type. + * responses: + * 200: + * description: Successfully retrieved whitelist entries. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * type: object + * properties: + * type: + * type: string + * enum: [IP, USER, MERCHANT, all] + * count: + * type: number + * entries: + * type: array + * items: + * $ref: '#/components/schemas/WhitelistEntry' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * post: + * summary: Add an entry to the whitelist + * tags: [Rate Limit Whitelist] + * description: Adds a new IP, User, or Merchant ID to the rate limit whitelist. Requires ADMIN role. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - type + * - value + * properties: + * type: + * type: string + * enum: [IP, USER, MERCHANT] + * description: The type of entity to whitelist. + * value: + * type: string + * description: The value of the entity (e.g., "192.168.1.1", "user123", "merchantABC"). + * reason: + * type: string + * nullable: true + * description: Optional reason for whitelisting. + * expiresAt: + * type: string + * format: date-time + * nullable: true + * description: Optional expiration date for the whitelist entry. + * example: + * type: "IP" + * value: "203.0.113.45" + * reason: "Internal testing server" + * expiresAt: "2025-12-31T23:59:59Z" + * responses: + * 201: + * description: Entry added to whitelist successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Entry added to whitelist successfully + * data: + * $ref: '#/components/schemas/WhitelistEntry' + * 400: + * description: Bad Request - Missing required fields or invalid type/value. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get( + "/whitelist", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.getWhitelist, +); +rateLimitRouter.post( + "/whitelist", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.addToWhitelist, +); + +/** + * @swagger + * /whitelist/{id}: + * delete: + * summary: Remove an entry from the whitelist + * tags: [Rate Limit Whitelist] + * description: Removes a whitelist entry by its ID. Requires ADMIN role. + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the whitelist entry to remove. + * responses: + * 200: + * description: Removed from whitelist successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Removed from whitelist successfully + * 400: + * description: Bad Request - Whitelist entry ID is missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: Not Found - Whitelist entry not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.delete( + "/whitelist/:id", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.removeFromWhitelist, +); + +/** + * @swagger + * /blacklist: + * get: + * summary: Get all blacklisted entries + * tags: [Rate Limit Blacklist] + * description: Retrieves all entries in the rate limit blacklist. Can filter by type. Requires ADMIN role. + * parameters: + * - in: query + * name: type + * schema: + * type: string + * enum: [IP, USER, MERCHANT] + * description: Optional filter to retrieve entries of a specific type. + * responses: + * 200: + * description: Successfully retrieved blacklist entries. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * data: + * type: object + * properties: + * type: + * type: string + * enum: [IP, USER, MERCHANT, all] + * count: + * type: number + * entries: + * type: array + * items: + * $ref: '#/components/schemas/BlacklistEntry' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * post: + * summary: Add an entry to the blacklist + * tags: [Rate Limit Blacklist] + * description: Adds a new IP, User, or Merchant ID to the rate limit blacklist. Requires ADMIN role. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - type + * - value + * - reason + * properties: + * type: + * type: string + * enum: [IP, USER, MERCHANT] + * description: The type of entity to blacklist. + * value: + * type: string + * description: The value of the entity (e.g., "192.168.1.1", "user123", "merchantABC"). + * reason: + * type: string + * enum: [ABUSE, FRAUD, MANUAL, OTHER] + * description: The reason for blacklisting. + * details: + * type: string + * nullable: true + * description: Optional additional details about the blacklist reason. + * expiresAt: + * type: string + * format: date-time + * nullable: true + * description: Optional expiration date for the blacklist entry. + * example: + * type: "USER" + * value: "user12345" + * reason: "ABUSE" + * details: "Repeated attempts to bypass rate limits" + * expiresAt: "2025-08-01T00:00:00Z" + * responses: + * 201: + * description: Entry added to blacklist successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Entry added to blacklist successfully + * data: + * $ref: '#/components/schemas/BlacklistEntry' + * 400: + * description: Bad Request - Missing required fields or invalid type/value/reason. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.get( + "/blacklist", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.getBlacklist, +); +rateLimitRouter.post( + "/blacklist", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.addToBlacklist, +); + +/** + * @swagger + * /blacklist/{id}: + * delete: + * summary: Remove an entry from the blacklist + * tags: [Rate Limit Blacklist] + * description: Removes a blacklist entry by its ID. Requires ADMIN role. + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the blacklist entry to remove. + * responses: + * 200: + * description: Removed from blacklist successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * message: + * type: string + * example: Removed from blacklist successfully + * 400: + * description: Bad Request - Blacklist entry ID is missing. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 401: + * description: Unauthorized - Authentication token missing or invalid. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 403: + * description: Forbidden - User does not have ADMIN role. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 404: + * description: Not Found - Blacklist entry not found. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' + */ +rateLimitRouter.delete( + "/blacklist/:id", + authMiddleware, + isUserAuthorized([UserRole.ADMIN]), + rateLimitController.removeFromBlacklist, +); + +export default rateLimitRouter; diff --git a/src/services/FraudDetectionService.ts b/src/services/FraudDetectionService.ts index 7986c01..35dfd0e 100644 --- a/src/services/FraudDetectionService.ts +++ b/src/services/FraudDetectionService.ts @@ -15,6 +15,16 @@ import { RiskLevelBreakdownDTO, TopTriggeredRuleDTO, } from "../dtos/FraudDetection.dto"; +import whitelistBlacklistService from "./whitelistBlacklistService"; +import { BlacklistType, BlacklistReason } from "../entities/RateLimitBlacklist"; +import { RateLimitHistory } from "../entities/RateLimitHistory"; +import { + RateLimitFraudStats, + SuspiciousActivity, + SuspiciousIPResult, + SuspiciousUser, + SuspiciousUserResult, +} from "src/interfaces/fruadDetection.interface"; export class FraudDetectionService { private transactionRepo: Repository; @@ -458,4 +468,291 @@ export class FraudDetectionService { return result?.average ? parseFloat(result.average) : null; } + + // ======================================== + // Methods to check rate limiting + // ======================================== + + async checkRateLimitingPatterns( + userId: string, + ip: string, + merchantId: string, + ): Promise<{ riskScore: number; rulesTriggered: string[] }> { + let riskScore = 0; + const rulesTriggered: string[] = []; + + try { + // Check if user has been rate limited recently + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + // Get rate limit history repository + const rateLimitHistoryRepo = + AppDataSource.getRepository(RateLimitHistory); + + const recentRateLimits = await rateLimitHistoryRepo.count({ + where: { + userId, + wasThrottled: true, + timestamp: MoreThanOrEqual(oneHourAgo), + }, + }); + + if (recentRateLimits > 5) { + riskScore += 25; + rulesTriggered.push("EXCESSIVE_RATE_LIMITING"); + } + + // Check for IP-based rate limiting patterns + const ipRateLimits = await rateLimitHistoryRepo.count({ + where: { + ip, + wasThrottled: true, + timestamp: MoreThanOrEqual(oneHourAgo), + }, + }); + + if (ipRateLimits > 10) { + riskScore += 30; + rulesTriggered.push("IP_RATE_LIMIT_ABUSE"); + } + + // Check for burst mode abuse + const burstModeUsage = await rateLimitHistoryRepo.count({ + where: { + userId, + wasBurst: true, + timestamp: MoreThanOrEqual(oneHourAgo), + }, + }); + + if (burstModeUsage > 3) { + riskScore += 15; + rulesTriggered.push("BURST_MODE_ABUSE"); + } + + // Check for rapid endpoint switching (potential scraping) + const endpointSwitching = await rateLimitHistoryRepo + .createQueryBuilder("history") + .select("COUNT(DISTINCT history.endpoint)", "endpointCount") + .where("history.userId = :userId", { userId }) + .andWhere("history.timestamp >= :oneHourAgo", { oneHourAgo }) + .getRawOne(); + + if (endpointSwitching && parseInt(endpointSwitching.endpointCount) > 10) { + riskScore += 20; + rulesTriggered.push("RAPID_ENDPOINT_SWITCHING"); + } + + // Check for consistent high-volume usage patterns + const highVolumePattern = await rateLimitHistoryRepo.count({ + where: { + userId, + requestCount: MoreThanOrEqual(50), // High request count per minute + timestamp: MoreThanOrEqual(oneHourAgo), + }, + }); + + if (highVolumePattern > 5) { + riskScore += 20; + rulesTriggered.push("HIGH_VOLUME_PATTERN"); + } + + return { riskScore, rulesTriggered }; + } catch (error) { + console.error(`Error checking rate limiting patterns: ${error}`); + return { riskScore: 0, rulesTriggered: [] }; + } + } + + async checkTransactionWithRateLimit( + context: TransactionContextDTO, + ): Promise { + // Get the original fraud check result + const originalResult = await this.checkTransaction(context); + + // Add rate limiting pattern analysis + if (context.transaction.payerId) { + const rateLimitCheck = await this.checkRateLimitingPatterns( + context.transaction.payerId, + context.ipAddress || "0.0.0.0", + context.transaction.merchantId, + ); + + // Combine scores + originalResult.riskScore = Math.min( + originalResult.riskScore + rateLimitCheck.riskScore, + 100, + ); + originalResult.rulesTriggered.push(...rateLimitCheck.rulesTriggered); + + // Recalculate risk level with new score + const config = await this.getMerchantConfig( + context.transaction.merchantId, + ); + originalResult.riskLevel = this.calculateRiskLevel( + originalResult.riskScore, + config, + ); + originalResult.shouldBlock = this.shouldBlockTransaction( + originalResult.riskLevel, + config, + ); + + // If high risk due to rate limiting, add to blacklist + if (rateLimitCheck.riskScore > 30) { + try { + await whitelistBlacklistService.addToBlacklist( + BlacklistType.USER, + context.transaction.payerId, + BlacklistReason.ABUSE, + `High risk score from rate limiting patterns: ${rateLimitCheck.riskScore}. Rules triggered: ${rateLimitCheck.rulesTriggered.join(", ")}`, + "fraud-system", + new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hour ban + ); + + console.warn( + `User ${context.transaction.payerId} blacklisted due to rate limiting fraud patterns`, + ); + } catch (error) { + console.error( + `Error blacklisting user for rate limit fraud: ${error}`, + ); + } + } + + // Also check IP if available + if (context.ipAddress && rateLimitCheck.riskScore > 25) { + try { + await whitelistBlacklistService.addToBlacklist( + BlacklistType.IP, + context.ipAddress, + BlacklistReason.ABUSE, + `IP associated with high-risk rate limiting patterns. Risk score: ${rateLimitCheck.riskScore}`, + "fraud-system", + new Date(Date.now() + 12 * 60 * 60 * 1000), // 12 hour ban for IP + ); + + console.warn( + `IP ${context.ipAddress} blacklisted due to rate limiting fraud patterns`, + ); + } catch (error) { + console.error(`Error blacklisting IP for rate limit fraud: ${error}`); + } + } + } + + return originalResult; + } + + // New method to get rate limiting fraud statistics + async getRateLimitFraudStats( + merchantId?: string, + days: number = 30, + ): Promise { + try { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - days); + + const rateLimitHistoryRepo = + AppDataSource.getRepository(RateLimitHistory); + + const baseQuery = rateLimitHistoryRepo + .createQueryBuilder("history") + .where("history.timestamp >= :startDate", { startDate }); + + if (merchantId) { + baseQuery.andWhere("history.merchantId = :merchantId", { merchantId }); + } + + // Get all rate limit events + const allEvents = await baseQuery.getMany(); + + // Get throttled events + const throttledEvents = allEvents.filter((e) => e.wasThrottled); + + // Get burst events + const burstEvents = allEvents.filter((e) => e.wasBurst); + + // Calculate fraud indicators + const suspiciousIPs = await rateLimitHistoryRepo + .createQueryBuilder("history") + .select("history.ip", "ip") + .addSelect("COUNT(*)", "count") + .where("history.timestamp >= :startDate", { startDate }) + .andWhere("history.wasThrottled = :wasThrottled", { + wasThrottled: true, + }) + .groupBy("history.ip") + .having("COUNT(*) > :threshold", { threshold: 10 }) + .getRawMany(); + + const suspiciousUsers = await rateLimitHistoryRepo + .createQueryBuilder("history") + .select("history.userId", "userId") + .addSelect("COUNT(*)", "count") + .where("history.timestamp >= :startDate", { startDate }) + .andWhere("history.wasThrottled = :wasThrottled", { + wasThrottled: true, + }) + .andWhere("history.userId IS NOT NULL") + .groupBy("history.userId") + .having("COUNT(*) > :threshold", { threshold: 15 }) + .getRawMany(); + + return { + period: { + startDate, + endDate: new Date(), + days, + }, + totalEvents: allEvents.length, + throttledEvents: throttledEvents.length, + burstEvents: burstEvents.length, + throttleRate: + allEvents.length > 0 + ? (throttledEvents.length / allEvents.length) * 100 + : 0, + burstRate: + allEvents.length > 0 + ? (burstEvents.length / allEvents.length) * 100 + : 0, + suspiciousActivity: { + suspiciousIPs: suspiciousIPs.map( + (ip): SuspiciousActivity => ({ + ip: ip.ip, + throttledCount: parseInt(ip.count, 10), + }), + ), + suspiciousUsers: suspiciousUsers.map( + (user): SuspiciousUser => ({ + userId: user.userId, + throttledCount: parseInt(user.count, 10), + }), + ), + }, + riskIndicators: { + highRiskIPs: suspiciousIPs.length, + highRiskUsers: suspiciousUsers.length, + averageThrottlePerIP: + suspiciousIPs.length > 0 + ? suspiciousIPs.reduce( + (sum, ip) => sum + parseInt(ip.count, 10), + 0, + ) / suspiciousIPs.length + : 0, + averageThrottlePerUser: + suspiciousUsers.length > 0 + ? suspiciousUsers.reduce( + (sum, user) => sum + parseInt(user.count, 10), + 0, + ) / suspiciousUsers.length + : 0, + }, + }; + } catch (error) { + console.error(`Error getting rate limit fraud stats: ${error}`); + throw error; + } + } } diff --git a/src/services/WalletService.ts b/src/services/WalletService.ts index fc3ec5b..7f4522f 100644 --- a/src/services/WalletService.ts +++ b/src/services/WalletService.ts @@ -22,7 +22,6 @@ interface WalletSettings { }; [key: string]: unknown; } - // Helper function to safely extract error message const getErrorMessage = (error: unknown): string => { if (error instanceof Error) { @@ -87,21 +86,45 @@ export class WalletService { const balances = await this.stellarService.getAccountBalances( wallet.publicKey, ); + for (const balance of balances) { await this.balanceRepository.upsert( { walletId, - assetCode: balance.assetCode, - assetIssuer: balance.assetIssuer, - balance: balance.balance, - assetType: balance.assetType, - isAuthorized: balance.isAuthorized, + assetCode: + typeof balance.assetCode === "string" + ? balance.assetCode + : undefined, + assetIssuer: + typeof balance.assetIssuer === "string" + ? balance.assetIssuer + : undefined, + balance: + typeof balance.balance === "string" ? balance.balance : "0", + assetType: + typeof balance.assetType === "string" + ? balance.assetType + : undefined, + isAuthorized: + typeof balance.isAuthorized === "boolean" + ? balance.isAuthorized + : undefined, isAuthorizedToMaintainLiabilities: - balance.isAuthorizedToMaintainLiabilities, - isClawbackEnabled: balance.isClawbackEnabled, - lastModifiedLedger: balance.lastModifiedLedger, - limit: balance.limit, - sponsor: balance.sponsor, + typeof balance.isAuthorizedToMaintainLiabilities === "boolean" + ? balance.isAuthorizedToMaintainLiabilities + : undefined, + isClawbackEnabled: + typeof balance.isClawbackEnabled === "boolean" + ? balance.isClawbackEnabled + : undefined, + lastModifiedLedger: + typeof balance.lastModifiedLedger === "number" + ? balance.lastModifiedLedger.toString() + : undefined, + limit: + typeof balance.limit === "string" ? balance.limit : undefined, + sponsor: + typeof balance.sponsor === "string" ? balance.sponsor : undefined, }, ["walletId", "assetCode", "assetIssuer"], ); @@ -112,9 +135,11 @@ export class WalletService { console.warn( `Failed to fetch live balances: ${getErrorMessage(error)}, using cached data`, ); + const cachedBalances = await this.balanceRepository.find({ where: { walletId }, }); + return cachedBalances.map((b) => ({ assetCode: b.assetCode, assetIssuer: b.assetIssuer, @@ -350,7 +375,14 @@ export class WalletService { 50, ); - for (const stellarTx of stellarTransactions) { + for (const stellarTxUnknown of stellarTransactions) { + // Add a type assertion or guard here based on expected shape + const stellarTx = stellarTxUnknown as { + hash: string; + successful: boolean; + source_account: string; + }; + const existingTx = await this.transactionRepository.findOne({ where: { hash: stellarTx.hash }, }); diff --git a/src/services/adaptiveRateLimitService.ts b/src/services/adaptiveRateLimitService.ts new file mode 100644 index 0000000..07bb100 --- /dev/null +++ b/src/services/adaptiveRateLimitService.ts @@ -0,0 +1,170 @@ +import type { Repository } from "typeorm"; +import AppDataSource from "../config/db"; +import { RateLimitConfig } from "../entities/RateLimitConfig"; +import { RateLimitHistory } from "../entities/RateLimitHistory"; +import logger from "../utils/logger"; +import rateLimitConfigService from "./rateLimitConfigService"; +import cron from "node-cron"; + +class AdaptiveRateLimitService { + private configRepo: Repository; + private historyRepo: Repository; + private cronTask: cron.ScheduledTask | null = null; + + constructor() { + if (AppDataSource.isInitialized) { + this.configRepo = AppDataSource.getRepository(RateLimitConfig); + this.historyRepo = AppDataSource.getRepository(RateLimitHistory); + } else { + AppDataSource.initialize() + .then(() => { + this.configRepo = AppDataSource.getRepository(RateLimitConfig); + this.historyRepo = AppDataSource.getRepository(RateLimitHistory); + }) + .catch((error) => { + logger.error( + "Failed to initialize AdaptiveRateLimitService repositories:", + error, + ); + }); + } + } + + // /** + // * Starts the periodic adjustment of rate limits using a cron job. + // * @param cronSchedule The cron schedule string (e.g., \'*/ 5; + + public startAdjustment(cronSchedule = "*/5 * * * *"): void { + if (this.cronTask) { + logger.warn("Adaptive rate limit adjustment cron job already running."); + return; + } + logger.info( + `Starting adaptive rate limit adjustment cron job with schedule: '${cronSchedule}'.`, + ); + this.cronTask = cron.schedule(cronSchedule, () => this.adjustLimits(), { + scheduled: true, + timezone: "UTC", + }); + } + + /** + * Stops the periodic adjustment cron job. + */ + public stopAdjustment(): void { + if (this.cronTask) { + this.cronTask.stop(); + this.cronTask = null; + logger.info("Stopped adaptive rate limit adjustment cron job."); + } + } + + /** + * Analyzes historical rate limit data and adjusts configurations. + */ + public async adjustLimits(): Promise { + if (!this.configRepo || !this.historyRepo) { + logger.warn( + "AdaptiveRateLimitService repositories not initialized. Skipping adjustment.", + ); + return; + } + + logger.info("Running adaptive rate limit adjustment..."); + const now = new Date(); + const lookbackPeriodMs = 60 * 60 * 1000; // Look back 1 hour + const startTime = new Date(now.getTime() - lookbackPeriodMs); + + try { + const aggregatedData = await this.historyRepo + .createQueryBuilder("history") + .select("history.merchantId", "merchantId") + .addSelect("history.endpoint", "endpoint") + .addSelect("COUNT(*)", "totalRequests") + .addSelect( + "SUM(CASE WHEN history.wasThrottled = TRUE THEN 1 ELSE 0 END)", + "throttledRequests", + ) + .where("history.timestamp >= :startTime", { startTime }) + .groupBy("history.merchantId") + .addGroupBy("history.endpoint") + .getRawMany(); + + for (const data of aggregatedData) { + const { merchantId, endpoint, totalRequests, throttledRequests } = data; + const total = Number.parseInt(totalRequests, 10); + const throttled = Number.parseInt(throttledRequests, 10); + + if (total === 0) { + continue; + } + + const throttleRate = (throttled / total) * 100; + + const config = await this.configRepo.findOne({ where: { merchantId } }); + + if (!config) { + logger.warn( + `No specific config found for merchant ${merchantId}. Skipping adjustment.`, + ); + continue; + } + + let updated = false; + let newRequestsPerMinute = config.requestsPerMinute; + const adjustmentFactor = 0.05; // 5% adjustment + + if (throttleRate < 1) { + newRequestsPerMinute = Math.ceil( + config.requestsPerMinute * (1 + adjustmentFactor), + ); + logger.info( + `Merchant ${merchantId} (Endpoint: ${endpoint}): Low throttle rate (${throttleRate.toFixed(2)}%). Increasing RPM from ${config.requestsPerMinute} to ${newRequestsPerMinute}.`, + ); + updated = true; + } else if (throttleRate > 10) { + newRequestsPerMinute = Math.floor( + config.requestsPerMinute * (1 - adjustmentFactor), + ); + newRequestsPerMinute = Math.max(newRequestsPerMinute, 10); // Minimum limit + logger.warn( + `Merchant ${merchantId} (Endpoint: ${endpoint}): High throttle rate (${throttleRate.toFixed(2)}%). Decreasing RPM from ${config.requestsPerMinute} to ${newRequestsPerMinute}.`, + ); + updated = true; + } else { + logger.info( + `Merchant ${merchantId} (Endpoint: ${endpoint}): Stable throttle rate (${throttleRate.toFixed(2)}%). No adjustment needed.`, + ); + } + + if (updated && newRequestsPerMinute !== config.requestsPerMinute) { + const ratio = newRequestsPerMinute / config.requestsPerMinute; + config.requestsPerSecond = Math.max( + 1, + Math.ceil(config.requestsPerSecond * ratio), + ); + config.requestsPerHour = Math.max( + 10, + Math.ceil(config.requestsPerHour * ratio), + ); + config.requestsPerDay = Math.max( + 100, + Math.ceil(config.requestsPerDay * ratio), + ); + config.requestsPerMinute = newRequestsPerMinute; + + await rateLimitConfigService.updateConfig(config.id!, config); + logger.info( + `Updated rate limit config for merchant ${merchantId}. New RPM: ${config.requestsPerMinute}`, + ); + } + } + logger.info("Adaptive rate limit adjustment completed."); + } catch (error) { + logger.error("Error during adaptive rate limit adjustment:", error); + } + } +} + +const adaptiveRateLimitService = new AdaptiveRateLimitService(); +export default adaptiveRateLimitService; diff --git a/src/services/rateLimitAlertService.ts b/src/services/rateLimitAlertService.ts new file mode 100644 index 0000000..9a91b93 --- /dev/null +++ b/src/services/rateLimitAlertService.ts @@ -0,0 +1,91 @@ +import { Repository, MoreThanOrEqual } from "typeorm"; +import AppDataSource from "../config/db"; +import { RateLimitHistory } from "../entities/RateLimitHistory"; +import { redisClient } from "../config/redisConfig"; +import logger from "../utils/logger"; + +interface AlertThresholds { + warningThreshold: number; + criticalThreshold: number; + cooldownMinutes: number; +} + +export class RateLimitAlertService { + private historyRepository: Repository; + private thresholds: AlertThresholds; + + constructor() { + this.historyRepository = AppDataSource.getRepository(RateLimitHistory); + this.thresholds = { + warningThreshold: 80, + criticalThreshold: 95, + cooldownMinutes: 15, + }; + } + + async checkUserThresholds(userId: string, merchantId: string): Promise { + if (!userId || !merchantId) { + logger.warn("Invalid paramters provided to checkUserThresholds"); + } + try { + const now = new Date(); + const oneMinuteAgo = new Date(now.getTime() - 60 * 1000); + + // Get recent history for this user + const recentHistory = await this.historyRepository.find({ + where: { + userId, + merchantId, + timestamp: MoreThanOrEqual(oneMinuteAgo), + }, + order: { timestamp: "DESC" }, + }); + + if (recentHistory.length === 0) { + return; + } + + // Calculate current usage percentage + const latestEntry = recentHistory[0]; + const usagePercentage = + (latestEntry.requestCount / latestEntry.limitUsed) * 100; + + // Check if we should send an alert + if (usagePercentage >= this.thresholds.criticalThreshold) { + await this.sendAlert(userId, merchantId, "critical", usagePercentage); + } else if (usagePercentage >= this.thresholds.warningThreshold) { + await this.sendAlert(userId, merchantId, "warning", usagePercentage); + } + } catch (error) { + logger.error(`Error checking user thresholds: ${error}`); + } + } + + private async sendAlert( + userId: string, + merchantId: string, + level: "warning" | "critical", + usagePercentage: number, + ): Promise { + try { + const cooldownKey = `ratelimit:alert:${userId}:${level}`; + const alertSent = await redisClient.get(cooldownKey); + + if (alertSent) { + return; + } + + await redisClient.set(cooldownKey, "1", { + EX: this.thresholds.cooldownMinutes * 60, + }); + + logger.warn( + `RATE LIMIT ALERT [${level.toUpperCase()}]: User ${userId} at ${Math.round(usagePercentage)}% of their rate limit for merchant ${merchantId}`, + ); + } catch (error) { + logger.error(`Error sending alert: ${error}`); + } + } +} + +export default new RateLimitAlertService(); diff --git a/src/services/rateLimitConfigService.ts b/src/services/rateLimitConfigService.ts new file mode 100644 index 0000000..54e376e --- /dev/null +++ b/src/services/rateLimitConfigService.ts @@ -0,0 +1,413 @@ +import { Repository } from "typeorm"; +import AppDataSource from "../config/db"; +import { RateLimitConfig } from "../entities/RateLimitConfig"; +import { + DEFAULT_USER_ROLE_LIMITS, + DEFAULT_MERCHANT_TYPE_LIMITS, + RateLimitTier, +} from "../config/rateLimitConfig"; +import { UserRole } from "../enums/UserRole"; +import { MerchantEntity } from "../entities/Merchant.entity"; +import logger from "../utils/logger"; + +export class RateLimitConfigService { + private configRepository: Repository; + private merchantRepository: Repository; + + constructor() { + this.configRepository = AppDataSource.getRepository(RateLimitConfig); + this.merchantRepository = AppDataSource.getRepository(MerchantEntity); + } + + async getConfigForUser( + userId: string, + merchantId: string, + userRole: string, + ): Promise { + try { + // First try to find a specific config for this user role and merchant + let config = await this.configRepository.findOne({ + where: { + merchantId, + userRole, + isActive: true, + }, + }); + + if (config) { + return config; + } + + // Get merchant info + const merchant = await this.merchantRepository.findOne({ + where: { id: merchantId }, + }); + + // Determine merchant type based on available fields or default to "standard" + const merchantType = this.determineMerchantType(merchant); + + // Try to find config for this merchant type + config = await this.configRepository.findOne({ + where: { + merchantId, + merchantType, + isActive: true, + }, + }); + + if (config) { + return config; + } + + // If still no config, create one based on defaults + const defaultLimits = this.getDefaultLimits(userRole, merchantType); + + config = this.configRepository.create({ + merchantId, + userRole, + merchantType, + requestsPerSecond: defaultLimits.limits.requestsPerSecond, + requestsPerMinute: defaultLimits.limits.requestsPerMinute, + requestsPerHour: defaultLimits.limits.requestsPerHour, + requestsPerDay: defaultLimits.limits.requestsPerDay, + burstMultiplier: defaultLimits.burstMultiplier, + burstDurationSeconds: defaultLimits.burstDurationSeconds, + }); + + return await this.configRepository.save(config); + } catch (error) { + logger.error(`Error getting rate limit config: ${error}`); + // Return a safe default if there's an error + return this.createDefaultConfig(merchantId, userRole); + } + } + + private determineMerchantType(merchant: MerchantEntity | null): string { + if (!merchant) { + return "standard"; + } + + if ( + merchant.business_name && + merchant.business_name.toLowerCase().includes("enterprise") + ) { + return "enterprise"; + } + + if ( + merchant.business_name && + merchant.business_name.toLowerCase().includes("premium") + ) { + return "premium"; + } + + return "standard"; + } + + private getDefaultLimits( + userRole: string, + merchantType: string, + ): RateLimitTier { + // First check user role limits + if (userRole && DEFAULT_USER_ROLE_LIMITS[userRole]) { + return DEFAULT_USER_ROLE_LIMITS[userRole]; + } + + // Then check merchant type limits + if (merchantType && DEFAULT_MERCHANT_TYPE_LIMITS[merchantType]) { + return DEFAULT_MERCHANT_TYPE_LIMITS[merchantType]; + } + + // Fallback to basic tier + return DEFAULT_USER_ROLE_LIMITS[UserRole.USER]; + } + + private createDefaultConfig( + merchantId: string, + userRole?: string, + ): RateLimitConfig { + const defaultLimits = this.getDefaultLimits( + userRole || UserRole.USER, + "standard", + ); + + const config = new RateLimitConfig(); + config.merchantId = merchantId; + config.userRole = userRole; + config.merchantType = "standard"; + config.requestsPerSecond = defaultLimits.limits.requestsPerSecond; + config.requestsPerMinute = defaultLimits.limits.requestsPerMinute; + config.requestsPerHour = defaultLimits.limits.requestsPerHour; + config.requestsPerDay = defaultLimits.limits.requestsPerDay; + config.burstMultiplier = defaultLimits.burstMultiplier; + config.burstDurationSeconds = defaultLimits.burstDurationSeconds; + config.isActive = true; + + return config; + } + + async updateConfig( + configId: string, + updates: Partial, + ): Promise { + const config = await this.configRepository.findOne({ + where: { id: configId }, + }); + + if (!config) { + throw new Error("Rate limit configuration not found"); + } + + Object.assign(config, updates); + return await this.configRepository.save(config); + } + + async getAllConfigsForMerchant( + merchantId: string, + ): Promise { + return await this.configRepository.find({ + where: { merchantId, isActive: true }, + order: { createdAt: "DESC" }, + }); + } + + async createConfig( + configData: Partial, + ): Promise { + // Validate required fields + if (!configData.merchantId) { + throw new Error("Merchant ID is required"); + } + + if ( + !configData.requestsPerSecond || + !configData.requestsPerMinute || + !configData.requestsPerHour || + !configData.requestsPerDay + ) { + throw new Error("All rate limit values are required"); + } + + const config = this.configRepository.create({ + ...configData, + burstMultiplier: configData.burstMultiplier || 2.0, + burstDurationSeconds: configData.burstDurationSeconds || 30, + isActive: configData.isActive !== undefined ? configData.isActive : true, + }); + + return await this.configRepository.save(config); + } + + async deleteConfig(configId: string): Promise { + const config = await this.configRepository.findOne({ + where: { id: configId }, + }); + + if (!config) { + throw new Error("Rate limit configuration not found"); + } + + config.isActive = false; + await this.configRepository.save(config); + } + + async getConfigById(configId: string): Promise { + return await this.configRepository.findOne({ + where: { id: configId, isActive: true }, + }); + } + + async getConfigsByUserRole( + merchantId: string, + userRole: string, + ): Promise { + return await this.configRepository.find({ + where: { + merchantId, + userRole, + isActive: true, + }, + order: { createdAt: "DESC" }, + }); + } + + async getConfigsByMerchantType( + merchantType: string, + ): Promise { + return await this.configRepository.find({ + where: { + merchantType, + isActive: true, + }, + order: { createdAt: "DESC" }, + }); + } + + // Method to initialize default configs for a new merchant + async initializeDefaultConfigsForMerchant(merchantId: string): Promise { + try { + const merchant = await this.merchantRepository.findOne({ + where: { id: merchantId }, + }); + + if (!merchant) { + throw new Error("Merchant not found"); + } + + const merchantType = this.determineMerchantType(merchant); + + // Create configs for each user role + const userRoles = Object.values(UserRole); + + for (const role of userRoles) { + // Check if config already exists + const existingConfig = await this.configRepository.findOne({ + where: { + merchantId, + userRole: role, + isActive: true, + }, + }); + + if (!existingConfig) { + const defaultLimits = this.getDefaultLimits(role, merchantType); + + await this.createConfig({ + merchantId, + userRole: role, + merchantType, + requestsPerSecond: defaultLimits.limits.requestsPerSecond, + requestsPerMinute: defaultLimits.limits.requestsPerMinute, + requestsPerHour: defaultLimits.limits.requestsPerHour, + requestsPerDay: defaultLimits.limits.requestsPerDay, + burstMultiplier: defaultLimits.burstMultiplier, + burstDurationSeconds: defaultLimits.burstDurationSeconds, + }); + + logger.info( + `Created default rate limit config for merchant ${merchantId}, role ${role}`, + ); + } + } + } catch (error) { + logger.error( + `Error initializing default configs for merchant ${merchantId}: ${error}`, + ); + throw error; + } + } + + // Method to bulk update configs + async bulkUpdateConfigs( + merchantId: string, + updates: Array<{ configId: string; updates: Partial }>, + ): Promise { + const updatedConfigs: RateLimitConfig[] = []; + + for (const update of updates) { + try { + const config = await this.updateConfig(update.configId, update.updates); + updatedConfigs.push(config); + } catch (error) { + logger.error(`Error updating config ${update.configId}: ${error}`); + // Continue with other updates even if one fails + } + } + + return updatedConfigs; + } + + // Method to get effective limits for a user (considering inheritance) + async getEffectiveLimits( + userId: string, + merchantId: string, + userRole: string, + ): Promise<{ + requestsPerSecond: number; + requestsPerMinute: number; + requestsPerHour: number; + requestsPerDay: number; + burstMultiplier: number; + burstDurationSeconds: number; + source: string; // "user-specific" | "role-default" | "merchant-default" | "system-default" + }> { + try { + // Try user-specific config first + let config = await this.configRepository.findOne({ + where: { + merchantId, + userRole, + isActive: true, + }, + }); + + if (config) { + return { + requestsPerSecond: config.requestsPerSecond, + requestsPerMinute: config.requestsPerMinute, + requestsPerHour: config.requestsPerHour, + requestsPerDay: config.requestsPerDay, + burstMultiplier: config.burstMultiplier, + burstDurationSeconds: config.burstDurationSeconds, + source: "user-specific", + }; + } + + // Try merchant-type default + const merchant = await this.merchantRepository.findOne({ + where: { id: merchantId }, + }); + + const merchantType = this.determineMerchantType(merchant); + + config = await this.configRepository.findOne({ + where: { + merchantId, + merchantType, + isActive: true, + }, + }); + + if (config) { + return { + requestsPerSecond: config.requestsPerSecond, + requestsPerMinute: config.requestsPerMinute, + requestsPerHour: config.requestsPerHour, + requestsPerDay: config.requestsPerDay, + burstMultiplier: config.burstMultiplier, + burstDurationSeconds: config.burstDurationSeconds, + source: "merchant-default", + }; + } + + // Fall back to system defaults + const defaultLimits = this.getDefaultLimits(userRole, merchantType); + + return { + requestsPerSecond: defaultLimits.limits.requestsPerSecond, + requestsPerMinute: defaultLimits.limits.requestsPerMinute, + requestsPerHour: defaultLimits.limits.requestsPerHour, + requestsPerDay: defaultLimits.limits.requestsPerDay, + burstMultiplier: defaultLimits.burstMultiplier, + burstDurationSeconds: defaultLimits.burstDurationSeconds, + source: "system-default", + }; + } catch (error) { + logger.error(`Error getting effective limits: ${error}`); + + // Return safe defaults + const safeLimits = this.getDefaultLimits(UserRole.USER, "standard"); + return { + requestsPerSecond: safeLimits.limits.requestsPerSecond, + requestsPerMinute: safeLimits.limits.requestsPerMinute, + requestsPerHour: safeLimits.limits.requestsPerHour, + requestsPerDay: safeLimits.limits.requestsPerDay, + burstMultiplier: safeLimits.burstMultiplier, + burstDurationSeconds: safeLimits.burstDurationSeconds, + source: "system-default", + }; + } + } +} + +export default new RateLimitConfigService(); diff --git a/src/services/rateLimitMonitoring.service.ts b/src/services/rateLimitMonitoring.service.ts index a9cfdd8..4c1091e 100644 --- a/src/services/rateLimitMonitoring.service.ts +++ b/src/services/rateLimitMonitoring.service.ts @@ -1,6 +1,29 @@ -import { Request, Response, NextFunction } from "express"; +import type { Request, Response, NextFunction } from "express"; +import { type Repository, MoreThanOrEqual } from "typeorm"; +import AppDataSource from "../config/db"; +import { RateLimitHistory } from "../entities/RateLimitHistory"; +import { redisClient } from "../config/redisConfig"; +import whitelistBlacklistService from "./whitelistBlacklistService"; +import { BlacklistType, BlacklistReason } from "../entities/RateLimitBlacklist"; +import logger from "../utils/logger"; +import type { Merchant } from "../interfaces/webhook.interfaces"; +import type { UserResponse } from "../interfaces/auth.interfaces"; + +// Extend Express Request to include custom properties +declare module "express-serve-static-core" { + interface Request { + user?: UserResponse; // Using the imported UserResponse interface + merchant?: Merchant; // Using the imported Merchant interface + rateLimit?: { + limit: number; + current: number; + remaining: number; + resetTime: Date; + total: number; + }; + } +} -// Interface for rate limit events export interface RateLimitEvent { id?: number; ip: string; @@ -16,9 +39,67 @@ interface SuspiciousActivityCriteria { timeWindowMs: number; } +interface AdvancedRateLimitEvent extends RateLimitEvent { + userRole?: string; + merchantId?: string; + merchantType?: string; + requestCount?: number; + limitUsed?: number; + wasThrottled?: boolean; + wasBurst?: boolean; +} + +interface EndpointStats { + total: number; + throttled: number; + burst: number; +} + +interface RoleStats { + total: number; + throttled: number; + burst: number; +} + +interface TopThrottledIP { + ip: string; + throttledCount: number; +} + +interface TopThrottledUser { + userId: string; + throttledCount: number; +} + +interface RateLimitMetrics { + timeframe: "minute" | "hour" | "day"; + startTime: Date; + endTime: Date; + totalRequests: number; + throttledRequests: number; + throttleRate: number; + burstRequests: number; + burstRate: number; + endpointStats: Record; + roleStats: Record; + topThrottledIPs: TopThrottledIP[]; + topThrottledUsers: TopThrottledUser[]; +} + +interface RealTimeStatus { + activeRequests: number; + throttledRequests: number; + burstModeActive: number; + activeBurstSessions: number; + timestamp: Date; + recentEvents: number; + error?: string; +} + class RateLimitMonitoringService { private recentEvents: Map; private readonly suspiciousCriteria: SuspiciousActivityCriteria; + private historyRepository: Repository; constructor() { this.recentEvents = new Map(); @@ -26,24 +107,73 @@ class RateLimitMonitoringService { threshold: 10, timeWindowMs: 60000, // 1 minute }; + + // Initialize repository when AppDataSource is ready + if (AppDataSource.isInitialized) { + this.historyRepository = AppDataSource.getRepository(RateLimitHistory); + } else { + // Wait for initialization + AppDataSource.initialize() + .then(() => { + this.historyRepository = + AppDataSource.getRepository(RateLimitHistory); + }) + .catch((error) => { + logger.error( + "Failed to initialize rate limit history repository:", + error, + ); + }); + } } - // This method logs rate limit events public async logRateLimitEvent(event: RateLimitEvent): Promise { try { if (!event.ip) { throw new Error("IP cannot be null or empty"); } - console.warn("Rate limit exceeded:", JSON.stringify(event)); - this.checkForSuspiciousActivity(event); } catch (error) { console.error("Failed to log rate limit event:", error); } } - // Check for suspicious activity like multiple rate limit events from same IP + public async logAdvancedRateLimitEvent( + event: AdvancedRateLimitEvent, + ): Promise { + try { + if (!event.ip) { + throw new Error("IP cannot be null or empty"); + } + // Only log to database if repository is available + if (this.historyRepository) { + const history = this.historyRepository.create({ + userId: event.userId?.toString(), + userRole: event.userRole, + merchantId: event.merchantId, + merchantType: event.merchantType, + endpoint: event.endpoint, + ip: event.ip, + requestCount: event.requestCount || 1, + limitUsed: event.limitUsed, + wasThrottled: event.wasThrottled || false, + wasBurst: event.wasBurst || false, + userAgent: event.userAgent, + timestamp: event.timestamp, + }); + await this.historyRepository.save(history); + } + // Also call the original method for backward compatibility + await this.logRateLimitEvent(event); + if (event.wasThrottled) { + await this.checkForAdvancedSuspiciousActivity(event); + } + } catch (error) { + logger.error("Failed to log advanced rate limit event:", error); + } + } + private checkForSuspiciousActivity(event: RateLimitEvent): void { const ipEvents = this.recentEvents.get(event.ip) || []; const now = Date.now(); @@ -64,14 +194,71 @@ class RateLimitMonitoringService { } } - // This would trigger an alert via email, SMS, etc. + private async checkForAdvancedSuspiciousActivity( + event: AdvancedRateLimitEvent, + ): Promise { + try { + if (!this.historyRepository) { + return; // Skip if repository not available + } + const now = new Date(); + const fiveMinutesAgo = new Date(now.getTime() - 5 * 60000); + + // Count throttled requests in the last 5 minutes for this IP + const throttledCount = await this.historyRepository.count({ + where: { + ip: event.ip, + wasThrottled: true, + timestamp: MoreThanOrEqual(fiveMinutesAgo), + }, + }); + + // If more than 10 throttled requests in 5 minutes, add IP to blacklist + if (throttledCount > 10) { + await whitelistBlacklistService.addToBlacklist( + BlacklistType.IP, + event.ip, + BlacklistReason.ABUSE, + `Exceeded rate limit ${throttledCount} times in 5 minutes`, + "system", + new Date(now.getTime() + 24 * 60 * 60000), // 24 hour ban + ); + logger.warn(`IP ${event.ip} blacklisted for rate limit abuse`); + } + + // Also check user-based abuse if userId is available + if (event.userId) { + const userThrottledCount = await this.historyRepository.count({ + where: { + userId: event.userId.toString(), + wasThrottled: true, + timestamp: MoreThanOrEqual(fiveMinutesAgo), + }, + }); + + if (userThrottledCount > 15) { + await whitelistBlacklistService.addToBlacklist( + BlacklistType.USER, + event.userId.toString(), + BlacklistReason.ABUSE, + `User exceeded rate limit ${userThrottledCount} times in 5 minutes`, + "system", + new Date(now.getTime() + 24 * 60 * 60000), // 24 hour ban + ); + logger.warn(`User ${event.userId} blacklisted for rate limit abuse`); + } + } + } catch (error) { + logger.error(`Error checking for advanced suspicious activity: ${error}`); + } + } + private triggerAlert(ip: string, count: number): void { console.error( `ALERT: Suspicious activity detected from IP ${ip} - ${count} rate limit events in the last minute`, ); } - // Create middleware to log rate limit events public createRateLimitMonitoringMiddleware(): ( req: Request, res: Response, @@ -79,28 +266,281 @@ class RateLimitMonitoringService { ) => void { return (req: Request, res: Response, next: NextFunction) => { const originalSend = res.send; - - res.send = (body): Response => { + res.send = (body: unknown): Response => { if (res.statusCode === 429) { - const event: RateLimitEvent = { + const event: AdvancedRateLimitEvent = { ip: req.ip || "0.0.0.0", endpoint: req.originalUrl, userAgent: req.headers["user-agent"], timestamp: new Date(), userId: req.user?.id, email: req.user?.email, + userRole: req.user?.role, + merchantId: req.merchant?.id, + merchantType: this.determineMerchantType(req.merchant), + wasThrottled: true, }; - - this.logRateLimitEvent(event).catch((err) => { - console.error("Failed to log rate limit event:", err); + this.logAdvancedRateLimitEvent(event).catch((err) => { + console.error("Failed to log advanced rate limit event:", err); }); } return originalSend.call(res, body); }; - next(); }; } + + private determineMerchantType(merchant: Merchant | undefined): string { + if (!merchant) return "standard"; + if ( + merchant.business_name && + merchant.business_name.toLowerCase().includes("enterprise") + ) { + return "enterprise"; + } + if ( + merchant.business_name && + merchant.business_name.toLowerCase().includes("premium") + ) { + return "premium"; + } + return "standard"; + } + + // New methods for metrics and monitoring + public async getRateLimitMetrics( + timeframe: "minute" | "hour" | "day" = "hour", + merchantId?: string, + userId?: string, + ): Promise { + try { + if (!this.historyRepository) { + throw new Error("History repository not initialized"); + } + const now = new Date(); + let startTime: Date; + switch (timeframe) { + case "minute": + startTime = new Date(now.getTime() - 60000); + break; + case "day": + startTime = new Date(now.getTime() - 24 * 60 * 60000); + break; + case "hour": + default: + startTime = new Date(now.getTime() - 60 * 60000); + break; + } + + const queryBuilder = this.historyRepository + .createQueryBuilder("history") + .where("history.timestamp >= :startTime", { startTime }); + + if (merchantId) { + queryBuilder.andWhere("history.merchantId = :merchantId", { + merchantId, + }); + } + if (userId) { + queryBuilder.andWhere("history.userId = :userId", { userId }); + } + + const results = await queryBuilder.getMany(); + + // Calculate metrics + const totalRequests = results.length; + const throttledRequests = results.filter((r) => r.wasThrottled).length; + const burstRequests = results.filter((r) => r.wasBurst).length; + + // Group by endpoint + const endpointStats: Record = {}; + results.forEach((r) => { + if (!endpointStats[r.endpoint]) { + endpointStats[r.endpoint] = { + total: 0, + throttled: 0, + burst: 0, + }; + } + endpointStats[r.endpoint].total++; + if (r.wasThrottled) endpointStats[r.endpoint].throttled++; + if (r.wasBurst) endpointStats[r.endpoint].burst++; + }); + + // Group by user role + const roleStats: Record = {}; + results.forEach((r) => { + if (r.userRole) { + if (!roleStats[r.userRole]) { + roleStats[r.userRole] = { + total: 0, + throttled: 0, + burst: 0, + }; + } + roleStats[r.userRole].total++; + if (r.wasThrottled) roleStats[r.userRole].throttled++; + if (r.wasBurst) roleStats[r.userRole].burst++; + } + }); + + return { + timeframe, + startTime, + endTime: now, + totalRequests, + throttledRequests, + throttleRate: + totalRequests > 0 ? (throttledRequests / totalRequests) * 100 : 0, + burstRequests, + burstRate: + totalRequests > 0 ? (burstRequests / totalRequests) * 100 : 0, + endpointStats, + roleStats, + topThrottledIPs: await this.getTopThrottledIPs(startTime, 10), + topThrottledUsers: await this.getTopThrottledUsers(startTime, 10), + }; + } catch (error) { + logger.error(`Error getting rate limit metrics: ${error}`); + throw error; + } + } + + public async getUserRateLimitHistory( + userId: string, + limit = 100, + ): Promise { + try { + if (!this.historyRepository) { + throw new Error("History repository not initialized"); + } + return await this.historyRepository.find({ + where: { userId }, + order: { timestamp: "DESC" }, + take: limit, + }); + } catch (error) { + logger.error(`Error getting user rate limit history: ${error}`); + throw error; + } + } + + private async getTopThrottledIPs( + startTime: Date, + limit: number, + ): Promise { + try { + if (!this.historyRepository) { + return []; + } + const results = await this.historyRepository + .createQueryBuilder("history") + .select("history.ip", "ip") + .addSelect("COUNT(*)", "count") + .where("history.timestamp >= :startTime", { startTime }) + .andWhere("history.wasThrottled = :wasThrottled", { + wasThrottled: true, + }) + .groupBy("history.ip") + .orderBy("count", "DESC") + .limit(limit) + .getRawMany(); + + return results.map((r) => ({ + ip: r.ip, + throttledCount: Number.parseInt(r.count, 10), + })); + } catch (error) { + logger.error(`Error getting top throttled IPs: ${error}`); + return []; + } + } + + private async getTopThrottledUsers( + startTime: Date, + limit: number, + ): Promise { + try { + if (!this.historyRepository) { + return []; + } + const results = await this.historyRepository + .createQueryBuilder("history") + .select("history.userId", "userId") + .addSelect("COUNT(*)", "count") + .where("history.timestamp >= :startTime", { startTime }) + .andWhere("history.wasThrottled = :wasThrottled", { + wasThrottled: true, + }) + .andWhere("history.userId IS NOT NULL") + .groupBy("history.userId") + .orderBy("count", "DESC") + .limit(limit) + .getRawMany(); + + return results.map((r) => ({ + userId: r.userId, + throttledCount: Number.parseInt(r.count, 10), + })); + } catch (error) { + logger.error(`Error getting top throttled users: ${error}`); + return []; + } + } + + // Method to get real-time rate limit status + public async getRealTimeStatus(): Promise { + try { + const now = new Date(); + const oneMinuteAgo = new Date(now.getTime() - 60000); + if (!this.historyRepository) { + return { + activeRequests: 0, + throttledRequests: 0, + burstModeActive: 0, + activeBurstSessions: 0, + timestamp: now, + recentEvents: 0, + }; + } + + const recentActivity = await this.historyRepository.find({ + where: { + timestamp: MoreThanOrEqual(oneMinuteAgo), + }, + }); + + const activeRequests = recentActivity.length; + const throttledRequests = recentActivity.filter( + (r) => r.wasThrottled, + ).length; + const burstModeActive = recentActivity.filter((r) => r.wasBurst).length; + + // Get active burst sessions from Redis + const burstKeys = await redisClient.keys("burst:*"); + const activeBurstSessions = burstKeys.length; + + return { + activeRequests, + throttledRequests, + burstModeActive, + activeBurstSessions, + timestamp: now, + recentEvents: this.recentEvents.size, + }; + } catch (error) { + logger.error(`Error getting real-time status: ${error}`); + return { + activeRequests: 0, + throttledRequests: 0, + burstModeActive: 0, + activeBurstSessions: 0, + timestamp: new Date(), + recentEvents: 0, + error: error instanceof Error ? error.message : String(error), + }; + } + } } // Create a singleton instance diff --git a/src/services/whitelistBlacklistService.ts b/src/services/whitelistBlacklistService.ts new file mode 100644 index 0000000..53f6884 --- /dev/null +++ b/src/services/whitelistBlacklistService.ts @@ -0,0 +1,239 @@ +import type { Repository } from "typeorm"; +import AppDataSource from "../config/db"; +import { + RateLimitWhitelist, + type WhitelistType, +} from "../entities/RateLimitWhiteList"; +import { + RateLimitBlacklist, + type BlacklistType, + BlacklistReason, +} from "../entities/RateLimitBlacklist"; +import logger from "../utils/logger"; + +interface WhitelistQuery { + isActive: boolean; + type?: WhitelistType; +} + +interface BlacklistQuery { + isActive: boolean; + type?: BlacklistType; +} + +export class WhitelistBlacklistService { + private whitelistRepository: Repository; + private blacklistRepository: Repository; + + constructor() { + this.whitelistRepository = AppDataSource.getRepository(RateLimitWhitelist); + this.blacklistRepository = AppDataSource.getRepository(RateLimitBlacklist); + } + + async addToWhitelist( + type: WhitelistType, + value: string, + reason?: string, + addedBy?: string, + expiresAt?: Date, + ): Promise { + try { + const existing = await this.whitelistRepository.findOne({ + where: { type, value }, + }); + + if (existing) { + existing.isActive = true; + existing.reason = reason || existing.reason; + existing.addedBy = addedBy || existing.addedBy; + existing.expiresAt = expiresAt || existing.expiresAt; + return await this.whitelistRepository.save(existing); + } + + const whitelist = this.whitelistRepository.create({ + type, + value, + reason, + addedBy, + expiresAt, + }); + return await this.whitelistRepository.save(whitelist); + } catch (error) { + logger.error(`Error adding to whitelist: ${error}`); + throw error; + } + } + + async removeFromWhitelist(id: string): Promise { + try { + const whitelist = await this.whitelistRepository.findOne({ + where: { id }, + }); + + if (!whitelist) { + throw new Error("Whitelist entry not found"); + } + + whitelist.isActive = false; + await this.whitelistRepository.save(whitelist); + } catch (error) { + logger.error(`Error removing from whitelist: ${error}`); + throw error; + } + } + + async isWhitelisted(type: WhitelistType, value: string): Promise { + try { + const now = new Date(); + const whitelist = await this.whitelistRepository.findOne({ + where: { + type, + value, + isActive: true, + }, + }); + + if (!whitelist) { + return false; + } + + // Check if whitelist entry has expired + if (whitelist.expiresAt && whitelist.expiresAt < now) { + whitelist.isActive = false; + await this.whitelistRepository.save(whitelist); + return false; + } + + return true; + } catch (error) { + logger.error(`Error checking whitelist: ${error}`); + return false; + } + } + + // Blacklist methods + async addToBlacklist( + type: BlacklistType, + value: string, + reason: BlacklistReason = BlacklistReason.MANUAL, + details?: string, + addedBy?: string, + expiresAt?: Date, + ): Promise { + try { + // Check if already exists + const existing = await this.blacklistRepository.findOne({ + where: { type, value }, + }); + + if (existing) { + // Update existing entry + existing.isActive = true; + existing.reason = reason; + existing.details = details || existing.details; + existing.addedBy = addedBy || existing.addedBy; + existing.expiresAt = expiresAt || existing.expiresAt; + return await this.blacklistRepository.save(existing); + } + + // Create new entry + const blacklist = this.blacklistRepository.create({ + type, + value, + reason, + details, + addedBy, + expiresAt, + }); + return await this.blacklistRepository.save(blacklist); + } catch (error) { + logger.error(`Error adding to blacklist: ${error}`); + throw error; + } + } + + async removeFromBlacklist(id: string): Promise { + try { + const blacklist = await this.blacklistRepository.findOne({ + where: { id }, + }); + + if (!blacklist) { + throw new Error("Blacklist entry not found"); + } + + blacklist.isActive = false; + await this.blacklistRepository.save(blacklist); + } catch (error) { + logger.error(`Error removing from blacklist: ${error}`); + throw error; + } + } + + async isBlacklisted(type: BlacklistType, value: string): Promise { + try { + const now = new Date(); + const blacklist = await this.blacklistRepository.findOne({ + where: { + type, + value, + isActive: true, + }, + }); + + if (!blacklist) { + return false; + } + + // Check if blacklist entry has expired + if (blacklist.expiresAt && blacklist.expiresAt < now) { + blacklist.isActive = false; + await this.blacklistRepository.save(blacklist); + return false; + } + + return true; + } catch (error) { + logger.error(`Error checking blacklist: ${error}`); + return false; + } + } + + async getWhitelistedEntries( + type?: WhitelistType, + ): Promise { + try { + const query: WhitelistQuery = { isActive: true }; + if (type) { + query.type = type; + } + return await this.whitelistRepository.find({ + where: query, + order: { createdAt: "DESC" }, + }); + } catch (error) { + logger.error(`Error getting whitelist entries: ${error}`); + throw error; + } + } + + async getBlacklistedEntries( + type?: BlacklistType, + ): Promise { + try { + const query: BlacklistQuery = { isActive: true }; + if (type) { + query.type = type; + } + return await this.blacklistRepository.find({ + where: query, + order: { createdAt: "DESC" }, + }); + } catch (error) { + logger.error(`Error getting blacklist entries: ${error}`); + throw error; + } + } +} + +export default new WhitelistBlacklistService(); diff --git a/src/tests/rateLimiter.test.ts b/src/tests/rateLimiter.test.ts new file mode 100644 index 0000000..2b537a3 --- /dev/null +++ b/src/tests/rateLimiter.test.ts @@ -0,0 +1,505 @@ +import type { Request, Response, NextFunction } from "express"; +// Do NOT import intelligentRateLimiter here yet, as we need to mock its dependency first. +import rateLimitConfigService from "../services/rateLimitConfigService"; +import whitelistBlacklistService from "../services/whitelistBlacklistService"; +import RateLimitMonitoringService from "../services/rateLimitMonitoring.service"; +import { redisClient } from "../config/redisConfig"; +import { UserRole } from "../enums/UserRole"; +import { jest } from "@jest/globals"; + +// --- START: Accurate Mock Interfaces based on your provided types --- +interface MockMerchantWebhookEntity { + id: string; + url: string; + event: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + merchantId: string; + merchant: MockMerchant; // Circular reference, but necessary for type accuracy +} + +interface MockMerchant { + id: string; + apiKey: string; + secret: string; + name: string; + email: string; + isActive: boolean; + business_name: string | null; + business_description: string | null; + business_address: string | null; + business_phone: string | null; + business_email: string | null; + business_logo_url: string | null; + createdAt: Date; + updatedAt: Date; + webhooks: MockMerchantWebhookEntity[]; + business_type?: "standard" | "premium" | "enterprise"; +} + +interface MockUser { + id: number; + email: string; + role: UserRole; +} + +interface MockRateLimitConfig { + id: string; + merchantId: string; + requestsPerSecond: number; + requestsPerMinute: number; + requestsPerHour: number; + requestsPerDay: number; + burstMultiplier: number; + burstDurationSeconds: number; + businessType?: "standard" | "premium" | "enterprise"; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + merchant: MockMerchant; +} + +// Extend Express Request to include custom properties for testing +declare module "express-serve-static-core" { + interface Request { + user?: MockUser; + merchant?: MockMerchant; + rateLimit?: { + limit: number; + current: number; + remaining: number; + resetTime: Date; + total: number; + }; + } +} + +// Define a type for the options passed to express-rate-limit +interface RateLimitOptions { + handler: ( + req: Request, + res: Response, + next: NextFunction, + options: RateLimitOptions, + ) => Promise | void; + max: number | ((req: Request, res: Response) => Promise); + windowMs: number; + skip: (req: Request, res: Response) => boolean; + // Add other options used by the limiter if necessary +} + +// Define a type for the mocked express-rate-limit instance +interface MockRateLimitInstance extends jest.Mock { + _options: RateLimitOptions; + resetKey: jest.Mock; +} + +// --- END: Accurate Mock Interfaces --- + +// Mock external dependencies +jest.mock("../services/rateLimitConfigService"); +jest.mock("../services/whitelistBlacklistService"); +jest.mock("../services/rateLimitMonitoring.service"); +jest.mock("../config/redisConfig", () => ({ + redisClient: { + get: jest.fn(), + set: jest.fn(), + }, +})); + +// --- CRITICAL FIX: Mock express-rate-limit itself --- +const mockRateLimit = jest.fn((options: RateLimitOptions) => { + const middlewareInstance = jest.fn( + (req: Request, res: Response, next: NextFunction) => next(), + ) as MockRateLimitInstance; + middlewareInstance._options = options; + middlewareInstance.resetKey = jest.fn(); + return middlewareInstance; +}) as unknown as (options: RateLimitOptions) => MockRateLimitInstance; // Cast the mock function itself + +jest.mock("express-rate-limit", () => ({ + __esModule: true, + default: mockRateLimit, +})); + +// Now, import the module under test AFTER its dependencies are mocked. +import intelligentRateLimiter from "../middleware/rateLimiter"; + +// Get typed mocks for the services +const mockedRateLimitConfigService = jest.mocked(rateLimitConfigService); +const mockedWhitelistBlacklistService = jest.mocked(whitelistBlacklistService); +const mockedRateLimitMonitoringService = jest.mocked( + RateLimitMonitoringService, +); +const mockedRedisClient = jest.mocked(redisClient); + +describe("intelligentRateLimiter", () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: NextFunction; + let originalSend: jest.Mock; + + // Helper to create a default MockMerchant + const defaultMockMerchant: MockMerchant = { + id: "default-merchant-id", + apiKey: "default-api-key", + secret: "default-secret", + name: "Default Mock Merchant", + email: "default@mock.com", + isActive: true, + business_name: "Default Business", + business_description: "A default mock business.", + business_address: "123 Default St", + business_phone: "555-0000", + business_email: "defaultbiz@mock.com", + business_logo_url: null, + webhooks: [], + createdAt: new Date(), + updatedAt: new Date(), + business_type: "standard", + }; + + // Helper to create a full MockRateLimitConfig + const createMockRateLimitConfig = ( + rpm: number, + merchantId = defaultMockMerchant.id, + businessType: "standard" | "premium" | "enterprise" = "standard", + isActive = true, + merchant: MockMerchant = { + ...defaultMockMerchant, + id: merchantId, + business_type: businessType, + }, + ): MockRateLimitConfig => ({ + id: `config-${merchantId}-${businessType}`, + merchantId: merchantId, + requestsPerMinute: rpm, + requestsPerSecond: Math.ceil(rpm / 60), + requestsPerHour: rpm * 60, + requestsPerDay: rpm * 60 * 24, + burstMultiplier: 2, + burstDurationSeconds: 30, + businessType: businessType, + isActive: isActive, + createdAt: new Date(), + updatedAt: new Date(), + merchant: merchant, + }); + + beforeEach(() => { + mockRequest = { + ip: "127.0.0.1", + originalUrl: "/api/test", // Default to a non-skipped path + path: "/api/test", // Default to a non-skipped path + headers: {}, + user: undefined, + merchant: undefined, + }; + mockResponse = { + statusCode: 200, + setHeader: jest.fn(), + send: jest.fn(), + json: jest.fn(), + status: jest.fn().mockReturnThis(), + }; + mockNext = jest.fn(); + originalSend = jest.fn(); + (mockResponse as Response).send = originalSend; + jest.clearAllMocks(); + + // Default mock implementations using typed mocks and full config objects + mockedRateLimitConfigService.getConfigForUser.mockResolvedValue( + createMockRateLimitConfig(60), + ); + mockedWhitelistBlacklistService.isBlacklisted.mockResolvedValue(false); + mockedWhitelistBlacklistService.isWhitelisted.mockResolvedValue(false); + mockedRedisClient.get.mockResolvedValue(null); + mockedRedisClient.set.mockResolvedValue("OK"); + mockedRateLimitMonitoringService.logAdvancedRateLimitEvent.mockResolvedValue( + undefined, + ); + }); + + // CRITICAL FIX: Simulate req.rateLimit being set by the express-rate-limit middleware + const simulateRateLimitExceeded = async ( + limiter: MockRateLimitInstance, + req: Request, + res: Response, + ) => { + const handler = limiter._options.handler; + const options = limiter._options; + // Manually set req.rateLimit to simulate the state when limit is exceeded + req.rateLimit = { + limit: options.max as number, // The configured max limit + current: (options.max as number) + 1, // Simulate current count exceeding the limit + remaining: -1, // No requests remaining + resetTime: new Date(Date.now() + options.windowMs), // Reset time in the future + total: (options.max as number) + 1, // Total requests made + }; + // Call the handler with the correct arguments + await handler(req, res, mockNext, options); + }; + + it("should apply default unauthenticated limit if no user/merchant context", async () => { + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + const limit = await maxFn(mockRequest as Request, mockResponse as Response); + expect(limit).toBe(30); + }); + + it("should apply default authenticated limit if user but no specific config", async () => { + mockRequest.user = { + id: 123, + email: "user@example.com", + role: UserRole.USER, + }; + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + const limit = await maxFn(mockRequest as Request, mockResponse as Response); + expect(limit).toBe(100); + }); + + it("should apply admin limit if user is ADMIN", async () => { + mockRequest.user = { + id: 456, + email: "admin@example.com", + role: UserRole.ADMIN, + }; + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + const limit = await maxFn(mockRequest as Request, mockResponse as Response); + expect(limit).toBe(200); + }); + + it("should apply dynamic limit based on user/merchant config", async () => { + mockRequest.user = { + id: 789, + email: "user@example.com", + role: UserRole.USER, + }; + const mockMerchant: MockMerchant = { + ...defaultMockMerchant, + id: "merchantABC", + name: "Mock Merchant", + email: "mock@merchant.com", + business_name: "Mock Business", + business_type: "standard", + }; + mockRequest.merchant = mockMerchant; + mockedRateLimitConfigService.getConfigForUser.mockResolvedValueOnce( + createMockRateLimitConfig( + 120, + mockMerchant.id, + mockMerchant.business_type, + true, + mockMerchant, + ), + ); + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + const limit = await maxFn(mockRequest as Request, mockResponse as Response); + expect(limit).toBe(120); + expect(mockedRateLimitConfigService.getConfigForUser).toHaveBeenCalledWith( + "789", + "merchantABC", + UserRole.USER, + ); + }); + + it("should return 0 (unlimited) if IP is whitelisted", async () => { + mockedWhitelistBlacklistService.isWhitelisted.mockResolvedValueOnce(true); + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + const limit = await maxFn(mockRequest as Request, mockResponse as Response); + expect(limit).toBe(0); + expect(mockedWhitelistBlacklistService.isWhitelisted).toHaveBeenCalledWith( + "ip", + "127.0.0.1", + ); + }); + + it("should return 0 (block) if IP is blacklisted", async () => { + mockedWhitelistBlacklistService.isBlacklisted.mockResolvedValueOnce(true); + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + const limit = await maxFn(mockRequest as Request, mockResponse as Response); + expect(limit).toBe(0); + expect(mockedWhitelistBlacklistService.isBlacklisted).toHaveBeenCalledWith( + "ip", + "127.0.0.1", + ); + }); + + it("should activate burst mode and return burst limit if user is authenticated and limit exceeded", async () => { + // CRITICAL FIX: Create a new request object for this test to set the specific path + const testRequest: Request = { + ...mockRequest, + originalUrl: "/api-docs", // Set specific path for this test + path: "/api-docs", // Set specific path for this test + user: { id: 101, email: "burst@example.com", role: UserRole.USER }, + } as Request; + const mockMerchant: MockMerchant = { + ...defaultMockMerchant, + id: "merchantXYZ", + name: "Burst Merchant", + email: "burst@merchant.com", + business_name: "Burst Business", + business_type: "premium", + }; + testRequest.merchant = mockMerchant; // Assign merchant to the new testRequest + const mockConfig = createMockRateLimitConfig( + 60, + mockMerchant.id, + mockMerchant.business_type, + true, + mockMerchant, + ); + mockedRateLimitConfigService.getConfigForUser.mockResolvedValue(mockConfig); // Use mockResolvedValue for consistency + + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + // First, call the max function to set up the config + await maxFn(testRequest, mockResponse as Response); // Use testRequest here + + // Now simulate rate limit exceeded + await simulateRateLimitExceeded( + limiter, + testRequest, + mockResponse as Response, + ); // Use testRequest here + + // Check if burst mode was activated + expect(mockedRedisClient.set).toHaveBeenCalledWith( + "burst:101:/api-docs", + "1", + { EX: 30 }, + ); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + "X-RateLimit-Burst", + "activated", + ); + expect(mockResponse.setHeader).toHaveBeenCalledWith( + "X-RateLimit-Burst-Duration", + "30", + ); + expect(mockResponse.status).toHaveBeenCalledWith(429); + expect(mockResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ + status: "error", + message: "Too many requests, please try again later", + code: "RATE_LIMIT_EXCEEDED", + burstModeAvailable: true, + }), + ); + + // Test that burst limit is returned when burst mode is active + mockedRedisClient.get.mockResolvedValue("1"); // Simulate burst active + const burstLimit = await maxFn(testRequest, mockResponse as Response); // Use testRequest here + expect(burstLimit).toBe(120); // 60 * 2 (burstMultiplier) + }); + + it("should log advanced rate limit event when limit is exceeded", async () => { + // CRITICAL FIX: Create a new request object for this test to set the specific path + const testRequest: Request = { + ...mockRequest, + originalUrl: "/api-docs", // Set specific path for this test + path: "/api-docs", // Set specific path for this test + user: { id: 202, email: "test@example.com", role: UserRole.USER }, + headers: { "user-agent": "jest-test" }, + } as Request; + const mockMerchant: MockMerchant = { + ...defaultMockMerchant, + id: "merchantABC", + name: "Test Enterprise", + email: "test@enterprise.com", + business_name: "Test Enterprise", + business_type: "enterprise", + }; + testRequest.merchant = mockMerchant; // Assign merchant to the new testRequest + + // Set up the config for this user/merchant + const mockConfig = createMockRateLimitConfig( + 60, + mockMerchant.id, + mockMerchant.business_type, + true, + mockMerchant, + ); + mockedRateLimitConfigService.getConfigForUser.mockResolvedValue(mockConfig); // Use mockResolvedValue for consistency + + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const maxFn = limiter._options.max as ( + req: Request, + res: Response, + ) => Promise; + // First call the max function to ensure config is loaded + await maxFn(testRequest, mockResponse as Response); // Use testRequest here + + // Now simulate rate limit exceeded + await simulateRateLimitExceeded( + limiter, + testRequest, + mockResponse as Response, + ); // Use testRequest here + + expect( + mockedRateLimitMonitoringService.logAdvancedRateLimitEvent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + ip: "127.0.0.1", + endpoint: "/api-docs", + userAgent: "jest-test", + userId: 202, // userId is number in RateLimitEvent + email: "test@example.com", + userRole: UserRole.USER, + merchantId: "merchantABC", + merchantType: "enterprise", + wasThrottled: true, + requestCount: 1, + }), + ); + }); + + it("should skip rate limiting for configured paths", async () => { + const limiter = intelligentRateLimiter as MockRateLimitInstance; + const skipFn = limiter._options.skip; + + // CRITICAL FIX: Create new request objects for each test case to avoid read-only property error + const healthRequest = { ...mockRequest, path: "/health" } as Request; + expect(skipFn(healthRequest, mockResponse as Response)).toBe(true); + + const apiDocsRequest = { + ...mockRequest, + path: "/api-docs/swagger", + } as Request; + expect(skipFn(apiDocsRequest, mockResponse as Response)).toBe(true); + + const otherRequest = { + ...mockRequest, + path: "/api/some-other-path", + } as Request; + expect(skipFn(otherRequest, mockResponse as Response)).toBe(false); + }); +}); diff --git a/src/tests/services/FraudDetectionService.test.ts b/src/tests/services/FraudDetectionService.test.ts deleted file mode 100644 index 6e04c83..0000000 --- a/src/tests/services/FraudDetectionService.test.ts +++ /dev/null @@ -1,640 +0,0 @@ -import { FraudDetectionService } from "../../services/FraudDetectionService"; -import { - Transaction, - TransactionStatus, - PaymentMethod, -} from "../../entities/Transaction"; -import { - RiskLevel, - FraudAlertStatus, - FraudAlert, -} from "../../entities/FraudAlert"; -import { MerchantFraudConfig } from "../../entities/MerchantFraudConfig"; -import { MerchantEntity } from "../../entities/Merchant.entity"; -import { Repository } from "typeorm"; -import AppDataSource from "../../config/db"; - -// Mock the database connection -jest.mock("../../config/db", () => ({ - getRepository: jest.fn(), -})); - -describe("FraudDetectionService", () => { - let fraudService: FraudDetectionService; - let mockTransactionRepo: jest.Mocked>>; - let mockFraudAlertRepo: jest.Mocked>>; - let mockMerchantConfigRepo: jest.Mocked< - Partial> - >; - let mockMerchantRepo: jest.Mocked>>; - - const createMockTransaction = ( - overrides: Partial = {}, - ): Transaction => ({ - id: "test-transaction-id", - merchantId: "test-merchant-id", - payerId: "test-payer-id", - amount: 100, - status: TransactionStatus.PENDING, - paymentMethod: PaymentMethod.CARD, - metadata: {}, - description: "Test transaction", - reference: "test-ref", - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }); - - const createMockConfig = ( - overrides: Partial = {}, - ): MerchantFraudConfig => - ({ - id: "config-id", - merchantId: "test-merchant-id", - lowRiskThreshold: 50, - mediumRiskThreshold: 70, - highRiskThreshold: 85, - criticalRiskThreshold: 95, - maxTransactionAmount: 1000, - dailyLimit: 5000, - maxTransactionsPerHour: 10, - maxTransactionsPerDay: 50, - maxSameAmountInHour: 3, - maxFailedAttemptsPerHour: 5, - autoBlockHighRisk: true, - autoBlockCritical: true, - requireManualReview: false, - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }) as MerchantFraudConfig; - - const createMockAlert = (overrides: Partial = {}): FraudAlert => - ({ - id: "alert-id", - transactionId: "test-transaction-id", - merchantId: "test-merchant-id", - payerId: "test-payer-id", - amount: 100, - riskScore: 0, - riskLevel: RiskLevel.LOW, - status: FraudAlertStatus.PENDING, - rulesTriggered: [], - metadata: {}, - reviewNotes: "", - reviewedBy: "", - reviewedAt: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }) as FraudAlert; - - beforeEach(() => { - jest.clearAllMocks(); - - mockTransactionRepo = { - count: jest.fn(), - find: jest.fn(), - createQueryBuilder: jest.fn(), - }; - - mockFraudAlertRepo = { - save: jest.fn(), - findOne: jest.fn(), - createQueryBuilder: jest.fn(), - }; - - mockMerchantConfigRepo = { - findOne: jest.fn(), - save: jest.fn(), - }; - - mockMerchantRepo = {}; - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - take: jest.fn().mockReturnThis(), - getMany: jest.fn(), - getRawOne: jest.fn(), - }; - - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - (mockFraudAlertRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - (AppDataSource.getRepository as jest.Mock).mockImplementation((entity) => { - if (entity === Transaction) return mockTransactionRepo; - if (entity.name === "FraudAlert") return mockFraudAlertRepo; - if (entity.name === "MerchantFraudConfig") return mockMerchantConfigRepo; - return mockMerchantRepo; - }); - - fraudService = new FraudDetectionService(); - }); - - describe("checkTransaction", () => { - it("should return low risk for normal transaction without creating alert", async () => { - const transaction = createMockTransaction(); - const config = createMockConfig(); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock).mockResolvedValue(0); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue([]); - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ total: "100" }), - }; - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.riskLevel).toBe(RiskLevel.LOW); - expect(result.shouldBlock).toBe(false); - expect(result.riskScore).toBeLessThan(50); - expect(result.rulesTriggered).toHaveLength(0); - expect(result.alert).toBeUndefined(); // No alert should be created for low risk with no rules triggered - expect(mockFraudAlertRepo.save).not.toHaveBeenCalled(); - }); - - it("should detect high risk for transaction exceeding amount limit and auto-create alert", async () => { - const transaction = createMockTransaction({ amount: 2000 }); - const config = createMockConfig(); - const mockAlert = createMockAlert({ - amount: 2000, - riskScore: 50, - riskLevel: RiskLevel.MEDIUM, - status: FraudAlertStatus.PENDING, - rulesTriggered: ["AMOUNT_EXCEEDS_LIMIT"], - }); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock).mockResolvedValue(0); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue([]); - (mockFraudAlertRepo.save as jest.Mock).mockResolvedValue(mockAlert); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.riskScore).toBeGreaterThanOrEqual(50); - expect(result.rulesTriggered).toContain("AMOUNT_EXCEEDS_LIMIT"); - }); - - it("should detect velocity violations and create blocked alert", async () => { - const transaction = createMockTransaction(); - const config = createMockConfig(); - const mockAlert = createMockAlert({ - riskScore: 100, // Capped at 100 - riskLevel: RiskLevel.CRITICAL, - status: FraudAlertStatus.BLOCKED, - rulesTriggered: [ - "VELOCITY_HOURLY_EXCEEDED", - "VELOCITY_DAILY_EXCEEDED", - "DAILY_AMOUNT_LIMIT_EXCEEDED", - ], - }); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock) - .mockResolvedValueOnce(15) // hourly count - .mockResolvedValueOnce(60); // daily count - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ total: "4950" }), - }; - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue([]); - - const createAlertSpy = jest - .spyOn(fraudService, "createFraudAlert") - .mockResolvedValue(mockAlert); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.rulesTriggered).toContain("VELOCITY_HOURLY_EXCEEDED"); - expect(result.rulesTriggered).toContain("VELOCITY_DAILY_EXCEEDED"); - expect(result.rulesTriggered).toContain("DAILY_AMOUNT_LIMIT_EXCEEDED"); - expect(result.riskLevel).toBe(RiskLevel.CRITICAL); - expect(result.riskScore).toBe(100); // Should be capped at 100 - expect(result.shouldBlock).toBe(true); - expect(createAlertSpy).toHaveBeenCalled(); - }); - - it("should detect pattern anomalies and not create alert for low risk", async () => { - const transaction = createMockTransaction({ amount: 500 }); - const config = createMockConfig(); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock) - .mockResolvedValueOnce(0) // hourly count - .mockResolvedValueOnce(0) // daily count - .mockResolvedValueOnce(5) // same amount count - .mockResolvedValueOnce(0); // failed attempts - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ total: "100" }), - }; - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue([]); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.rulesTriggered).toContain("SAME_AMOUNT_PATTERN"); - expect(result.rulesTriggered).toContain("ROUND_AMOUNT_PATTERN"); - expect(result.riskLevel).toBe(RiskLevel.LOW); // Still low risk - expect(result.shouldBlock).toBe(false); - expect(mockFraudAlertRepo.save).not.toHaveBeenCalled(); // No alert created for low risk - }); - - it("should detect excessive failed attempts and not create alert for low risk", async () => { - const transaction = createMockTransaction(); - const config = createMockConfig(); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock) - .mockResolvedValueOnce(0) // hourly count - .mockResolvedValueOnce(0) // daily count - .mockResolvedValueOnce(0) // same amount count - .mockResolvedValueOnce(10); // failed attempts - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ total: "100" }), - }; - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue([]); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.rulesTriggered).toContain("EXCESSIVE_FAILED_ATTEMPTS"); - expect(result.riskScore).toBeGreaterThan(0); - expect(result.riskLevel).toBe(RiskLevel.LOW); - expect(result.shouldBlock).toBe(false); - expect(mockFraudAlertRepo.save).not.toHaveBeenCalled(); // No alert for low risk - }); - - it("should detect statistical anomalies and not create alert for low risk", async () => { - const transaction = createMockTransaction({ amount: 1000 }); - const config = createMockConfig(); - - const recentTransactions = Array.from({ length: 20 }, (_, i) => - createMockTransaction({ amount: 100 + i }), - ); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock).mockResolvedValue(0); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue( - recentTransactions, - ); - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ total: "100" }), - }; - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.rulesTriggered).toContain("STATISTICAL_ANOMALY"); - expect(result.riskLevel).toBe(RiskLevel.LOW); - expect(result.shouldBlock).toBe(false); - expect(mockFraudAlertRepo.save).not.toHaveBeenCalled(); - }); - - it("should handle unusual transaction times and not create alert for low risk", async () => { - const mockDate = new Date(); - mockDate.setHours(3); - - const OriginalDate = global.Date; - const mockDateConstructor = jest.fn( - () => mockDate, - ) as unknown as DateConstructor; - mockDateConstructor.now = jest.fn().mockReturnValue(Date.now()); - mockDateConstructor.parse = jest.fn().mockImplementation(Date.parse); - mockDateConstructor.UTC = jest.fn().mockImplementation(Date.UTC); - - global.Date = mockDateConstructor; - - const transaction = createMockTransaction(); - const config = createMockConfig(); - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - (mockTransactionRepo.count as jest.Mock).mockResolvedValue(0); - (mockTransactionRepo.find as jest.Mock).mockResolvedValue([]); - - const mockQueryBuilder = { - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getRawOne: jest.fn().mockResolvedValue({ total: "100" }), - }; - (mockTransactionRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - const result = await fraudService.checkTransaction({ transaction }); - - expect(result.rulesTriggered).toContain("UNUSUAL_TIME"); - expect(result.riskLevel).toBe(RiskLevel.LOW); - expect(result.shouldBlock).toBe(false); - expect(mockFraudAlertRepo.save).not.toHaveBeenCalled(); - - global.Date = OriginalDate; - }); - }); - - describe("getMerchantConfig", () => { - it("should return existing config", async () => { - const config = createMockConfig(); - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(config); - - const result = await fraudService.getMerchantConfig("test-merchant-id"); - - expect(result).toEqual(config); - expect(mockMerchantConfigRepo.findOne).toHaveBeenCalledWith({ - where: { merchantId: "test-merchant-id" }, - }); - }); - - it("should create default config if none exists", async () => { - const newConfig = createMockConfig(); - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue(null); - (mockMerchantConfigRepo.save as jest.Mock).mockResolvedValue(newConfig); - - const result = await fraudService.getMerchantConfig("test-merchant-id"); - - expect(mockMerchantConfigRepo.save).toHaveBeenCalled(); - expect(result).toEqual(newConfig); - }); - }); - - describe("createFraudAlert", () => { - it("should create fraud alert with correct data", async () => { - const transaction = createMockTransaction(); - const fraudResult = { - riskScore: 75, - riskLevel: RiskLevel.HIGH, - shouldBlock: false, - rulesTriggered: ["AMOUNT_EXCEEDS_LIMIT"], - requiresReview: true, - }; - - const expectedAlert = { - id: "alert-id", - transactionId: transaction.id, - merchantId: transaction.merchantId, - payerId: transaction.payerId, - amount: transaction.amount, - riskScore: fraudResult.riskScore, - riskLevel: fraudResult.riskLevel, - status: FraudAlertStatus.PENDING, - rulesTriggered: fraudResult.rulesTriggered, - metadata: transaction.metadata, - }; - - (mockFraudAlertRepo.save as jest.Mock).mockResolvedValue(expectedAlert); - - const result = await fraudService.createFraudAlert( - transaction, - fraudResult, - ); - - expect(mockFraudAlertRepo.save).toHaveBeenCalled(); - expect(result).toEqual(expectedAlert); - }); - - it("should create blocked alert for high-risk transactions", async () => { - const transaction = createMockTransaction(); - const fraudResult = { - riskScore: 90, - riskLevel: RiskLevel.CRITICAL, - shouldBlock: true, - rulesTriggered: ["AMOUNT_EXCEEDS_LIMIT"], - requiresReview: true, - }; - - const expectedAlert = { - id: "alert-id", - status: FraudAlertStatus.BLOCKED, - }; - - (mockFraudAlertRepo.save as jest.Mock).mockResolvedValue(expectedAlert); - - const result = await fraudService.createFraudAlert( - transaction, - fraudResult, - ); - - expect(result.status).toBe(FraudAlertStatus.BLOCKED); - }); - }); - - describe("reviewFraudAlert", () => { - it("should update fraud alert status successfully", async () => { - const existingAlert = { - id: "alert-id", - status: FraudAlertStatus.PENDING, - reviewNotes: null, - reviewedBy: null, - reviewedAt: null, - }; - - const updatedAlert = { - ...existingAlert, - status: FraudAlertStatus.APPROVED, - reviewNotes: "Legitimate transaction", - reviewedBy: "admin-user", - reviewedAt: new Date(), - }; - - (mockFraudAlertRepo.findOne as jest.Mock).mockResolvedValue( - existingAlert, - ); - (mockFraudAlertRepo.save as jest.Mock).mockResolvedValue(updatedAlert); - - const result = await fraudService.reviewFraudAlert( - "alert-id", - FraudAlertStatus.APPROVED, - "Legitimate transaction", - "admin-user", - ); - - expect(result.status).toBe(FraudAlertStatus.APPROVED); - expect(result.reviewNotes).toBe("Legitimate transaction"); - expect(result.reviewedBy).toBe("admin-user"); - expect(result.reviewedAt).toBeDefined(); - }); - - it("should throw error if alert not found", async () => { - (mockFraudAlertRepo.findOne as jest.Mock).mockResolvedValue(null); - - await expect( - fraudService.reviewFraudAlert( - "non-existent-id", - FraudAlertStatus.APPROVED, - ), - ).rejects.toThrow("Fraud alert not found"); - }); - }); - - describe("updateMerchantConfig", () => { - it("should update existing config", async () => { - const existingConfig = createMockConfig(); - const updates = { maxTransactionAmount: 2000 }; - const updatedConfig = { ...existingConfig, ...updates }; - - (mockMerchantConfigRepo.findOne as jest.Mock).mockResolvedValue( - existingConfig, - ); - (mockMerchantConfigRepo.save as jest.Mock).mockResolvedValue( - updatedConfig, - ); - - const result = await fraudService.updateMerchantConfig( - "test-merchant-id", - updates, - ); - - expect(result.maxTransactionAmount).toBe(2000); - expect(mockMerchantConfigRepo.save).toHaveBeenCalledWith(updatedConfig); - }); - }); - - describe("getFraudAlerts", () => { - it("should return fraud alerts with filters", async () => { - const mockAlerts = [ - { - id: "alert-1", - merchantId: "merchant-1", - status: FraudAlertStatus.PENDING, - }, - { - id: "alert-2", - merchantId: "merchant-1", - status: FraudAlertStatus.BLOCKED, - }, - ]; - - const mockQueryBuilder = { - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(mockAlerts), - }; - - (mockFraudAlertRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - const result = await fraudService.getFraudAlerts( - "merchant-1", - FraudAlertStatus.PENDING, - ); - - expect(mockQueryBuilder.where).toHaveBeenCalledWith( - "alert.merchantId = :merchantId", - { merchantId: "merchant-1" }, - ); - expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( - "alert.status = :status", - { status: FraudAlertStatus.PENDING }, - ); - expect(result).toEqual(mockAlerts); - }); - - it("should return all alerts without filters", async () => { - const mockAlerts = [{ id: "alert-1" }]; - - const mockQueryBuilder = { - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(mockAlerts), - }; - - (mockFraudAlertRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - const result = await fraudService.getFraudAlerts(); - - expect(mockQueryBuilder.where).not.toHaveBeenCalled(); - expect(result).toEqual(mockAlerts); - }); - }); - - describe("getFraudStats", () => { - it("should return fraud statistics", async () => { - const mockAlerts = [ - createMockAlert({ - riskScore: 75, - riskLevel: RiskLevel.HIGH, - status: FraudAlertStatus.BLOCKED, - amount: 500, - rulesTriggered: ["AMOUNT_EXCEEDS_LIMIT", "VELOCITY_HOURLY_EXCEEDED"], - }), - createMockAlert({ - riskScore: 45, - riskLevel: RiskLevel.MEDIUM, - status: FraudAlertStatus.PENDING, - amount: 300, - rulesTriggered: ["SAME_AMOUNT_PATTERN"], - }), - ]; - - const mockQueryBuilder = { - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue(mockAlerts), - }; - - (mockFraudAlertRepo.createQueryBuilder as jest.Mock).mockReturnValue( - mockQueryBuilder, - ); - - const result = await fraudService.getFraudStats("test-merchant-id", 30); - - expect(result.totalAlerts).toBe(2); - expect(result.blockedTransactions).toBe(1); - expect(result.pendingReviews).toBe(1); - expect(result.averageRiskScore).toBe(60); - expect(result.totalAmount).toBe(800); - expect(result.blockedAmount).toBe(500); - expect(result.topTriggeredRules).toEqual([ - { rule: "AMOUNT_EXCEEDS_LIMIT", count: 1 }, - { rule: "VELOCITY_HOURLY_EXCEEDED", count: 1 }, - { rule: "SAME_AMOUNT_PATTERN", count: 1 }, - ]); - }); - }); -}); diff --git a/src/tests/services/fraudDetectionServices.ts b/src/tests/services/fraudDetectionServices.ts new file mode 100644 index 0000000..cc90333 --- /dev/null +++ b/src/tests/services/fraudDetectionServices.ts @@ -0,0 +1,568 @@ +import { FraudDetectionService } from "../../services/FraudDetectionService"; +import AppDataSource from "../../config/db"; +import { RateLimitHistory } from "../../entities/RateLimitHistory"; +import { Transaction, TransactionStatus } from "../../entities/Transaction"; +import { MerchantFraudConfig } from "../../entities/MerchantFraudConfig"; +import { + BlacklistType, + BlacklistReason, +} from "../../entities/RateLimitBlacklist"; +import whitelistBlacklistService from "../../services/whitelistBlacklistService"; +import type { Repository } from "typeorm"; +import { RiskLevel } from "../../entities/FraudAlert"; +import { + jest, + describe, + beforeAll, + beforeEach, + it, + expect, +} from "@jest/globals"; + +// Mock external dependencies +jest.mock("../config/db", () => ({ + initialize: jest.fn().mockResolvedValue(undefined), + getRepository: jest.fn(() => ({ + create: jest.fn((entity) => entity), + save: jest.fn(), + count: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + having: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + getRawMany: jest.fn(), + getMany: jest.fn(), + })), + })), + isInitialized: true, // Assume initialized for tests +})); +jest.mock("../services/whitelistBlacklistService"); +jest.mock("../utils/logger", () => ({ + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +})); + +// Define interfaces for raw query results +interface RawCountResult { + count: string; +} + +interface RawEndpointCountResult { + endpointCount: string; +} + +interface RawIpCountResult { + ip: string; + count: string; +} + +interface RawUserCountResult { + userId: string; + count: string; +} + +// Create a mockable version of FraudDetectionService to allow mocking private/protected methods +class MockableFraudDetectionService extends FraudDetectionService { + public getAverageTransactionAmount: jest.Mock; // Make it public for mocking + constructor() { + super(); + this.getAverageTransactionAmount = jest.fn(); + } +} + +describe("FraudDetectionService - Rate Limit Integration", () => { + let service: MockableFraudDetectionService; + let mockRateLimitHistoryRepo: jest.Mocked>; + let mockTransactionRepo: jest.Mocked>; + let mockMerchantConfigRepo: jest.Mocked>; + + const defaultMerchantConfig: MerchantFraudConfig = { + id: "config1", + merchantId: "merchant123", + businessType: "standard", + requestsPerSecond: 10, + requestsPerMinute: 600, + requestsPerHour: 36000, + requestsPerDay: 864000, + burstMultiplier: 1.5, + burstDurationSeconds: 30, + criticalRiskThreshold: 80, + highRiskThreshold: 60, + mediumRiskThreshold: 30, + autoBlockCritical: true, + autoBlockHighRisk: false, + maxTransactionAmount: 10000, + maxTransactionsPerHour: 100, + maxTransactionsPerDay: 1000, + dailyLimit: 50000, + maxSameAmountInHour: 5, + maxFailedAttemptsPerHour: 3, + createdAt: new Date(), + updatedAt: new Date(), + }; + + beforeAll(() => { + service = new MockableFraudDetectionService(); + mockRateLimitHistoryRepo = AppDataSource.getRepository( + RateLimitHistory, + ) as jest.Mocked>; + mockTransactionRepo = AppDataSource.getRepository( + Transaction, + ) as jest.Mocked>; + mockMerchantConfigRepo = AppDataSource.getRepository( + MerchantFraudConfig, + ) as jest.Mocked>; + }); + + beforeEach(() => { + jest.clearAllMocks(); + // Default mock for getMerchantConfig + mockMerchantConfigRepo.findOne.mockResolvedValue(defaultMerchantConfig); + + // Default mock for original checkTransaction dependencies + mockTransactionRepo.count.mockResolvedValue(0); + mockTransactionRepo + .createQueryBuilder() + .getRawOne.mockResolvedValue({ total: 0 } as RawCountResult); + mockTransactionRepo.find.mockResolvedValue([]); + service.getAverageTransactionAmount.mockResolvedValue(100); // Mock internal helper + }); + + describe("checkRateLimitingPatterns", () => { + it("should add score for excessive rate limiting", async () => { + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(6); // Excessive rate limits + mockRateLimitHistoryRepo.count.mockResolvedValue(0); // Other counts + mockRateLimitHistoryRepo + .createQueryBuilder() + .getRawOne.mockResolvedValue({ + endpointCount: 0, + } as RawEndpointCountResult); + const result = await service.checkRateLimitingPatterns( + "user123", + "192.168.1.1", + "merchantABC", + ); + expect(result.riskScore).toBe(25); + expect(result.rulesTriggered).toContain("EXCESSIVE_RATE_LIMITING"); + }); + + it("should add score for IP rate limit abuse", async () => { + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(0); // Excessive rate limits + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(11); // IP rate limits + mockRateLimitHistoryRepo.count.mockResolvedValue(0); // Other counts + mockRateLimitHistoryRepo + .createQueryBuilder() + .getRawOne.mockResolvedValue({ + endpointCount: 0, + } as RawEndpointCountResult); + const result = await service.checkRateLimitingPatterns( + "user123", + "192.168.1.1", + "merchantABC", + ); + expect(result.riskScore).toBe(30); + expect(result.rulesTriggered).toContain("IP_RATE_LIMIT_ABUSE"); + }); + + it("should add score for burst mode abuse", async () => { + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(0); + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(0); + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(4); // Burst mode abuse + mockRateLimitHistoryRepo.count.mockResolvedValue(0); + mockRateLimitHistoryRepo + .createQueryBuilder() + .getRawOne.mockResolvedValue({ + endpointCount: 0, + } as RawEndpointCountResult); + const result = await service.checkRateLimitingPatterns( + "user123", + "192.168.1.1", + "merchantABC", + ); + expect(result.riskScore).toBe(15); + expect(result.rulesTriggered).toContain("BURST_MODE_ABUSE"); + }); + + it("should add score for rapid endpoint switching", async () => { + mockRateLimitHistoryRepo.count.mockResolvedValue(0); + mockRateLimitHistoryRepo + .createQueryBuilder() + .getRawOne.mockResolvedValue({ + endpointCount: 11, + } as RawEndpointCountResult); // Rapid endpoint switching + mockRateLimitHistoryRepo.count.mockResolvedValue(0); + const result = await service.checkRateLimitingPatterns( + "user123", + "192.168.1.1", + "merchantABC", + ); + expect(result.riskScore).toBe(20); + expect(result.rulesTriggered).toContain("RAPID_ENDPOINT_SWITCHING"); + }); + + it("should add score for high volume pattern", async () => { + mockRateLimitHistoryRepo.count.mockResolvedValue(0); + mockRateLimitHistoryRepo + .createQueryBuilder() + .getRawOne.mockResolvedValue({ + endpointCount: 0, + } as RawEndpointCountResult); + mockRateLimitHistoryRepo.count.mockResolvedValueOnce(6); // High volume pattern + const result = await service.checkRateLimitingPatterns( + "user123", + "192.168.1.1", + "merchantABC", + ); + expect(result.riskScore).toBe(20); + expect(result.rulesTriggered).toContain("HIGH_VOLUME_PATTERN"); + }); + }); + + describe("checkTransactionWithRateLimit", () => { + const mockTransaction: Transaction = { + id: "trans1", + merchantId: "merchant123", + payerId: "user123", + amount: 100, + status: TransactionStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + const mockContext = { + transaction: mockTransaction, + ipAddress: "192.168.1.1", + }; + + it("should combine risk score from original check and rate limit patterns", async () => { + // Mock original checkTransaction to return a base score + jest.spyOn(service, "checkTransaction").mockResolvedValueOnce({ + riskScore: 20, + riskLevel: RiskLevel.LOW, + shouldBlock: false, + rulesTriggered: ["SOME_RULE"], + requiresReview: false, + }); + + // Mock rate limiting patterns to add score + jest.spyOn(service, "checkRateLimitingPatterns").mockResolvedValueOnce({ + riskScore: 30, // e.g., from IP_RATE_LIMIT_ABUSE + rulesTriggered: ["IP_RATE_LIMIT_ABUSE"], + }); + + const result = await service.checkTransactionWithRateLimit(mockContext); + expect(result.riskScore).toBe(50); // 20 (original) + 30 (rate limit) + expect(result.rulesTriggered).toContain("SOME_RULE"); + expect(result.rulesTriggered).toContain("IP_RATE_LIMIT_ABUSE"); + expect(result.riskLevel).toBe(RiskLevel.MEDIUM); // Assuming 50 falls into MEDIUM + }); + + it("should blacklist user if rate limit risk score is high", async () => { + jest.spyOn(service, "checkTransaction").mockResolvedValueOnce({ + riskScore: 0, + riskLevel: RiskLevel.LOW, + shouldBlock: false, + rulesTriggered: [], + requiresReview: false, + }); + jest.spyOn(service, "checkRateLimitingPatterns").mockResolvedValueOnce({ + riskScore: 35, // High enough to trigger blacklist + rulesTriggered: ["EXCESSIVE_RATE_LIMITING"], + }); + + await service.checkTransactionWithRateLimit(mockContext); + expect(whitelistBlacklistService.addToBlacklist).toHaveBeenCalledWith( + BlacklistType.USER, + mockTransaction.payerId, + BlacklistReason.ABUSE, + expect.stringContaining( + "High risk score from rate limiting patterns: 35", + ), + "fraud-system", + expect.any(Date), + ); + }); + + it("should blacklist IP if rate limit risk score is high and IP is present", async () => { + jest.spyOn(service, "checkTransaction").mockResolvedValueOnce({ + riskScore: 0, + riskLevel: RiskLevel.LOW, + shouldBlock: false, + rulesTriggered: [], + requiresReview: false, + }); + jest.spyOn(service, "checkRateLimitingPatterns").mockResolvedValueOnce({ + riskScore: 26, // High enough to trigger IP blacklist + rulesTriggered: ["IP_RATE_LIMIT_ABUSE"], + }); + + await service.checkTransactionWithRateLimit(mockContext); + expect(whitelistBlacklistService.addToBlacklist).toHaveBeenCalledWith( + BlacklistType.IP, + mockContext.ipAddress, + BlacklistReason.ABUSE, + expect.stringContaining( + "IP associated with high-risk rate limiting patterns. Risk score: 26", + ), + "fraud-system", + expect.any(Date), + ); + }); + }); + + describe("getRateLimitFraudStats", () => { + it("should return aggregated rate limit fraud statistics", async () => { + const mockHistoryData: RateLimitHistory[] = [ + { + wasThrottled: true, + wasBurst: false, + ip: "1.1.1.1", + userId: "userA", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "1.1.1.1", + userId: "userA", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "1.1.1.1", + userId: "userA", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "1.1.1.1", + userId: "userA", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "1.1.1.1", + userId: "userA", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "1.1.1.1", + userId: "userA", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, // 6 throttled for userA + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: true, + wasBurst: false, + ip: "2.2.2.2", + userId: "userB", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, // 11 throttled for IP 2.2.2.2 + { + wasThrottled: false, + wasBurst: true, + ip: "3.3.3.3", + userId: "userC", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + { + wasThrottled: false, + wasBurst: true, + ip: "3.3.3.3", + userId: "userC", + timestamp: new Date(), + merchantId: "merchant123", + endpoint: "/api/test", + requestCount: 1, + limitUsed: 10, + userAgent: "test", + }, + ]; + mockRateLimitHistoryRepo + .createQueryBuilder() + .getMany.mockResolvedValue(mockHistoryData); + mockRateLimitHistoryRepo + .createQueryBuilder() + .getRawMany.mockResolvedValueOnce([ + { ip: "2.2.2.2", count: "11" }, + ] as RawIpCountResult[]) // suspiciousIPs + .mockResolvedValueOnce([ + { userId: "userA", count: "6" }, + ] as RawUserCountResult[]); // suspiciousUsers + + const stats = await service.getRateLimitFraudStats("merchant123", 30); + expect(stats.totalEvents).toBe(mockHistoryData.length); + expect(stats.throttledEvents).toBe(11); // 11 IPs, 6 users + expect(stats.burstEvents).toBe(2); + expect(stats.suspiciousActivity.suspiciousIPs).toEqual([ + { ip: "2.2.2.2", throttledCount: 11 }, + ]); + expect(stats.suspiciousActivity.suspiciousUsers).toEqual([ + { userId: "userA", throttledCount: 6 }, + ]); + expect(stats.riskIndicators.highRiskIPs).toBe(1); + expect(stats.riskIndicators.highRiskUsers).toBe(1); + }); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 086f189..5bb7ad3 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -27,3 +27,16 @@ export interface MerchantData { apiKey: string; // Añade más campos según necesites } + +export type BalanceInfo = { + assetCode?: string; + assetIssuer?: string | null; + balance: string; + assetType?: string; + isAuthorized?: boolean; + isAuthorizedToMaintainLiabilities?: boolean; + isClawbackEnabled?: boolean; + lastModifiedLedger?: number; + limit?: string; + sponsor?: string; +};