diff --git a/README.md b/README.md index 20e6ec2..f42405d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,11 @@ PayStell Backend is the server-side component of a payment solution that enables - Minimal transaction fees - Robust data validation and error handling - Integration with local and online payment systems +- **Multi-Environment Configuration Management System** +- **Feature Flags with targeting and percentage rollouts** +- **Dynamic configuration updates without restart** +- **Encrypted sensitive configurations** +- **Comprehensive audit logging** ## āš™ļø Project Structure @@ -63,6 +68,31 @@ cp .env.example .env # Start the development server npm start + +## šŸ”§ Configuration Management + +The PayStell backend includes a comprehensive Multi-Environment Configuration Management System: + +### Initialize Configurations +```bash +npm run init-config +``` + +### Configuration API +- **Get all configurations**: `GET /api/config` +- **Get configuration by key**: `GET /api/config/{key}` +- **Create/Update configuration**: `POST /api/config` +- **Delete configuration**: `DELETE /api/config/{key}` +- **Get configurations by category**: `GET /api/config/category/{category}` +- **Reload configurations**: `POST /api/config/reload` +- **Validate configurations**: `GET /api/config/validate` + +### Feature Flags +- **Get all feature flags**: `GET /api/config/feature-flags` +- **Create/Update feature flag**: `POST /api/config/feature-flags` +- **Evaluate feature flag**: `GET /api/config/feature-flags/{flagName}/evaluate` + +For detailed documentation, see [CONFIGURATION_SYSTEM.md](docs/CONFIGURATION_SYSTEM.md) ``` ## šŸ“¬ How to Import the Postman Collection diff --git a/docs/CONFIGURATION_SYSTEM.md b/docs/CONFIGURATION_SYSTEM.md new file mode 100644 index 0000000..80d7365 --- /dev/null +++ b/docs/CONFIGURATION_SYSTEM.md @@ -0,0 +1,498 @@ +# Multi-Environment Configuration Management System + +## Overview + +The PayStell backend now includes a comprehensive Multi-Environment Configuration Management System that provides centralized configuration management, feature flags, and dynamic configuration updates across different environments (development, staging, production). + +## Features + +- āœ… **Centralized Configuration Management**: All configurations stored in database with environment-specific values +- āœ… **Configuration Validation**: Automatic validation of required configurations at startup +- āœ… **Feature Flags System**: Dynamic feature toggles with targeting and percentage rollouts +- āœ… **Encryption Support**: Automatic encryption of sensitive configurations +- āœ… **Hot Reload**: Update configurations without server restart +- āœ… **Audit Logging**: Complete audit trail of all configuration changes +- āœ… **Environment Support**: Separate configurations for development, staging, and production +- āœ… **API Management**: RESTful API for configuration management +- āœ… **Caching**: In-memory caching for performance +- āœ… **Import/Export**: Bulk configuration import and export capabilities + +## Architecture + +### Core Components + +1. **Configuration Entity** (`src/entities/Configuration.ts`) + - Stores configuration values with metadata + - Supports different data types (string, number, boolean, JSON, encrypted) + - Environment-specific configurations + - Validation rules and constraints + +2. **Feature Flag Entity** (`src/entities/FeatureFlag.ts`) + - Manages feature flags with targeting rules + - Supports percentage rollouts and scheduling + - Different scopes (global, user, merchant, environment) + +3. **Configuration Service** (`src/services/ConfigurationService.ts`) + - Core business logic for configuration management + - Encryption/decryption of sensitive values + - Caching and validation + - Feature flag evaluation + +4. **Configuration Controller** (`src/controllers/ConfigurationController.ts`) + - REST API endpoints for configuration management + - CRUD operations for configurations and feature flags + - Import/export functionality + +5. **Configuration Middleware** (`src/middlewares/configurationMiddleware.ts`) + - Injects configuration service into requests + - Feature flag checking middleware + - Environment-specific configuration injection + +## Database Schema + +### Configuration Table +```sql +CREATE TABLE configurations ( + id UUID PRIMARY KEY, + config_key VARCHAR(255) NOT NULL, + value TEXT NOT NULL, + environment VARCHAR(50) NOT NULL, + type VARCHAR(50) NOT NULL, + category VARCHAR(50) NOT NULL, + description TEXT, + is_encrypted BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + is_required BOOLEAN DEFAULT FALSE, + validation_rules TEXT, + default_value TEXT, + allowed_values TEXT, + expires_at TIMESTAMP, + metadata TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + updated_by VARCHAR(255), + UNIQUE(config_key, environment) +); +``` + +### Feature Flag Table +```sql +CREATE TABLE feature_flags ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + is_enabled BOOLEAN DEFAULT FALSE, + environment VARCHAR(50) NOT NULL, + scope VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL, + targeting_rules JSONB, + scheduled_start_date TIMESTAMP, + scheduled_end_date TIMESTAMP, + metadata JSONB, + owner VARCHAR(255), + tags TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + updated_by VARCHAR(255), + UNIQUE(name, environment) +); +``` + +## API Endpoints + +### Configuration Management + +#### Get All Configurations +```http +GET /api/config +Authorization: Bearer +``` + +#### Get Configuration by Key +```http +GET /api/config/{key} +Authorization: Bearer +``` + +#### Create/Update Configuration +```http +POST /api/config +Authorization: Bearer +Content-Type: application/json + +{ + "key": "PAYMENT_MIN_AMOUNT", + "value": "0.01", + "type": "number", + "category": "payment", + "description": "Minimum payment amount", + "isRequired": true, + "isEncrypted": false +} +``` + +#### Delete Configuration +```http +DELETE /api/config/{key} +Authorization: Bearer +``` + +#### Get Configurations by Category +```http +GET /api/config/category/{category} +Authorization: Bearer +``` + +#### Reload Configurations +```http +POST /api/config/reload +Authorization: Bearer +``` + +#### Validate Configurations +```http +GET /api/config/validate +Authorization: Bearer +``` + +### Feature Flag Management + +#### Get All Feature Flags +```http +GET /api/config/feature-flags +Authorization: Bearer +``` + +#### Create/Update Feature Flag +```http +POST /api/config/feature-flags +Authorization: Bearer +Content-Type: application/json + +{ + "name": "advanced_fraud_detection", + "description": "Enable advanced fraud detection features", + "isEnabled": true, + "scope": "global", + "targetingRules": { + "percentage": 25 + } +} +``` + +#### Evaluate Feature Flag +```http +GET /api/config/feature-flags/{flagName}/evaluate?userId=123&userRole=admin +Authorization: Bearer +``` + +### Utility Endpoints + +#### Get Configuration Statistics +```http +GET /api/config/stats +Authorization: Bearer +``` + +#### Export Configurations +```http +GET /api/config/export?environment=production +Authorization: Bearer +``` + +#### Import Configurations +```http +POST /api/config/import +Authorization: Bearer +Content-Type: application/json + +{ + "configurations": [ + { + "key": "PAYMENT_MIN_AMOUNT", + "value": "0.01", + "type": "number", + "category": "payment" + } + ], + "overwrite": false +} +``` + +## Usage Examples + +### In Controllers/Services + +```typescript +import { configurationService } from "../services/ConfigurationService"; + +// Get configuration value +const minAmount = await configurationService.getConfig("PAYMENT_MIN_AMOUNT", "0.01"); + +// Check feature flag +const isAdvancedFraudEnabled = await configurationService.evaluateFeatureFlag( + "advanced_fraud_detection", + { userId: "123", userRole: "admin" } +); + +// Set configuration +await configurationService.setConfig("PAYMENT_MAX_AMOUNT", 10000, { + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.PAYMENT, + description: "Maximum payment amount", + isRequired: true, + updatedBy: "user123" +}); +``` + +### In Request Handlers + +```typescript +// Using injected configuration service +app.get("/payment", async (req, res) => { + const minAmount = await req.config?.get("PAYMENT_MIN_AMOUNT", "0.01"); + const isFeatureEnabled = await req.config?.isFeatureEnabled("real_time_notifications"); + + // Use configurations... +}); +``` + +### Feature Flag Middleware + +```typescript +import { featureFlagMiddleware } from "../middlewares/configurationMiddleware"; + +// Protect route with feature flag +app.get("/analytics", + featureFlagMiddleware("advanced_analytics"), + (req, res) => { + // Route only accessible if feature flag is enabled + } +); +``` + +## Configuration Categories + +### Database +- `POSTGRES_HOST` +- `POSTGRES_PORT` +- `POSTGRES_USER` +- `POSTGRES_PASSWORD` +- `POSTGRES_DATABASE` + +### Authentication +- `JWT_SECRET` +- `JWT_EXPIRES_IN` +- `AUTH0_CLIENT_ID` +- `AUTH0_CLIENT_SECRET` +- `AUTH0_DOMAIN` + +### Stellar +- `STELLAR_HORIZON_URL` +- `STELLAR_NETWORK_PASSPHRASE` +- `STELLAR_SECRET_KEY` +- `SOROBAN_CONTRACT_ID` + +### Payment +- `PAYMENT_MIN_AMOUNT` +- `PAYMENT_MAX_AMOUNT` +- `PAYMENT_CURRENCIES` +- `PAYMENT_TIMEOUT_MINUTES` + +### Security +- `RATE_LIMIT_WINDOW_MS` +- `RATE_LIMIT_MAX_REQUESTS` +- `SESSION_SECRET` +- `CONFIG_ENCRYPTION_KEY` + +### Email +- `SMTP_HOST` +- `SMTP_PORT` +- `SMTP_USER` +- `SMTP_PASSWORD` +- `FROM_EMAIL` + +### Monitoring +- `NEW_RELIC_LICENSE_KEY` +- `LOG_LEVEL` + +### General +- `APP_NAME` +- `APP_VERSION` +- `BASE_URL` +- `NODE_ENV` + +## Feature Flags + +### Default Feature Flags + +1. **advanced_fraud_detection** + - Description: Enable advanced fraud detection features + - Scope: Global + - Default: Disabled + +2. **real_time_notifications** + - Description: Enable real-time payment notifications + - Scope: Global + - Default: Enabled + +3. **multi_currency_support** + - Description: Enable support for multiple currencies + - Scope: Global + - Default: Enabled + +4. **advanced_analytics** + - Description: Enable advanced analytics dashboard + - Scope: User + - Default: Disabled + +5. **beta_features** + - Description: Enable beta features for testing + - Scope: User + - Default: Disabled + - Targeting: 10% rollout + +## Setup and Initialization + +### 1. Initialize Database +```bash +npm run migration:run +``` + +### 2. Initialize Configurations +```bash +npm run init-config +``` + +This will create default configurations for all environments (development, staging, production). + +### 3. Environment Variables +Ensure these environment variables are set: +```bash +NODE_ENV=development +CONFIG_ENCRYPTION_KEY=your-encryption-key +``` + +## Security Considerations + +### Encryption +- Sensitive configurations are automatically encrypted using AES-256-CBC +- Encryption key is stored in environment variable `CONFIG_ENCRYPTION_KEY` +- Encrypted values are never logged or exposed in API responses + +### Access Control +- All configuration endpoints require authentication +- Role-based access control using RBAC system +- Audit logging for all configuration changes + +### Validation +- Configuration values are validated against allowed values +- Required configurations are checked at startup +- Type validation for different configuration types + +## Best Practices + +### 1. Configuration Naming +- Use descriptive, hierarchical names (e.g., `PAYMENT_MIN_AMOUNT`) +- Use UPPER_CASE for configuration keys +- Group related configurations by category + +### 2. Feature Flags +- Use descriptive names for feature flags +- Document the purpose and impact of each flag +- Set appropriate scopes and targeting rules +- Clean up deprecated feature flags + +### 3. Environment Management +- Use different configurations for each environment +- Never commit sensitive values to version control +- Use environment-specific encryption keys + +### 4. Monitoring +- Monitor configuration changes through audit logs +- Set up alerts for missing required configurations +- Track feature flag usage and impact + +## Troubleshooting + +### Common Issues + +1. **Configuration Not Found** + - Check if configuration exists for current environment + - Verify configuration key spelling + - Check if configuration is active + +2. **Encryption Errors** + - Verify `CONFIG_ENCRYPTION_KEY` is set + - Check if encryption key is consistent across restarts + - Re-encrypt configurations if needed + +3. **Feature Flag Not Working** + - Check if feature flag is enabled + - Verify targeting rules and scope + - Check if user context is provided correctly + +4. **Validation Errors** + - Check configuration value against allowed values + - Verify configuration type matches expected type + - Check if required configurations are missing + +### Debug Commands + +```bash +# Check configuration status +curl -H "Authorization: Bearer " http://localhost:4000/api/config/validate + +# Get configuration statistics +curl -H "Authorization: Bearer " http://localhost:4000/api/config/stats + +# Export configurations for backup +curl -H "Authorization: Bearer " http://localhost:4000/api/config/export +``` + +## Migration from Environment Variables + +To migrate from environment variables to the configuration system: + +1. **Export current environment variables** +2. **Import them using the configuration API** +3. **Update application code to use configuration service** +4. **Remove environment variables gradually** + +Example migration script: +```typescript +const envToConfig = [ + { env: "POSTGRES_HOST", config: "POSTGRES_HOST" }, + { env: "JWT_SECRET", config: "JWT_SECRET", encrypted: true }, + // ... more mappings +]; + +for (const mapping of envToConfig) { + const value = process.env[mapping.env]; + if (value) { + await configurationService.setConfig(mapping.config, value, { + isEncrypted: mapping.encrypted || false, + updatedBy: "migration" + }); + } +} +``` + +## Performance Considerations + +### Caching +- Configuration values are cached in memory +- Cache is invalidated when configurations are updated +- Request-level caching for frequently accessed values + +### Database Optimization +- Indexes on key and environment columns +- Efficient queries for configuration lookups +- Connection pooling for database access + +### Monitoring +- Monitor configuration access patterns +- Track cache hit rates +- Monitor database query performance + +This configuration system provides a robust, secure, and flexible way to manage application configurations across multiple environments while supporting feature flags and dynamic updates. \ No newline at end of file diff --git a/package.json b/package.json index 0aefc50..460b2f6 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "test:coverage": "jest --coverage", "migration:run": "npx typeorm-ts-node-commonjs migration:run -d src/config/db.ts", "validate:openapi": "node scripts/validate-openapi.js", - "init-rbac": "ts-node src/scripts/initializeRBAC.ts" + "init-rbac": "ts-node src/scripts/initializeRBAC.ts", + "init-config": "ts-node src/scripts/initializeConfigurations.ts", + "test-config": "ts-node src/scripts/testConfigurationSystem.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.777.0", diff --git a/src/app.ts b/src/app.ts index 5ef15c6..4bc23ac 100644 --- a/src/app.ts +++ b/src/app.ts @@ -40,6 +40,10 @@ import logger from "./utils/logger"; import { oauthConfig } from "./config/auth0Config"; import { auth } from "express-openid-connect"; import { auditMiddleware } from "./middlewares/auditMiddleware"; +import { + configurationMiddleware, + environmentConfigMiddleware, +} from "./middlewares/configurationMiddleware"; import routes from "./routes"; import intelligentRateLimiter from "./middleware/rateLimiter"; import rateLimitMonitoringService from "./services/rateLimitMonitoring.service"; @@ -122,6 +126,10 @@ try { // Add audit middleware after auth middleware but before routes app.use(auditMiddleware); +// Add configuration middleware +app.use(configurationMiddleware); +app.use(environmentConfigMiddleware); + // Swagger UI setup app.use( "/api-docs", @@ -154,6 +162,10 @@ app.use("/api/v1/stellar", stellarContractRoutes); app.use("/token", tokenRoutes); app.use("/payment", paymentRouter); app.use("/subscriptions", subscriptionRouter); + +// Configuration routes +import configurationRoutes from "./routes/configurationRoutes"; +app.use("/api/config", configurationRoutes); app.use("/api/notifications", notificationRoutes); app.use("/", routes); diff --git a/src/config/db.ts b/src/config/db.ts index cbad86e..5ce3074 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -16,6 +16,8 @@ import { ReferralProgram } from "../entities/ReferralProgram"; import { AuditLog } from "../entities/AuditLog"; import { AuditSubscriber } from "../subscribers/AuditSubscriber"; import { Wallet } from "../entities/Wallet"; +import { Configuration } from "../entities/Configuration"; +import { FeatureFlag } from "../entities/FeatureFlag"; import { Transaction } from "../entities/Transaction"; dotenv.config(); @@ -46,6 +48,8 @@ const AppDataSource = new DataSource({ ReferralProgram, AuditLog, Wallet, + Configuration, + FeatureFlag, Transaction, ], subscribers: [AuditSubscriber], diff --git a/src/controllers/ConfigurationController.ts b/src/controllers/ConfigurationController.ts new file mode 100644 index 0000000..7786d76 --- /dev/null +++ b/src/controllers/ConfigurationController.ts @@ -0,0 +1,513 @@ +import { Request, Response, NextFunction } from "express"; +import { configurationService } from "../services/ConfigurationService"; +import { + ConfigurationCategory, + ConfigurationType, +} from "../entities/Configuration"; +import { FeatureFlagScope } from "../entities/FeatureFlag"; +import { AppError } from "../utils/AppError"; +import logger from "../utils/logger"; +import { validationResult } from "express-validator"; + +export class ConfigurationController { + /** + * Get all configurations for current environment + */ + async getAllConfigurations( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const configs = await configurationService.getAllConfigs(); + + // Filter out encrypted values for security + const sanitizedConfigs = configs.map((config) => ({ + ...config, + value: config.isEncrypted ? "[ENCRYPTED]" : config.value, + })); + + res.json({ + success: true, + data: sanitizedConfigs, + count: sanitizedConfigs.length, + }); + } catch (error) { + next(error); + } + } + + /** + * Get configuration by key + */ + async getConfiguration( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { key } = req.params; + const config = await configurationService.getConfigDetails(key); + + if (!config) { + throw new AppError("Configuration not found", 404); + } + + res.json({ + success: true, + data: { + key: config.configKey, + value: config.isEncrypted ? "[ENCRYPTED]" : config.value, + type: config.type, + category: config.category, + isEncrypted: config.isEncrypted, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Create or update configuration + */ + async setConfiguration( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + message: "Validation failed", + errors: errors.array(), + }); + return; + } + + const { + key, + value, + type = ConfigurationType.STRING, + category = ConfigurationCategory.GENERAL, + description, + isEncrypted = false, + isRequired = false, + validationRules, + defaultValue, + allowedValues, + expiresAt, + metadata, + } = req.body; + + const config = await configurationService.setConfig(key, value, { + type, + category, + description, + isEncrypted, + isRequired, + validationRules, + defaultValue, + allowedValues, + expiresAt: expiresAt ? new Date(expiresAt) : undefined, + metadata, + updatedBy: req.user?.id?.toString(), + }); + + res.status(201).json({ + success: true, + message: "Configuration updated successfully", + data: { + ...config, + value: isEncrypted ? "[ENCRYPTED]" : config.value, + }, + }); + } catch (error) { + next(error); + } + } + + /** + * Delete configuration + */ + async deleteConfiguration( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { key } = req.params; + + await configurationService.deleteConfig(key, req.user?.id?.toString()); + + res.json({ + success: true, + message: "Configuration deleted successfully", + }); + } catch (error) { + next(error); + } + } + + /** + * Get configurations by category + */ + async getConfigurationsByCategory( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { category } = req.params; + + if ( + !Object.values(ConfigurationCategory).includes( + category as ConfigurationCategory, + ) + ) { + throw new AppError("Invalid category", 400); + } + + const configs = await configurationService.getConfigsByCategory( + category as ConfigurationCategory, + ); + + // Filter out encrypted values for security + const sanitizedConfigs = configs.map((config) => ({ + ...config, + value: config.isEncrypted ? "[ENCRYPTED]" : config.value, + })); + + res.json({ + success: true, + data: sanitizedConfigs, + count: sanitizedConfigs.length, + }); + } catch (error) { + next(error); + } + } + + /** + * Reload configurations from database + */ + async reloadConfigurations( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + await configurationService.reloadConfigurations(); + + res.json({ + success: true, + message: "Configurations reloaded successfully", + }); + } catch (error) { + next(error); + } + } + + /** + * Validate configurations + */ + async validateConfigurations( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + // Get all configurations and validate required ones + const allConfigs = await configurationService.getAllConfigs(); + const requiredConfigs = allConfigs.filter((config) => config.isRequired); + const missingRequired = requiredConfigs.filter( + (config) => !config.value || config.value.trim() === "", + ); + + const validationResult = { + isValid: missingRequired.length === 0, + errors: missingRequired.map( + (config) => + `Required configuration missing or empty: ${config.configKey}`, + ), + warnings: [], + }; + + res.json({ + success: true, + data: validationResult, + }); + } catch (error) { + next(error); + } + } + + // Feature Flag Methods + + /** + * Get all feature flags + */ + async getAllFeatureFlags( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const flags = await configurationService.getAllFeatureFlags(); + + res.json({ + success: true, + data: flags, + count: flags.length, + }); + } catch (error) { + next(error); + } + } + + /** + * Create or update feature flag + */ + async setFeatureFlag( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const errors = validationResult(req); + if (!errors.isEmpty()) { + res.status(400).json({ + success: false, + message: "Validation failed", + errors: errors.array(), + }); + return; + } + + const { + name, + description, + isEnabled, + scope = FeatureFlagScope.GLOBAL, + targetingRules, + scheduledStartDate, + scheduledEndDate, + metadata, + owner, + tags, + } = req.body; + + const flag = await configurationService.setFeatureFlag( + name, + description, + isEnabled, + { + scope, + targetingRules, + scheduledStartDate: scheduledStartDate + ? new Date(scheduledStartDate) + : undefined, + scheduledEndDate: scheduledEndDate + ? new Date(scheduledEndDate) + : undefined, + metadata, + owner, + tags, + updatedBy: req.user?.id?.toString(), + }, + ); + + res.status(201).json({ + success: true, + message: "Feature flag updated successfully", + data: flag, + }); + } catch (error) { + next(error); + } + } + + /** + * Evaluate feature flag + */ + async evaluateFeatureFlag( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { flagName } = req.params; + const { userId, merchantId, userRole } = req.query; + + const evaluation = await configurationService.evaluateFeatureFlag( + flagName, + { + userId: userId as string, + merchantId: merchantId as string, + userRole: userRole as string, + }, + ); + + res.json({ + success: true, + data: evaluation, + }); + } catch (error) { + next(error); + } + } + + /** + * Get configuration statistics + */ + async getConfigurationStats( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const configs = await configurationService.getAllConfigs(); + const flags = await configurationService.getAllFeatureFlags(); + + const stats = { + totalConfigurations: configs.length, + encryptedConfigurations: configs.filter((c) => c.isEncrypted).length, + requiredConfigurations: configs.filter((c) => c.isRequired).length, + configurationsByCategory: Object.values(ConfigurationCategory).reduce( + (acc, category) => { + acc[category] = configs.filter( + (c) => c.category === category, + ).length; + return acc; + }, + {} as Record, + ), + totalFeatureFlags: flags.length, + activeFeatureFlags: flags.filter((f) => f.isEnabled).length, + featureFlagsByScope: Object.values(FeatureFlagScope).reduce( + (acc, scope) => { + acc[scope] = flags.filter((f) => f.scope === scope).length; + return acc; + }, + {} as Record, + ), + }; + + res.json({ + success: true, + data: stats, + }); + } catch (error) { + next(error); + } + } + + /** + * Export configurations + */ + async exportConfigurations( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { environment } = req.query; + const configs = await configurationService.getAllConfigs(); + + const exportData = { + environment: environment || process.env.NODE_ENV, + exportedAt: new Date().toISOString(), + configurations: configs.map((config) => ({ + key: config.configKey, + value: config.isEncrypted ? "[ENCRYPTED]" : config.value, + type: config.type, + category: config.category, + description: config.description, + isEncrypted: config.isEncrypted, + isRequired: config.isRequired, + validationRules: config.validationRules, + defaultValue: config.defaultValue, + allowedValues: config.allowedValues, + expiresAt: config.expiresAt, + metadata: config.metadata, + })), + }; + + res.setHeader("Content-Type", "application/json"); + res.setHeader( + "Content-Disposition", + `attachment; filename="configurations-${environment || "current"}.json"`, + ); + res.json(exportData); + } catch (error) { + next(error); + } + } + + /** + * Import configurations + */ + async importConfigurations( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + const { configurations, overwrite = false } = req.body; + + if (!Array.isArray(configurations)) { + throw new AppError("Invalid configurations format", 400); + } + + let imported = 0; + let skipped = 0; + let errors = 0; + + for (const config of configurations) { + try { + const existing = await configurationService.getConfig(config.key); + + if (existing !== null && !overwrite) { + skipped++; + continue; + } + + await configurationService.setConfig(config.key, config.value, { + type: config.type, + category: config.category, + description: config.description, + isEncrypted: config.isEncrypted, + isRequired: config.isRequired, + validationRules: config.validationRules, + defaultValue: config.defaultValue, + allowedValues: config.allowedValues, + expiresAt: config.expiresAt + ? new Date(config.expiresAt) + : undefined, + metadata: config.metadata, + updatedBy: req.user?.id?.toString(), + }); + + imported++; + } catch (error) { + logger.error(`Failed to import configuration ${config.key}:`, error); + errors++; + } + } + + res.json({ + success: true, + message: "Configuration import completed", + data: { + imported, + skipped, + errors, + }, + }); + } catch (error) { + next(error); + } + } +} diff --git a/src/entities/Configuration.ts b/src/entities/Configuration.ts new file mode 100644 index 0000000..3ebab39 --- /dev/null +++ b/src/entities/Configuration.ts @@ -0,0 +1,119 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; +import { IsNotEmpty, IsEnum, IsOptional } from "class-validator"; + +export enum Environment { + DEVELOPMENT = "development", + STAGING = "staging", + PRODUCTION = "production", + TEST = "test", +} + +export enum ConfigurationType { + STRING = "string", + NUMBER = "number", + BOOLEAN = "boolean", + JSON = "json", + ENCRYPTED = "encrypted", +} + +export enum ConfigurationCategory { + DATABASE = "database", + AUTHENTICATION = "authentication", + PAYMENT = "payment", + STELLAR = "stellar", + EMAIL = "email", + REDIS = "redis", + FEATURE_FLAG = "feature_flag", + SECURITY = "security", + MONITORING = "monitoring", + GENERAL = "general", +} + +@Entity("configurations") +@Index(["configKey", "environment"], { unique: true }) +@Index(["category", "environment"]) +export class Configuration { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ length: 255 }) + @IsNotEmpty() + configKey: string; + + @Column({ type: "text" }) + @IsNotEmpty() + value: string; + + @Column({ + type: "enum", + enum: Environment, + default: Environment.DEVELOPMENT, + }) + @IsEnum(Environment) + environment: Environment; + + @Column({ + type: "enum", + enum: ConfigurationType, + default: ConfigurationType.STRING, + }) + @IsEnum(ConfigurationType) + type: ConfigurationType; + + @Column({ + type: "enum", + enum: ConfigurationCategory, + default: ConfigurationCategory.GENERAL, + }) + @IsEnum(ConfigurationCategory) + category: ConfigurationCategory; + + @Column({ type: "text", nullable: true }) + @IsOptional() + description?: string; + + @Column({ default: false }) + isEncrypted: boolean; + + @Column({ default: true }) + isActive: boolean; + + @Column({ default: false }) + isRequired: boolean; + + @Column({ type: "text", nullable: true }) + @IsOptional() + validationRules?: string; // JSON string for validation rules + + @Column({ type: "text", nullable: true }) + @IsOptional() + defaultValue?: string; + + @Column({ type: "text", nullable: true }) + @IsOptional() + allowedValues?: string; // JSON string for enum values + + @Column({ type: "timestamp", nullable: true }) + @IsOptional() + expiresAt?: Date; + + @Column({ type: "text", nullable: true }) + @IsOptional() + metadata?: string; // JSON string for additional metadata + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ nullable: true }) + updatedBy?: string; // User ID who last updated this configuration +} diff --git a/src/entities/FeatureFlag.ts b/src/entities/FeatureFlag.ts new file mode 100644 index 0000000..0cdba15 --- /dev/null +++ b/src/entities/FeatureFlag.ts @@ -0,0 +1,107 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from "typeorm"; +import { IsNotEmpty, IsEnum, IsOptional, IsBoolean } from "class-validator"; +import { Environment } from "./Configuration"; + +export enum FeatureFlagScope { + GLOBAL = "global", + USER = "user", + MERCHANT = "merchant", + ENVIRONMENT = "environment", +} + +export enum FeatureFlagStatus { + ACTIVE = "active", + INACTIVE = "inactive", + SCHEDULED = "scheduled", + DEPRECATED = "deprecated", +} + +@Entity("feature_flags") +@Index(["name", "environment"], { unique: true }) +@Index(["status", "environment"]) +export class FeatureFlag { + @PrimaryGeneratedColumn("uuid") + id: string; + + @Column({ length: 255 }) + @IsNotEmpty() + name: string; + + @Column({ type: "text" }) + @IsNotEmpty() + description: string; + + @Column({ default: false }) + @IsBoolean() + isEnabled: boolean; + + @Column({ + type: "enum", + enum: Environment, + default: Environment.DEVELOPMENT, + }) + @IsEnum(Environment) + environment: Environment; + + @Column({ + type: "enum", + enum: FeatureFlagScope, + default: FeatureFlagScope.GLOBAL, + }) + @IsEnum(FeatureFlagScope) + scope: FeatureFlagScope; + + @Column({ + type: "enum", + enum: FeatureFlagStatus, + default: FeatureFlagStatus.ACTIVE, + }) + @IsEnum(FeatureFlagStatus) + status: FeatureFlagStatus; + + @Column({ type: "jsonb", nullable: true }) + @IsOptional() + targetingRules?: { + userIds?: string[]; + merchantIds?: string[]; + userRoles?: string[]; + percentage?: number; // For percentage-based rollouts (0-100) + conditions?: Record; + }; + + @Column({ type: "timestamp", nullable: true }) + @IsOptional() + scheduledStartDate?: Date; + + @Column({ type: "timestamp", nullable: true }) + @IsOptional() + scheduledEndDate?: Date; + + @Column({ type: "jsonb", nullable: true }) + @IsOptional() + metadata?: Record; + + @Column({ type: "text", nullable: true }) + @IsOptional() + owner?: string; // User ID who owns this feature flag + + @Column({ type: "text", nullable: true }) + @IsOptional() + tags?: string; // Comma-separated tags + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ nullable: true }) + updatedBy?: string; // User ID who last updated this feature flag +} diff --git a/src/entities/Permission.ts b/src/entities/Permission.ts index a2a3f4f..cda8c03 100644 --- a/src/entities/Permission.ts +++ b/src/entities/Permission.ts @@ -30,6 +30,7 @@ export enum PermissionResource { SUBSCRIPTIONS = "subscriptions", FRAUD_DETECTION = "fraud_detection", ROLES = "roles", // New dedicated roles resource + CONFIGURATION = "configuration", } @Entity("permissions") diff --git a/src/index.ts b/src/index.ts index 660986e..9d16041 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import "reflect-metadata"; import app from "./app"; import AppDataSource from "./config/db"; +import { configurationService } from "./services/ConfigurationService"; import adaptiveRateLimitService from "./services/adaptiveRateLimitService"; async function main() { @@ -8,7 +9,14 @@ async function main() { // Initialize the database connection await AppDataSource.initialize(); console.log("āœ… Database connected successfully"); + + // Initialize configuration service + await configurationService.initialize(); + console.log("āœ… Configuration service initialized successfully"); + + // Start adaptive rate limit adjustment adaptiveRateLimitService.startAdjustment(); + // Start the server const PORT = process.env.PORT || 4000; const server = app.listen(PORT, () => { @@ -35,7 +43,7 @@ async function main() { } else { console.error(error); } - process.exit(1); // Exit the process if the database fails to initialize + process.exit(1); } } @@ -45,7 +53,6 @@ process.on("uncaughtException", (error) => { process.exit(1); }); -// Handle unhandled promise rejections process.on("unhandledRejection", (reason, promise) => { console.error("āŒ Unhandled Rejection at:", promise, "reason:", reason); process.exit(1); diff --git a/src/middlewares/configurationMiddleware.ts b/src/middlewares/configurationMiddleware.ts new file mode 100644 index 0000000..65aa958 --- /dev/null +++ b/src/middlewares/configurationMiddleware.ts @@ -0,0 +1,196 @@ +import { Request, Response, NextFunction } from "express"; +import { configurationService } from "../services/ConfigurationService"; +import logger from "../utils/logger"; + +// Configuration middleware interfaces are now in src/types/express.d.ts + +/** + * Middleware to inject configuration service into request object + */ +export const configurationMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + // Inject configuration service into request + req.config = { + get: async (key: string, defaultValue?: string) => { + return await configurationService.getConfig(key, defaultValue); + }, + isFeatureEnabled: async ( + flagName: string, + context?: { + userId?: string; + merchantId?: string; + userRole?: string; + }, + ) => { + const evaluation = await configurationService.evaluateFeatureFlag( + flagName, + context, + ); + return evaluation.isEnabled; + }, + }; + + next(); + } catch (error) { + logger.error("Configuration middleware error:", error); + next(error); + } +}; + +/** + * Middleware to validate required configurations before processing request + */ +export const validateRequiredConfigMiddleware = (requiredConfigs: string[]) => { + return async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const missingConfigs: string[] = []; + + for (const configKey of requiredConfigs) { + const value = await configurationService.getConfig(configKey); + if (value === null || value === undefined) { + missingConfigs.push(configKey); + } + } + + if (missingConfigs.length > 0) { + res.status(500).json({ + success: false, + message: "Required configurations are missing", + missingConfigs, + }); + return; + } + + next(); + } catch (error) { + logger.error("Required config validation error:", error); + res.status(500).json({ + success: false, + message: "Configuration validation failed", + }); + } + }; +}; + +/** + * Middleware to check feature flag before processing request + */ +export const featureFlagMiddleware = (flagName: string) => { + return async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const context = { + userId: req.user?.id, + merchantId: req.merchant?.id, + userRole: req.user?.role, + }; + + const evaluation = await configurationService.evaluateFeatureFlag( + flagName, + { + userId: context.userId?.toString(), + merchantId: context.merchantId, + userRole: context.userRole?.toString(), + }, + ); + + if (!evaluation.isEnabled) { + res.status(403).json({ + success: false, + message: "Feature is not enabled", + reason: evaluation.reason, + }); + return; + } + + next(); + } catch (error) { + logger.error(`Feature flag middleware error for ${flagName}:`, error); + res.status(500).json({ + success: false, + message: "Feature flag evaluation failed", + }); + } + }; +}; + +/** + * Middleware to inject environment-specific configurations + */ +export const environmentConfigMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + // Inject environment-specific configurations + const environment = process.env.NODE_ENV || "development"; + + // Add environment info to request + req.environment = environment; + + // Load common configurations + const commonConfigs = await Promise.all([ + configurationService.getConfig("APP_NAME", "PayStell"), + configurationService.getConfig("APP_VERSION", "1.0.0"), + configurationService.getConfig("ENVIRONMENT", environment), + ]); + + req.appConfig = { + name: commonConfigs[0] as string, + version: commonConfigs[1] as string, + environment: commonConfigs[2] as string, + }; + + next(); + } catch (error) { + logger.error("Environment config middleware error:", error); + next(error); + } +}; + +/** + * Middleware to cache configuration values for the duration of the request + */ +export const configCacheMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +): Promise => { + try { + // Create a request-specific cache + const requestCache = new Map(); + + // Extend the config object with caching + if (req.config) { + const originalGet = req.config.get; + req.config.get = async (key: string, defaultValue?: string) => { + // Check request cache first + if (requestCache.has(key)) { + return requestCache.get(key); + } + + // Get from service and cache + const value = await originalGet(key, defaultValue); + requestCache.set(key, value); + return value; + }; + } + + next(); + } catch (error) { + logger.error("Config cache middleware error:", error); + next(error); + } +}; diff --git a/src/routes/configurationRoutes.ts b/src/routes/configurationRoutes.ts new file mode 100644 index 0000000..05e3988 --- /dev/null +++ b/src/routes/configurationRoutes.ts @@ -0,0 +1,857 @@ +import { Router, RequestHandler } from "express"; +import { body, param, query } from "express-validator"; +import { ConfigurationController } from "../controllers/ConfigurationController"; +import { authMiddleware } from "../middlewares/authMiddleware"; +import { requirePermission } from "../middlewares/permissionMiddleware"; +import { PermissionResource, PermissionAction } from "../entities/Permission"; +import { handleValidationErrors } from "../middlewares/validationErrorHandler"; +import { + ConfigurationCategory, + ConfigurationType, +} from "../entities/Configuration"; +import { FeatureFlagScope } from "../entities/FeatureFlag"; + +const router = Router(); +const configurationController = new ConfigurationController(); + +// Validation schemas +const configurationValidation = [ + body("key") + .isString() + .isLength({ min: 1, max: 255 }) + .withMessage("Key is required and must be 1-255 characters"), + body("value").notEmpty().withMessage("Value is required"), + body("type") + .optional() + .isIn(Object.values(ConfigurationType)) + .withMessage("Invalid configuration type"), + body("category") + .optional() + .isIn(Object.values(ConfigurationCategory)) + .withMessage("Invalid configuration category"), + body("description") + .optional() + .isString() + .withMessage("Description must be a string"), + body("isEncrypted") + .optional() + .isBoolean() + .withMessage("isEncrypted must be a boolean"), + body("isRequired") + .optional() + .isBoolean() + .withMessage("isRequired must be a boolean"), + body("validationRules") + .optional() + .isObject() + .withMessage("validationRules must be an object"), + body("defaultValue") + .optional() + .isString() + .withMessage("defaultValue must be a string"), + body("allowedValues") + .optional() + .isArray() + .withMessage("allowedValues must be an array"), + body("expiresAt") + .optional() + .isISO8601() + .withMessage("expiresAt must be a valid ISO date"), + body("metadata") + .optional() + .isObject() + .withMessage("metadata must be an object"), +]; + +const featureFlagValidation = [ + body("name") + .isString() + .isLength({ min: 1, max: 255 }) + .withMessage("Name is required and must be 1-255 characters"), + body("description") + .isString() + .isLength({ min: 1 }) + .withMessage("Description is required"), + body("isEnabled").isBoolean().withMessage("isEnabled must be a boolean"), + body("scope") + .optional() + .isIn(Object.values(FeatureFlagScope)) + .withMessage("Invalid feature flag scope"), + body("targetingRules") + .optional() + .isObject() + .withMessage("targetingRules must be an object"), + body("scheduledStartDate") + .optional() + .isISO8601() + .withMessage("scheduledStartDate must be a valid ISO date"), + body("scheduledEndDate") + .optional() + .isISO8601() + .withMessage("scheduledEndDate must be a valid ISO date"), + body("metadata") + .optional() + .isObject() + .withMessage("metadata must be an object"), + body("owner").optional().isString().withMessage("owner must be a string"), + body("tags").optional().isString().withMessage("tags must be a string"), +]; + +const importValidation = [ + body("configurations") + .isArray() + .withMessage("configurations must be an array"), + body("overwrite") + .optional() + .isBoolean() + .withMessage("overwrite must be a boolean"), + body("confirmOverwrite") + .optional() + .isString() + .custom((value, { req }) => { + if (req.body.overwrite && process.env.NODE_ENV === "production") { + return ( + value === + `CONFIRM_OVERWRITE_${new Date().toISOString().split("T")[0]}` + ); + } + return true; + }) + .withMessage( + "Production overwrites require confirmation with today's date", + ), +]; + +/** + * @swagger + * /api/config: + * get: + * summary: Get all configurations + * description: Retrieve all configurations for the current environment + * tags: [Configuration] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Configurations retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * $ref: '#/components/schemas/Configuration' + * count: + * type: number + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + configurationController.getAllConfigurations.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/{key}: + * get: + * summary: Get configuration by key + * description: Retrieve a specific configuration by its key + * tags: [Configuration] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: key + * required: true + * schema: + * type: string + * description: Configuration key + * responses: + * 200: + * description: Configuration retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * key: + * type: string + * value: + * oneOf: + * - type: string + * - type: number + * - type: boolean + * - type: object + * 404: + * description: Configuration not found + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/:key", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + param("key").isString().withMessage("Key must be a string"), + handleValidationErrors, + configurationController.getConfiguration.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config: + * post: + * summary: Create or update configuration + * description: Create a new configuration or update an existing one + * tags: [Configuration] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - key + * - value + * properties: + * key: + * type: string + * description: Configuration key + * value: + * oneOf: + * - type: string + * - type: number + * - type: boolean + * - type: object + * description: Configuration value + * type: + * type: string + * enum: [string, number, boolean, json, encrypted] + * description: Configuration type + * category: + * type: string + * enum: [database, authentication, payment, stellar, email, redis, feature_flag, security, monitoring, general] + * description: Configuration category + * description: + * type: string + * description: Configuration description + * isEncrypted: + * type: boolean + * description: Whether the value should be encrypted + * isRequired: + * type: boolean + * description: Whether this configuration is required + * validationRules: + * type: object + * description: Validation rules for the configuration + * defaultValue: + * type: string + * description: Default value for the configuration + * allowedValues: + * type: array + * items: + * type: string + * description: Allowed values for the configuration + * expiresAt: + * type: string + * format: date-time + * description: Expiration date for the configuration + * metadata: + * type: object + * description: Additional metadata + * responses: + * 201: + * description: Configuration created/updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * data: + * $ref: '#/components/schemas/Configuration' + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.post( + "/", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.CREATE), + configurationValidation, + handleValidationErrors, + configurationController.setConfiguration.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/{key}: + * delete: + * summary: Delete configuration + * description: Delete a configuration by its key + * tags: [Configuration] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: key + * required: true + * schema: + * type: string + * description: Configuration key + * responses: + * 200: + * description: Configuration deleted successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 404: + * description: Configuration not found + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.delete( + "/:key", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.DELETE), + param("key").isString().withMessage("Key must be a string"), + handleValidationErrors, + configurationController.deleteConfiguration.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/category/{category}: + * get: + * summary: Get configurations by category + * description: Retrieve all configurations for a specific category + * tags: [Configuration] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: category + * required: true + * schema: + * type: string + * enum: [database, authentication, payment, stellar, email, redis, feature_flag, security, monitoring, general] + * description: Configuration category + * responses: + * 200: + * description: Configurations retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * $ref: '#/components/schemas/Configuration' + * count: + * type: number + * 400: + * description: Invalid category + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/category/:category", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + param("category") + .isIn(Object.values(ConfigurationCategory)) + .withMessage("Invalid category"), + handleValidationErrors, + configurationController.getConfigurationsByCategory.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/reload: + * post: + * summary: Reload configurations + * description: Reload all configurations from the database + * tags: [Configuration] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Configurations reloaded successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.post( + "/reload", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.UPDATE), + configurationController.reloadConfigurations.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/validate: + * get: + * summary: Validate configurations + * description: Validate all required configurations + * tags: [Configuration] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Validation completed + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * isValid: + * type: boolean + * errors: + * type: array + * items: + * type: string + * warnings: + * type: array + * items: + * type: string + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/validate", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + configurationController.validateConfigurations.bind( + configurationController, + ) as RequestHandler, +); + +// Feature Flag Routes + +/** + * @swagger + * /api/config/feature-flags: + * get: + * summary: Get all feature flags + * description: Retrieve all feature flags for the current environment + * tags: [Feature Flags] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Feature flags retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: array + * items: + * $ref: '#/components/schemas/FeatureFlag' + * count: + * type: number + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/feature-flags", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + configurationController.getAllFeatureFlags.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/feature-flags: + * post: + * summary: Create or update feature flag + * description: Create a new feature flag or update an existing one + * tags: [Feature Flags] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - name + * - description + * - isEnabled + * properties: + * name: + * type: string + * description: Feature flag name + * description: + * type: string + * description: Feature flag description + * isEnabled: + * type: boolean + * description: Whether the feature flag is enabled + * scope: + * type: string + * enum: [global, user, merchant, environment] + * description: Feature flag scope + * targetingRules: + * type: object + * description: Targeting rules for the feature flag + * scheduledStartDate: + * type: string + * format: date-time + * description: Scheduled start date + * scheduledEndDate: + * type: string + * format: date-time + * description: Scheduled end date + * metadata: + * type: object + * description: Additional metadata + * owner: + * type: string + * description: Feature flag owner + * tags: + * type: string + * description: Comma-separated tags + * responses: + * 201: + * description: Feature flag created/updated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * data: + * $ref: '#/components/schemas/FeatureFlag' + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.post( + "/feature-flags", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.CREATE), + featureFlagValidation, + handleValidationErrors, + configurationController.setFeatureFlag.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/feature-flags/{flagName}/evaluate: + * get: + * summary: Evaluate feature flag + * description: Evaluate a feature flag for a specific context + * tags: [Feature Flags] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: flagName + * required: true + * schema: + * type: string + * description: Feature flag name + * - in: query + * name: userId + * schema: + * type: string + * description: User ID for evaluation + * - in: query + * name: merchantId + * schema: + * type: string + * description: Merchant ID for evaluation + * - in: query + * name: userRole + * schema: + * type: string + * description: User role for evaluation + * responses: + * 200: + * description: Feature flag evaluated successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * isEnabled: + * type: boolean + * reason: + * type: string + * targetingMatch: + * type: boolean + * percentageRollout: + * type: number + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/feature-flags/:flagName/evaluate", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + param("flagName").isString().withMessage("Flag name must be a string"), + handleValidationErrors, + configurationController.evaluateFeatureFlag.bind( + configurationController, + ) as RequestHandler, +); + +// Utility Routes + +/** + * @swagger + * /api/config/stats: + * get: + * summary: Get configuration statistics + * description: Get statistics about configurations and feature flags + * tags: [Configuration] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Statistics retrieved successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * data: + * type: object + * properties: + * totalConfigurations: + * type: number + * encryptedConfigurations: + * type: number + * requiredConfigurations: + * type: number + * configurationsByCategory: + * type: object + * totalFeatureFlags: + * type: number + * activeFeatureFlags: + * type: number + * featureFlagsByScope: + * type: object + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/stats", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + configurationController.getConfigurationStats.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/export: + * get: + * summary: Export configurations + * description: Export all configurations for the current environment + * tags: [Configuration] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: environment + * schema: + * type: string + * description: Environment to export (optional) + * responses: + * 200: + * description: Configurations exported successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * environment: + * type: string + * exportedAt: + * type: string + * format: date-time + * configurations: + * type: array + * items: + * type: object + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.get( + "/export", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.READ), + query("environment") + .optional() + .isString() + .withMessage("Environment must be a string"), + handleValidationErrors, + configurationController.exportConfigurations.bind( + configurationController, + ) as RequestHandler, +); + +/** + * @swagger + * /api/config/import: + * post: + * summary: Import configurations + * description: Import configurations from a JSON file + * tags: [Configuration] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - configurations + * properties: + * configurations: + * type: array + * items: + * type: object + * description: Array of configuration objects + * overwrite: + * type: boolean + * description: Whether to overwrite existing configurations + * responses: + * 200: + * description: Configurations imported successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * data: + * type: object + * properties: + * imported: + * type: number + * skipped: + * type: number + * errors: + * type: number + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +router.post( + "/import", + authMiddleware as RequestHandler, + requirePermission(PermissionResource.CONFIGURATION, PermissionAction.CREATE), + importValidation, + handleValidationErrors, + configurationController.importConfigurations.bind( + configurationController, + ) as RequestHandler, +); + +export default router; diff --git a/src/scripts/initializeConfigurations.ts b/src/scripts/initializeConfigurations.ts new file mode 100644 index 0000000..8ab4455 --- /dev/null +++ b/src/scripts/initializeConfigurations.ts @@ -0,0 +1,480 @@ +import "reflect-metadata"; +import dotenv from "dotenv"; +import { configurationService } from "../services/ConfigurationService"; +import { + ConfigurationCategory, + ConfigurationType, + Environment, +} from "../entities/Configuration"; +import { FeatureFlagScope } from "../entities/FeatureFlag"; +import AppDataSource from "../config/db"; +import logger from "../utils/logger"; + +dotenv.config(); + +const defaultConfigurations = [ + // Database configurations + { + key: "POSTGRES_HOST", + value: "localhost", + type: ConfigurationType.STRING, + category: ConfigurationCategory.DATABASE, + description: "PostgreSQL database host", + isRequired: true, + }, + { + key: "POSTGRES_PORT", + value: "5432", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.DATABASE, + description: "PostgreSQL database port", + isRequired: true, + }, + { + key: "POSTGRES_USER", + value: "postgres", + type: ConfigurationType.STRING, + category: ConfigurationCategory.DATABASE, + description: "PostgreSQL database user", + isRequired: true, + }, + { + key: "POSTGRES_PASSWORD", + value: process.env.POSTGRES_PASSWORD || "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.DATABASE, + description: "PostgreSQL database password", + isRequired: true, + isEncrypted: true, + }, + { + key: "POSTGRES_DATABASE", + value: "paystell", + type: ConfigurationType.STRING, + category: ConfigurationCategory.DATABASE, + description: "PostgreSQL database name", + isRequired: true, + }, + + // Authentication configurations + { + key: "JWT_SECRET", + value: + process.env.JWT_SECRET || + "your-super-secret-jwt-key-change-in-production", + type: ConfigurationType.STRING, + category: ConfigurationCategory.AUTHENTICATION, + description: "JWT secret key for token signing", + isRequired: true, + isEncrypted: true, + }, + { + key: "JWT_EXPIRES_IN", + value: "24h", + type: ConfigurationType.STRING, + category: ConfigurationCategory.AUTHENTICATION, + description: "JWT token expiration time", + isRequired: true, + allowedValues: ["15m", "1h", "24h", "7d"], + }, + { + key: "AUTH0_CLIENT_ID", + value: "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.AUTHENTICATION, + description: "Auth0 client ID", + isRequired: false, + }, + { + key: "AUTH0_CLIENT_SECRET", + value: process.env.AUTH0_CLIENT_SECRET || "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.AUTHENTICATION, + description: "Auth0 client secret", + isRequired: false, + isEncrypted: true, + }, + { + key: "AUTH0_DOMAIN", + value: "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.AUTHENTICATION, + description: "Auth0 domain", + isRequired: false, + }, + + // Stellar configurations + { + key: "STELLAR_HORIZON_URL", + value: "https://horizon-testnet.stellar.org", + type: ConfigurationType.STRING, + category: ConfigurationCategory.STELLAR, + description: "Stellar Horizon server URL", + isRequired: true, + allowedValues: [ + "https://horizon-testnet.stellar.org", + "https://horizon.stellar.org", + "https://horizon-futurenet.stellar.org", + ], + }, + { + key: "STELLAR_NETWORK_PASSPHRASE", + value: "Test SDF Network ; September 2015", + type: ConfigurationType.STRING, + category: ConfigurationCategory.STELLAR, + description: "Stellar network passphrase", + isRequired: true, + allowedValues: [ + "Test SDF Network ; September 2015", + "Public Global Stellar Network ; September 2015", + "Test SDF Future Network ; October 2022", + ], + }, + { + key: "STELLAR_SECRET_KEY", + value: "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.STELLAR, + description: "Stellar secret key for operations", + isRequired: false, + isEncrypted: true, + }, + { + key: "SOROBAN_CONTRACT_ID", + value: "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.STELLAR, + description: "Soroban contract ID for payments", + isRequired: false, + }, + + // Redis configurations + { + key: "REDIS_HOST", + value: "localhost", + type: ConfigurationType.STRING, + category: ConfigurationCategory.REDIS, + description: "Redis server host", + isRequired: true, + }, + { + key: "REDIS_PORT", + value: "6379", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.REDIS, + description: "Redis server port", + isRequired: true, + }, + { + key: "REDIS_PASSWORD", + value: process.env.REDIS_PASSWORD || "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.REDIS, + description: "Redis server password", + isRequired: false, + isEncrypted: true, + }, + + // Email configurations + { + key: "SMTP_HOST", + value: "smtp.gmail.com", + type: ConfigurationType.STRING, + category: ConfigurationCategory.EMAIL, + description: "SMTP server host", + isRequired: true, + }, + { + key: "SMTP_PORT", + value: "587", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.EMAIL, + description: "SMTP server port", + isRequired: true, + }, + { + key: "SMTP_USER", + value: "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.EMAIL, + description: "SMTP username", + isRequired: true, + }, + { + key: "SMTP_PASSWORD", + value: process.env.SMTP_PASSWORD || "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.EMAIL, + description: "SMTP password", + isRequired: true, + isEncrypted: true, + }, + { + key: "FROM_EMAIL", + value: "noreply@paystell.com", + type: ConfigurationType.STRING, + category: ConfigurationCategory.EMAIL, + description: "Default sender email address", + isRequired: true, + }, + + // Payment configurations + { + key: "PAYMENT_MIN_AMOUNT", + value: "0.01", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.PAYMENT, + description: "Minimum payment amount", + isRequired: true, + }, + { + key: "PAYMENT_MAX_AMOUNT", + value: "10000", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.PAYMENT, + description: "Maximum payment amount", + isRequired: true, + }, + { + key: "PAYMENT_CURRENCIES", + value: '["XLM", "USD", "EUR"]', + type: ConfigurationType.JSON, + category: ConfigurationCategory.PAYMENT, + description: "Supported payment currencies", + isRequired: true, + }, + { + key: "PAYMENT_TIMEOUT_MINUTES", + value: "30", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.PAYMENT, + description: "Payment timeout in minutes", + isRequired: true, + }, + + // Security configurations + { + key: "RATE_LIMIT_WINDOW_MS", + value: "900000", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.SECURITY, + description: "Rate limiting window in milliseconds", + isRequired: true, + }, + { + key: "RATE_LIMIT_MAX_REQUESTS", + value: "100", + type: ConfigurationType.NUMBER, + category: ConfigurationCategory.SECURITY, + description: "Maximum requests per window", + isRequired: true, + }, + { + key: "SESSION_SECRET", + value: + process.env.SESSION_SECRET || + "a-long-randomly-generated-string-change-in-production", + type: ConfigurationType.STRING, + category: ConfigurationCategory.SECURITY, + description: "Session secret for cookie signing", + isRequired: true, + isEncrypted: true, + }, + { + key: "CONFIG_ENCRYPTION_KEY", + value: + process.env.CONFIG_ENCRYPTION_KEY || + "your-config-encryption-key-change-in-production", + type: ConfigurationType.STRING, + category: ConfigurationCategory.SECURITY, + description: "Encryption key for sensitive configurations", + isRequired: true, + isEncrypted: true, + }, + + // Monitoring configurations + { + key: "NEW_RELIC_LICENSE_KEY", + value: "", + type: ConfigurationType.STRING, + category: ConfigurationCategory.MONITORING, + description: "New Relic license key", + isRequired: false, + isEncrypted: true, + }, + { + key: "LOG_LEVEL", + value: "info", + type: ConfigurationType.STRING, + category: ConfigurationCategory.MONITORING, + description: "Application log level", + isRequired: true, + allowedValues: ["error", "warn", "info", "debug"], + }, + + // General configurations + { + key: "APP_NAME", + value: "PayStell", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Application name", + isRequired: true, + }, + { + key: "APP_VERSION", + value: "1.0.0", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Application version", + isRequired: true, + }, + { + key: "BASE_URL", + value: "http://localhost:4000", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Application base URL", + isRequired: true, + }, + { + key: "NODE_ENV", + value: "development", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Node.js environment", + isRequired: true, + allowedValues: ["development", "staging", "production", "test"], + }, +]; + +const defaultFeatureFlags = [ + { + name: "advanced_fraud_detection", + description: "Enable advanced fraud detection features", + isEnabled: false, + scope: FeatureFlagScope.GLOBAL, + }, + { + name: "real_time_notifications", + description: "Enable real-time payment notifications", + isEnabled: true, + scope: FeatureFlagScope.GLOBAL, + }, + { + name: "multi_currency_support", + description: "Enable support for multiple currencies", + isEnabled: true, + scope: FeatureFlagScope.GLOBAL, + }, + { + name: "advanced_analytics", + description: "Enable advanced analytics dashboard", + isEnabled: false, + scope: FeatureFlagScope.USER, + }, + { + name: "beta_features", + description: "Enable beta features for testing", + isEnabled: false, + scope: FeatureFlagScope.USER, + targetingRules: { + percentage: 10, // 10% rollout + }, + }, +]; + +async function initializeConfigurations() { + try { + logger.info("Starting configuration initialization..."); + + // Initialize database connection + await AppDataSource.initialize(); + logger.info("Database connected successfully"); + + // Initialize configuration service + await configurationService.initialize(); + logger.info("Configuration service initialized"); + + const environments = [ + Environment.DEVELOPMENT, + Environment.STAGING, + Environment.PRODUCTION, + ]; + + for (const environment of environments) { + logger.info( + `Initializing configurations for environment: ${environment}`, + ); + + // Set environment-specific configurations + for (const config of defaultConfigurations) { + let value = config.value; + + // Override values for different environments + if (environment === Environment.PRODUCTION) { + if (config.key === "STELLAR_HORIZON_URL") { + value = "https://horizon.stellar.org"; + } + if (config.key === "STELLAR_NETWORK_PASSPHRASE") { + value = "Public Global Stellar Network ; September 2015"; + } + if (config.key === "NODE_ENV") { + value = "production"; + } + if (config.key === "BASE_URL") { + value = "https://api.paystell.com"; + } + } else if (environment === Environment.STAGING) { + if (config.key === "BASE_URL") { + value = "https://staging-api.paystell.com"; + } + if (config.key === "NODE_ENV") { + value = "staging"; + } + } + + await configurationService.setConfig(config.key, value, { + type: config.type, + category: config.category, + description: config.description, + isEncrypted: config.isEncrypted || false, + isRequired: config.isRequired || false, + allowedValues: config.allowedValues, + updatedBy: "system", + }); + } + + // Initialize feature flags + for (const flag of defaultFeatureFlags) { + await configurationService.setFeatureFlag( + flag.name, + flag.description, + flag.isEnabled, + { + scope: flag.scope, + targetingRules: flag.targetingRules, + updatedBy: "system", + }, + ); + } + + logger.info( + `Configuration initialization completed for environment: ${environment}`, + ); + } + + logger.info("Configuration initialization completed successfully"); + process.exit(0); + } catch (error) { + logger.error("Configuration initialization failed:", error); + process.exit(1); + } +} + +// Run the initialization if this script is executed directly +if (require.main === module) { + initializeConfigurations(); +} + +export { initializeConfigurations }; diff --git a/src/scripts/testConfigurationSystem.ts b/src/scripts/testConfigurationSystem.ts new file mode 100644 index 0000000..53523e0 --- /dev/null +++ b/src/scripts/testConfigurationSystem.ts @@ -0,0 +1,282 @@ +import "reflect-metadata"; +import dotenv from "dotenv"; +import { configurationService } from "../services/ConfigurationService"; +import { + ConfigurationType, + ConfigurationCategory, +} from "../entities/Configuration"; +import { FeatureFlagScope } from "../entities/FeatureFlag"; +import AppDataSource from "../config/db"; +import logger from "../utils/logger"; + +dotenv.config(); + +async function testConfigurationSystem() { + try { + logger.info("🧪 Starting Configuration System Test..."); + + // Initialize database connection + await AppDataSource.initialize(); + logger.info("āœ… Database connected successfully"); + + // Initialize configuration service + await configurationService.initialize(); + logger.info("āœ… Configuration service initialized"); + + // Test 1: Basic Configuration Operations + logger.info("\nšŸ“ Test 1: Basic Configuration Operations"); + + // Create a test configuration + const testConfig = await configurationService.setConfig( + "TEST_CONFIG", + "test_value", + { + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Test configuration for validation", + updatedBy: "test-script", + }, + ); + logger.info( + `āœ… Created configuration: ${testConfig.configKey} = ${testConfig.value}`, + ); + + // Retrieve the configuration + const retrievedValue = await configurationService.getConfig("TEST_CONFIG"); + logger.info(`āœ… Retrieved configuration: ${retrievedValue}`); + + // Test 2: Different Data Types + logger.info("\nšŸ“ Test 2: Different Data Types"); + + const typeTests = [ + { key: "NUMBER_CONFIG", value: 123, type: ConfigurationType.NUMBER }, + { key: "BOOLEAN_CONFIG", value: true, type: ConfigurationType.BOOLEAN }, + { + key: "JSON_CONFIG", + value: { key: "value", nested: { data: "test" } }, + type: ConfigurationType.JSON, + }, + ]; + + for (const test of typeTests) { + await configurationService.setConfig(test.key, test.value, { + type: test.type, + category: ConfigurationCategory.GENERAL, + updatedBy: "test-script", + }); + + const retrieved = await configurationService.getConfig(test.key); + logger.info( + `āœ… ${test.key}: ${JSON.stringify(retrieved)} (${test.type})`, + ); + } + + // Test 3: Encrypted Configuration + logger.info("\nšŸ“ Test 3: Encrypted Configuration"); + + await configurationService.setConfig( + "SECRET_CONFIG", + "super_secret_value", + { + type: ConfigurationType.STRING, + category: ConfigurationCategory.SECURITY, + isEncrypted: true, + description: "Encrypted configuration test", + updatedBy: "test-script", + }, + ); + + const encryptedValue = + await configurationService.getConfig("SECRET_CONFIG"); + logger.info(`āœ… Encrypted config retrieved: ${encryptedValue}`); + + // Test 4: Feature Flags + logger.info("\nšŸ“ Test 4: Feature Flags"); + + // Create a global feature flag + const globalFlag = await configurationService.setFeatureFlag( + "test_global_flag", + "Test global feature flag", + true, + { + scope: FeatureFlagScope.GLOBAL, + updatedBy: "test-script", + }, + ); + logger.info(`āœ… Created global feature flag: ${globalFlag.name}`); + + // Create a targeted feature flag + const targetedFlag = await configurationService.setFeatureFlag( + "test_targeted_flag", + "Test targeted feature flag", + true, + { + scope: FeatureFlagScope.USER, + targetingRules: { + userIds: ["user1", "user2"], + percentage: 50, + }, + updatedBy: "test-script", + }, + ); + logger.info(`āœ… Created targeted feature flag: ${targetedFlag.name}`); + + // Test feature flag evaluation + const globalEvaluation = + await configurationService.evaluateFeatureFlag("test_global_flag"); + logger.info( + `āœ… Global flag evaluation: ${globalEvaluation.isEnabled} - ${globalEvaluation.reason}`, + ); + + const targetedEvaluation1 = await configurationService.evaluateFeatureFlag( + "test_targeted_flag", + { + userId: "user1", + }, + ); + logger.info( + `āœ… Targeted flag evaluation (user1): ${targetedEvaluation1.isEnabled} - ${targetedEvaluation1.reason}`, + ); + + const targetedEvaluation2 = await configurationService.evaluateFeatureFlag( + "test_targeted_flag", + { + userId: "user3", + }, + ); + logger.info( + `āœ… Targeted flag evaluation (user3): ${targetedEvaluation2.isEnabled} - ${targetedEvaluation2.reason}`, + ); + + // Test 5: Configuration Validation + logger.info("\nšŸ“ Test 5: Configuration Validation"); + + // Test validation by checking if required configs exist + const validationConfigs = await configurationService.getAllConfigs(); + const requiredConfigs = validationConfigs.filter((c) => c.isRequired); + const missingRequired = requiredConfigs.filter( + (c) => !c.value || c.value.trim() === "", + ); + + if (missingRequired.length > 0) { + logger.warn( + `āš ļø Missing required configurations: ${missingRequired.map((c) => c.configKey).join(", ")}`, + ); + } else { + logger.info("āœ… All required configurations are present"); + } + + // Test 6: Configuration Statistics + logger.info("\nšŸ“ Test 6: Configuration Statistics"); + + const allConfigs = await configurationService.getAllConfigs(); + const allFlags = await configurationService.getAllFeatureFlags(); + + logger.info(`āœ… Total configurations: ${allConfigs.length}`); + logger.info(`āœ… Total feature flags: ${allFlags.length}`); + logger.info( + `āœ… Encrypted configurations: ${allConfigs.filter((c) => c.isEncrypted).length}`, + ); + logger.info( + `āœ… Required configurations: ${allConfigs.filter((c) => c.isRequired).length}`, + ); + + // Test 7: Cache Management + logger.info("\nšŸ“ Test 7: Cache Management"); + + // Test cache by getting the same config multiple times + const startTime = Date.now(); + for (let i = 0; i < 5; i++) { + await configurationService.getConfig("TEST_CONFIG"); + } + const endTime = Date.now(); + logger.info( + `āœ… Cache performance test: ${endTime - startTime}ms for 5 requests`, + ); + + // Test cache clearing + configurationService.clearCache(); + logger.info("āœ… Cache cleared successfully"); + + // Test 8: Configuration Categories + logger.info("\nšŸ“ Test 8: Configuration Categories"); + + const categories = [ + ConfigurationCategory.DATABASE, + ConfigurationCategory.AUTHENTICATION, + ConfigurationCategory.PAYMENT, + ConfigurationCategory.SECURITY, + ]; + + for (const category of categories) { + const configs = await configurationService.getConfigsByCategory(category); + logger.info(`āœ… ${category} configurations: ${configs.length}`); + } + + // Test 9: Environment-Specific Configurations + logger.info("\nšŸ“ Test 9: Environment-Specific Configurations"); + + // Test that configurations are environment-specific + const currentEnv = process.env.NODE_ENV || "development"; + logger.info(`āœ… Current environment: ${currentEnv}`); + + // Create environment-specific config + await configurationService.setConfig( + "ENV_SPECIFIC_CONFIG", + `value_for_${currentEnv}`, + { + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: `Environment-specific configuration for ${currentEnv}`, + updatedBy: "test-script", + }, + ); + + const envConfig = await configurationService.getConfig( + "ENV_SPECIFIC_CONFIG", + ); + logger.info(`āœ… Environment-specific config: ${envConfig}`); + + // Test 10: Cleanup + logger.info("\nšŸ“ Test 10: Cleanup"); + + // Delete test configurations + const testConfigs = [ + "TEST_CONFIG", + "NUMBER_CONFIG", + "BOOLEAN_CONFIG", + "JSON_CONFIG", + "SECRET_CONFIG", + "ENV_SPECIFIC_CONFIG", + ]; + + for (const configKey of testConfigs) { + try { + await configurationService.deleteConfig(configKey, "test-script"); + logger.info(`āœ… Deleted configuration: ${configKey}`); + } catch (error) { + logger.warn(`āš ļø Could not delete ${configKey}: ${error}`); + } + } + + logger.info("\nšŸŽ‰ All Configuration System Tests Completed Successfully!"); + logger.info("āœ… Configuration management works correctly"); + logger.info("āœ… Feature flags work correctly"); + logger.info("āœ… Encryption works correctly"); + logger.info("āœ… Validation works correctly"); + logger.info("āœ… Caching works correctly"); + logger.info("āœ… Environment-specific configurations work correctly"); + + process.exit(0); + } catch (error) { + logger.error("āŒ Configuration System Test Failed:", error); + process.exit(1); + } +} + +// Run the test if this script is executed directly +if (require.main === module) { + testConfigurationSystem(); +} + +export { testConfigurationSystem }; diff --git a/src/services/AuditService.ts b/src/services/AuditService.ts index bb5ceec..21102f3 100644 --- a/src/services/AuditService.ts +++ b/src/services/AuditService.ts @@ -1,7 +1,7 @@ import { Repository } from "typeorm"; import AppDataSource from "../config/db"; import { AuditLog } from "../entities/AuditLog"; -import { Request } from "express-serve-static-core"; +import { Request } from "express"; // Request interface extensions are now handled in src/types/express.d.ts export interface AuditContext { @@ -158,11 +158,20 @@ export class AuditService { } static extractContextFromRequest(req: Request): AuditContext { + const extendedReq = req as Request & { + user?: { id?: number; email?: string }; + validatedIp?: string; + }; return { - userId: req.user?.id?.toString(), - userEmail: req.user?.email, + userId: extendedReq.user?.id?.toString(), + userEmail: extendedReq.user?.email, ipAddress: - req.validatedIp || req.ip || req.connection.remoteAddress || "unknown", + extendedReq.validatedIp || + req.ip || + req.socket?.remoteAddress || + req.headers["x-forwarded-for"]?.toString().split(",")[0]?.trim() || + req.headers["x-real-ip"]?.toString() || + "unknown", userAgent: req.get("User-Agent") || "unknown", sessionId: req.headers["x-session-id"] as string, }; diff --git a/src/services/ConfigurationService.ts b/src/services/ConfigurationService.ts new file mode 100644 index 0000000..c41e52a --- /dev/null +++ b/src/services/ConfigurationService.ts @@ -0,0 +1,780 @@ +import { Repository } from "typeorm"; + +import crypto from "crypto"; +import AppDataSource from "../config/db"; +import { + Configuration, + Environment, + ConfigurationType, + ConfigurationCategory, +} from "../entities/Configuration"; +import { + FeatureFlag, + FeatureFlagScope, + FeatureFlagStatus, +} from "../entities/FeatureFlag"; +import { AuditService } from "./AuditService"; +import logger from "../utils/logger"; + +export interface ConfigurationValue { + key: string; + value: string | number | boolean | Record; + type: ConfigurationType; + category: ConfigurationCategory; + isEncrypted: boolean; + isRequired: boolean; + description?: string; + validationRules?: Record; + defaultValue?: string; + allowedValues?: string[]; + expiresAt?: Date; + metadata?: Record; +} + +export interface ConfigurationValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +export interface FeatureFlagEvaluation { + isEnabled: boolean; + reason: string; + targetingMatch?: boolean; + percentageRollout?: number; +} + +export class ConfigurationService { + private configRepository: Repository; + private featureFlagRepository: Repository; + private auditService: AuditService; + private cache: Map = new Map(); + private encryptionKey: string; + private currentEnvironment: Environment; + + constructor() { + this.configRepository = AppDataSource.getRepository(Configuration); + this.featureFlagRepository = AppDataSource.getRepository(FeatureFlag); + this.auditService = new AuditService(); + this.encryptionKey = + process.env.CONFIG_ENCRYPTION_KEY || "default-encryption-key"; + this.currentEnvironment = + (process.env.NODE_ENV as Environment) || Environment.DEVELOPMENT; + } + + /** + * Initialize configuration service and load all configurations + */ + async initialize(): Promise { + try { + logger.info("Initializing ConfigurationService..."); + + // Load all configurations for current environment + await this.loadConfigurations(); + + // Validate required configurations + const validationResult = await this.validateRequiredConfigurations(); + if (!validationResult.isValid) { + throw new Error( + `Configuration validation failed: ${validationResult.errors.join(", ")}`, + ); + } + + logger.info("ConfigurationService initialized successfully"); + } catch (error) { + logger.error("Failed to initialize ConfigurationService:", error); + throw error; + } + } + + /** + * Get configuration value by key + */ + async getConfig( + key: string, + defaultValue?: string, + ): Promise | null> { + const cached = this.cache.get(key); + if (cached) { + return this.parseValue(cached.value as string, cached.type); + } + + const config = await this.configRepository.findOne({ + where: { + configKey: key, + environment: this.currentEnvironment, + isActive: true, + }, + }); + + if (!config) { + return defaultValue !== undefined ? defaultValue : null; + } + + let value = config.value; + if (config.isEncrypted) { + value = this.decryptValue(value); + } + + const configValue: ConfigurationValue = { + key: config.configKey, + value, + type: config.type, + category: config.category, + isEncrypted: config.isEncrypted, + isRequired: config.isRequired, + description: config.description, + validationRules: config.validationRules + ? (this.safeJsonParse>( + config.validationRules, + ) as Record) + : undefined, + defaultValue: config.defaultValue, + allowedValues: config.allowedValues + ? (this.safeJsonParse(config.allowedValues, []) as string[]) + : [], + expiresAt: config.expiresAt, + metadata: config.metadata + ? (this.safeJsonParse>( + config.metadata, + ) as Record) + : undefined, + }; + + this.cache.set(key, configValue); + return this.parseValue(value, config.type); + } + + /** + * Get configuration details including metadata + */ + async getConfigDetails(key: string): Promise { + return this.configRepository.findOne({ + where: { + configKey: key, + environment: this.currentEnvironment, + isActive: true, + }, + }); + } + + /** + * Set configuration value + */ + async setConfig( + key: string, + value: string | number | boolean | Record, + options: { + type?: ConfigurationType; + category?: ConfigurationCategory; + description?: string; + isEncrypted?: boolean; + isRequired?: boolean; + validationRules?: Record; + defaultValue?: string; + allowedValues?: string[]; + expiresAt?: Date; + metadata?: Record; + updatedBy?: string; + } = {}, + ): Promise { + const stringValue = this.stringifyValue(value); + let finalValue = stringValue; + + if (options.isEncrypted) { + finalValue = this.encryptValue(stringValue); + } + + let config = await this.configRepository.findOne({ + where: { + configKey: key, + environment: this.currentEnvironment, + }, + }); + + const oldValues = config ? { ...config } : undefined; + + if (!config) { + config = this.configRepository.create({ + configKey: key, + value: finalValue, + environment: this.currentEnvironment, + type: options.type || ConfigurationType.STRING, + category: options.category || ConfigurationCategory.GENERAL, + description: options.description, + isEncrypted: options.isEncrypted || false, + isRequired: options.isRequired || false, + validationRules: options.validationRules + ? JSON.stringify(options.validationRules) + : undefined, + defaultValue: options.defaultValue, + allowedValues: options.allowedValues + ? JSON.stringify(options.allowedValues) + : undefined, + expiresAt: options.expiresAt, + metadata: options.metadata + ? JSON.stringify(options.metadata) + : undefined, + updatedBy: options.updatedBy, + }); + } else { + Object.assign(config, { + value: finalValue, + type: options.type || config.type, + category: options.category || config.category, + description: options.description || config.description, + isEncrypted: + options.isEncrypted !== undefined + ? options.isEncrypted + : config.isEncrypted, + isRequired: + options.isRequired !== undefined + ? options.isRequired + : config.isRequired, + validationRules: options.validationRules + ? JSON.stringify(options.validationRules) + : config.validationRules, + defaultValue: options.defaultValue || config.defaultValue, + allowedValues: options.allowedValues + ? JSON.stringify(options.allowedValues) + : config.allowedValues, + expiresAt: options.expiresAt || config.expiresAt, + metadata: options.metadata + ? JSON.stringify(options.metadata) + : config.metadata, + updatedBy: options.updatedBy, + }); + } + + const savedConfig = await this.configRepository.save(config); + + // Clear cache + this.cache.delete(key); + + // Audit log + await this.auditService.createAuditLog({ + entityType: "Configuration", + entityId: savedConfig.id, + action: oldValues ? "UPDATE" : "CREATE", + oldValues: oldValues as unknown as Record, + newValues: savedConfig as unknown as Record, + context: { + ipAddress: "system", + userAgent: "ConfigurationService", + userId: options.updatedBy, + }, + }); + + logger.info( + `Configuration updated: ${key} = ${options.isEncrypted ? "[ENCRYPTED]" : value}`, + ); + + return savedConfig; + } + + /** + * Delete configuration + */ + async deleteConfig(key: string, updatedBy?: string): Promise { + const config = await this.configRepository.findOne({ + where: { + configKey: key, + environment: this.currentEnvironment, + }, + }); + + if (!config) { + throw new Error(`Configuration not found: ${key}`); + } + + await this.configRepository.remove(config); + + // Clear cache + this.cache.delete(key); + + // Audit log + await this.auditService.createAuditLog({ + entityType: "Configuration", + entityId: config.id, + action: "DELETE", + oldValues: config as unknown as Record, + context: { + ipAddress: "system", + userAgent: "ConfigurationService", + userId: updatedBy, + }, + }); + + logger.info(`Configuration deleted: ${key}`); + } + + /** + * Get all configurations for current environment + */ + async getAllConfigs(): Promise { + return this.configRepository.find({ + where: { + environment: this.currentEnvironment, + isActive: true, + }, + order: { + category: "ASC", + configKey: "ASC", + }, + }); + } + + /** + * Get configurations by category + */ + async getConfigsByCategory( + category: ConfigurationCategory, + ): Promise { + return this.configRepository.find({ + where: { + category, + environment: this.currentEnvironment, + isActive: true, + }, + order: { + configKey: "ASC", + }, + }); + } + + /** + * Evaluate feature flag + */ + async evaluateFeatureFlag( + flagName: string, + context?: { + userId?: string; + merchantId?: string; + userRole?: string; + }, + ): Promise { + const flag = await this.featureFlagRepository.findOne({ + where: { + name: flagName, + environment: this.currentEnvironment, + status: FeatureFlagStatus.ACTIVE, + }, + }); + + if (!flag) { + return { + isEnabled: false, + reason: "Feature flag not found or inactive", + }; + } + + // Check if flag is scheduled + const now = new Date(); + if (flag.scheduledStartDate && now < flag.scheduledStartDate) { + return { + isEnabled: false, + reason: "Feature flag not yet scheduled to start", + }; + } + + if (flag.scheduledEndDate && now > flag.scheduledEndDate) { + return { + isEnabled: false, + reason: "Feature flag has expired", + }; + } + + // Check targeting rules + if (flag.targetingRules) { + const targetingMatch = this.evaluateTargetingRules( + flag.targetingRules, + context, + flagName, + ); + if (!targetingMatch) { + return { + isEnabled: false, + reason: "User does not match targeting rules", + targetingMatch: false, + }; + } + } + + // Check percentage rollout + if (flag.targetingRules?.percentage) { + const percentage = flag.targetingRules.percentage; + const userId = context?.userId || "anonymous"; + const hash = crypto + .createHash("md5") + .update(`${flagName}:${userId}`) + .digest("hex"); + const hashValue = parseInt(hash.substring(0, 8), 16); + const userPercentage = hashValue % 100; + + if (userPercentage >= percentage) { + return { + isEnabled: false, + reason: "User not included in percentage rollout", + percentageRollout: percentage, + }; + } + } + + return { + isEnabled: flag.isEnabled, + reason: flag.isEnabled + ? "Feature flag is enabled" + : "Feature flag is disabled", + targetingMatch: true, + }; + } + + /** + * Create or update feature flag + */ + async setFeatureFlag( + name: string, + description: string, + isEnabled: boolean, + options: { + scope?: FeatureFlagScope; + targetingRules?: Record; + scheduledStartDate?: Date; + scheduledEndDate?: Date; + metadata?: Record; + owner?: string; + tags?: string; + updatedBy?: string; + } = {}, + ): Promise { + let flag = await this.featureFlagRepository.findOne({ + where: { + name, + environment: this.currentEnvironment, + }, + }); + + const oldValues = flag ? { ...flag } : undefined; + + if (!flag) { + flag = this.featureFlagRepository.create({ + name, + description, + isEnabled, + environment: this.currentEnvironment, + scope: options.scope || FeatureFlagScope.GLOBAL, + targetingRules: options.targetingRules, + scheduledStartDate: options.scheduledStartDate, + scheduledEndDate: options.scheduledEndDate, + metadata: options.metadata, + owner: options.owner, + tags: options.tags, + updatedBy: options.updatedBy, + }); + } else { + Object.assign(flag, { + description, + isEnabled, + scope: options.scope || flag.scope, + targetingRules: options.targetingRules || flag.targetingRules, + scheduledStartDate: + options.scheduledStartDate || flag.scheduledStartDate, + scheduledEndDate: options.scheduledEndDate || flag.scheduledEndDate, + metadata: options.metadata || flag.metadata, + owner: options.owner || flag.owner, + tags: options.tags || flag.tags, + updatedBy: options.updatedBy, + }); + } + + const savedFlag = await this.featureFlagRepository.save(flag); + + // Audit log + await this.auditService.createAuditLog({ + entityType: "FeatureFlag", + entityId: savedFlag.id, + action: oldValues ? "UPDATE" : "CREATE", + oldValues: oldValues as unknown as Record, + newValues: savedFlag as unknown as Record, + context: { + ipAddress: "system", + userAgent: "ConfigurationService", + userId: options.updatedBy, + }, + }); + + logger.info(`Feature flag updated: ${name} = ${isEnabled}`); + + return savedFlag; + } + + /** + * Get all feature flags for current environment + */ + async getAllFeatureFlags(): Promise { + return this.featureFlagRepository.find({ + where: { + environment: this.currentEnvironment, + }, + order: { + name: "ASC", + }, + }); + } + + /** + * Clear configuration cache + */ + clearCache(): void { + this.cache.clear(); + logger.info("Configuration cache cleared"); + } + + /** + * Reload configurations from database + */ + async reloadConfigurations(): Promise { + this.clearCache(); + await this.loadConfigurations(); + logger.info("Configurations reloaded from database"); + } + + // Private helper methods + + private async loadConfigurations(): Promise { + const configs = await this.configRepository.find({ + where: { + environment: this.currentEnvironment, + isActive: true, + }, + }); + + this.cache.clear(); + for (const config of configs) { + let value = config.value; + if (config.isEncrypted) { + value = this.decryptValue(value); + } + + this.cache.set(config.configKey, { + key: config.configKey, + value, + type: config.type, + category: config.category, + isEncrypted: config.isEncrypted, + isRequired: config.isRequired, + description: config.description, + validationRules: config.validationRules + ? (this.safeJsonParse>( + config.validationRules, + ) as Record) + : undefined, + defaultValue: config.defaultValue, + allowedValues: config.allowedValues + ? (this.safeJsonParse(config.allowedValues, []) as string[]) + : [], + expiresAt: config.expiresAt, + metadata: config.metadata + ? (this.safeJsonParse>( + config.metadata, + ) as Record) + : undefined, + }); + } + } + + private async validateRequiredConfigurations(): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + const requiredConfigs = await this.configRepository.find({ + where: { + environment: this.currentEnvironment, + isRequired: true, + isActive: true, + }, + }); + + for (const config of requiredConfigs) { + if (!config.value || config.value.trim() === "") { + errors.push( + `Required configuration missing or empty: ${config.configKey}`, + ); + } + + // Check if configuration has expired + if (config.expiresAt && new Date() > config.expiresAt) { + warnings.push(`Configuration has expired: ${config.configKey}`); + } + + // Validate against allowed values + if (config.allowedValues) { + const allowedValues = this.safeJsonParse( + config.allowedValues, + [], + ) as string[]; + if (!allowedValues.includes(config.value)) { + errors.push( + `Configuration value not allowed: ${config.configKey} = ${config.value}`, + ); + } + } + } + + return { + isValid: errors.length === 0, + errors, + warnings, + }; + } + + private parseValue( + value: string, + type: ConfigurationType, + ): string | number | boolean | Record { + switch (type) { + case ConfigurationType.NUMBER: + return parseFloat(value); + case ConfigurationType.BOOLEAN: + return value.toLowerCase() === "true"; + case ConfigurationType.JSON: + try { + return JSON.parse(value); + } catch (error) { + logger.error( + `Failed to parse JSON value for type JSON: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + return value; // Return raw value as fallback + } + case ConfigurationType.ENCRYPTED: + return this.decryptValue(value); + default: + return value; + } + } + + private stringifyValue( + value: string | number | boolean | Record, + ): string { + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); + } + + private encryptValue(value: string): string { + const iv = crypto.randomBytes(16); + // Use a KDF with a deterministic salt based on a fixed application salt + const salt = crypto + .createHash("sha256") + .update("paystell-config-v1") + .digest(); + const key = crypto.scryptSync(this.encryptionKey, salt, 32); + const cipher = crypto.createCipheriv("aes-256-cbc", key, iv); + let encrypted = cipher.update(value, "utf8", "hex"); + encrypted += cipher.final("hex"); + return `${iv.toString("hex")}:${encrypted}`; + } + + private decryptValue(encryptedValue: string): string { + const [ivHex, encrypted] = encryptedValue.split(":"); + const iv = Buffer.from(ivHex, "hex"); + // Use the same salt for decryption + const salt = crypto + .createHash("sha256") + .update("paystell-config-v1") + .digest(); + const key = crypto.scryptSync(this.encryptionKey, salt, 32); + const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv); + let decrypted = decipher.update(encrypted, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } + + private evaluateTargetingRules( + targetingRules: Record, + context?: { + userId?: string; + merchantId?: string; + userRole?: string; + }, + flagName?: string, + ): boolean { + if (!context) return true; + + // Check user IDs + if (targetingRules.userIds && Array.isArray(targetingRules.userIds)) { + if (!context.userId || !targetingRules.userIds.includes(context.userId)) { + return false; + } + } + + // Check merchant IDs + if ( + targetingRules.merchantIds && + Array.isArray(targetingRules.merchantIds) + ) { + if ( + !context.merchantId || + !targetingRules.merchantIds.includes(context.merchantId) + ) { + return false; + } + } + + // Check user roles + if (targetingRules.userRoles && Array.isArray(targetingRules.userRoles)) { + if ( + !context.userRole || + !targetingRules.userRoles.includes(context.userRole) + ) { + return false; + } + } + + // Check percentage rollout + if (targetingRules.percentage !== undefined) { + // Validate percentage is between 0 and 100 + if ( + typeof targetingRules.percentage !== "number" || + targetingRules.percentage < 0 || + targetingRules.percentage > 100 + ) { + logger.warn( + `Invalid percentage value: ${targetingRules.percentage}. Must be between 0 and 100.`, + ); + return false; + } + + const userId = context.userId || "anonymous"; + // Use the same hash format as evaluateFeatureFlag for consistency + const hashInput = flagName + ? `${flagName}:${userId}` + : `targeting:${userId}`; + const hash = crypto.createHash("md5").update(hashInput).digest("hex"); + const hashValue = parseInt(hash.substring(0, 8), 16); + const userPercentage = hashValue % 100; + return userPercentage < targetingRules.percentage; + } + + return true; + } + + /** + * Safely parse JSON with error handling + */ + private safeJsonParse( + value: string, + defaultValue?: T, + ): T | unknown { + try { + return JSON.parse(value); + } catch (error) { + logger.warn(`Failed to parse JSON: ${(error as Error).message}`); + return defaultValue !== undefined ? defaultValue : undefined; + } + } +} + +// Singleton instance +export const configurationService = new ConfigurationService(); diff --git a/src/tests/integration/configurationIntegration.test.ts b/src/tests/integration/configurationIntegration.test.ts new file mode 100644 index 0000000..4aa4e45 --- /dev/null +++ b/src/tests/integration/configurationIntegration.test.ts @@ -0,0 +1,471 @@ +import request from "supertest"; +import app from "../../app"; +import AppDataSource from "../../config/db"; +import { configurationService } from "../../services/ConfigurationService"; +import { Configuration } from "../../entities/Configuration"; +import { FeatureFlag } from "../../entities/FeatureFlag"; + +describe("Configuration System Integration Tests", () => { + let authToken: string; + + beforeAll(async () => { + // Initialize database connection + if (!AppDataSource.isInitialized) { + await AppDataSource.initialize(); + } + + // Initialize configuration service + await configurationService.initialize(); + + // Create a test user and generate a proper auth token + // In a real implementation, this would use the actual auth system + authToken = "test-auth-token"; // TODO: Replace with proper JWT token generation + }); + + afterAll(async () => { + await AppDataSource.destroy(); + }); + + beforeEach(async () => { + // Clear test data + const configRepo = AppDataSource.getRepository(Configuration); + const flagRepo = AppDataSource.getRepository(FeatureFlag); + + await configRepo.delete({}); + await flagRepo.delete({}); + }); + + describe("Configuration API Endpoints", () => { + it("should create and retrieve a configuration", async () => { + // Create configuration + const createResponse = await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "TEST_CONFIG", + value: "test_value", + type: "string", + category: "general", + description: "Test configuration", + isRequired: false, + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.success).toBe(true); + + // Retrieve configuration + const getResponse = await request(app) + .get("/api/config/TEST_CONFIG") + .set("Authorization", `Bearer ${authToken}`); + + expect(getResponse.status).toBe(200); + expect(getResponse.body.data.value).toBe("test_value"); + }); + + it("should handle encrypted configurations", async () => { + // Create encrypted configuration + const createResponse = await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "ENCRYPTED_CONFIG", + value: "secret_value", + type: "string", + category: "security", + description: "Encrypted configuration", + isEncrypted: true, + isRequired: false, + }); + + expect(createResponse.status).toBe(201); + + // Retrieve configuration (should show as encrypted) + const getResponse = await request(app) + .get("/api/config") + .set("Authorization", `Bearer ${authToken}`); + + expect(getResponse.status).toBe(200); + const encryptedConfig = getResponse.body.data.find( + (config: { key: string; value: string }) => + config.key === "ENCRYPTED_CONFIG", + ); + expect(encryptedConfig.value).toBe("[ENCRYPTED]"); + }); + + it("should get configurations by category", async () => { + // Create configurations in different categories + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "PAYMENT_CONFIG", + value: "100", + type: "number", + category: "payment", + description: "Payment configuration", + }); + + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "SECURITY_CONFIG", + value: "true", + type: "boolean", + category: "security", + description: "Security configuration", + }); + + // Get configurations by category + const response = await request(app) + .get("/api/config/category/payment") + .set("Authorization", `Bearer ${authToken}`); + + expect(response.status).toBe(200); + expect(response.body.data.length).toBe(1); + expect(response.body.data[0].key).toBe("PAYMENT_CONFIG"); + }); + + it("should delete configuration", async () => { + // Create configuration + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "TO_DELETE", + value: "delete_me", + type: "string", + category: "general", + }); + + // Delete configuration + const deleteResponse = await request(app) + .delete("/api/config/TO_DELETE") + .set("Authorization", `Bearer ${authToken}`); + + expect(deleteResponse.status).toBe(200); + expect(deleteResponse.body.success).toBe(true); + + // Verify it's deleted + const getResponse = await request(app) + .get("/api/config/TO_DELETE") + .set("Authorization", `Bearer ${authToken}`); + + expect(getResponse.status).toBe(404); + }); + }); + + describe("Feature Flag API Endpoints", () => { + it("should create and evaluate feature flag", async () => { + // Create feature flag + const createResponse = await request(app) + .post("/api/config/feature-flags") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "test_feature", + description: "Test feature flag", + isEnabled: true, + scope: "global", + }); + + expect(createResponse.status).toBe(201); + expect(createResponse.body.success).toBe(true); + + // Evaluate feature flag + const evaluateResponse = await request(app) + .get("/api/config/feature-flags/test_feature/evaluate") + .set("Authorization", `Bearer ${authToken}`); + + expect(evaluateResponse.status).toBe(200); + expect(evaluateResponse.body.data.isEnabled).toBe(true); + }); + + it("should handle targeted feature flags", async () => { + // Create targeted feature flag + await request(app) + .post("/api/config/feature-flags") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "targeted_feature", + description: "Targeted feature flag", + isEnabled: true, + scope: "user", + targetingRules: { + userIds: ["user1", "user2"], + }, + }); + + // Evaluate with matching user + const matchResponse = await request(app) + .get("/api/config/feature-flags/targeted_feature/evaluate?userId=user1") + .set("Authorization", `Bearer ${authToken}`); + + expect(matchResponse.status).toBe(200); + expect(matchResponse.body.data.isEnabled).toBe(true); + + // Evaluate with non-matching user + const noMatchResponse = await request(app) + .get("/api/config/feature-flags/targeted_feature/evaluate?userId=user3") + .set("Authorization", `Bearer ${authToken}`); + + expect(noMatchResponse.status).toBe(200); + expect(noMatchResponse.body.data.isEnabled).toBe(false); + }); + }); + + describe("Configuration Service Integration", () => { + it("should get configuration value from service", async () => { + // Create configuration via API + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "SERVICE_TEST", + value: "service_value", + type: "string", + category: "general", + }); + + // Get value via service + const value = await configurationService.getConfig("SERVICE_TEST"); + expect(value).toBe("service_value"); + }); + + it("should evaluate feature flag via service", async () => { + // Create feature flag via API + await request(app) + .post("/api/config/feature-flags") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "service_test_flag", + description: "Service test flag", + isEnabled: true, + scope: "global", + }); + + // Evaluate via service + const evaluation = + await configurationService.evaluateFeatureFlag("service_test_flag"); + expect(evaluation.isEnabled).toBe(true); + }); + + it("should handle different data types", async () => { + // Create different types of configurations + const testCases = [ + { + key: "STRING_CONFIG", + value: "string_value", + type: "string", + expected: "string_value", + }, + { key: "NUMBER_CONFIG", value: "123", type: "number", expected: 123 }, + { + key: "BOOLEAN_CONFIG", + value: "true", + type: "boolean", + expected: true, + }, + { + key: "JSON_CONFIG", + value: '{"key":"value"}', + type: "json", + expected: { key: "value" }, + }, + ]; + + for (const testCase of testCases) { + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: testCase.key, + value: testCase.value, + type: testCase.type, + category: "general", + }); + + const value = await configurationService.getConfig(testCase.key); + expect(value).toEqual(testCase.expected); + } + }); + }); + + describe("Configuration Validation", () => { + it("should validate required configurations", async () => { + // Create a required configuration with empty value + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "REQUIRED_CONFIG", + value: "", + type: "string", + category: "general", + isRequired: true, + }); + + // Validate configurations + const validateResponse = await request(app) + .get("/api/config/validate") + .set("Authorization", `Bearer ${authToken}`); + + expect(validateResponse.status).toBe(200); + expect(validateResponse.body.data.isValid).toBe(false); + expect(validateResponse.body.data.errors).toContain( + "Required configuration missing or empty: REQUIRED_CONFIG", + ); + }); + }); + + describe("Configuration Statistics", () => { + it("should return configuration statistics", async () => { + // Create some test configurations + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "STATS_CONFIG_1", + value: "value1", + type: "string", + category: "general", + }); + + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "STATS_CONFIG_2", + value: "secret", + type: "string", + category: "security", + isEncrypted: true, + }); + + // Create feature flag + await request(app) + .post("/api/config/feature-flags") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "stats_test_flag", + description: "Stats test flag", + isEnabled: true, + scope: "global", + }); + + // Get statistics + const statsResponse = await request(app) + .get("/api/config/stats") + .set("Authorization", `Bearer ${authToken}`); + + expect(statsResponse.status).toBe(200); + expect(statsResponse.body.data.totalConfigurations).toBe(2); + expect(statsResponse.body.data.encryptedConfigurations).toBe(1); + expect(statsResponse.body.data.totalFeatureFlags).toBe(1); + expect(statsResponse.body.data.activeFeatureFlags).toBe(1); + }); + + it("should update existing configuration", async () => { + // Create initial configuration + await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "UPDATE_TEST", + value: "initial_value", + type: "string", + category: "general", + }); + + // Update configuration + const updateResponse = await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "UPDATE_TEST", + value: "updated_value", + type: "string", + category: "general", + }); + + expect(updateResponse.status).toBe(200); + expect(updateResponse.body.data.value).toBe("updated_value"); + }); + + it("should return 403 for unauthorized access", async () => { + const response = await request(app) + .get("/api/config") + .set("Authorization", "Bearer invalid-token"); + + expect(response.status).toBe(403); + }); + + it("should validate configuration types", async () => { + const response = await request(app) + .post("/api/config") + .set("Authorization", `Bearer ${authToken}`) + .send({ + key: "TYPE_TEST", + value: "not-a-number", + type: "number", + category: "general", + }); + + expect(response.status).toBe(400); + }); + + it("should handle percentage-based rollouts", async () => { + await request(app) + .post("/api/config/feature-flags") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "percentage_feature", + description: "Percentage rollout feature", + isEnabled: true, + scope: "user", + targetingRules: { + percentage: 50, + }, + }); + + // Test multiple evaluations to verify percentage distribution + const results = []; + for (let i = 0; i < 100; i++) { + const response = await request(app) + .get( + `/api/config/feature-flags/percentage_feature/evaluate?userId=user${i}`, + ) + .set("Authorization", `Bearer ${authToken}`); + results.push(response.body.data.isEnabled); + } + + const enabledCount = results.filter((r) => r).length; + expect(enabledCount).toBeGreaterThan(30); + expect(enabledCount).toBeLessThan(70); + }); + + it("should handle role-based targeting", async () => { + await request(app) + .post("/api/config/feature-flags") + .set("Authorization", `Bearer ${authToken}`) + .send({ + name: "role_feature", + description: "Role-based feature", + isEnabled: true, + scope: "user", + targetingRules: { + userRoles: ["admin", "super_admin"], + }, + }); + + const adminResponse = await request(app) + .get("/api/config/feature-flags/role_feature/evaluate?userRole=admin") + .set("Authorization", `Bearer ${authToken}`); + expect(adminResponse.body.data.isEnabled).toBe(true); + + const userResponse = await request(app) + .get("/api/config/feature-flags/role_feature/evaluate?userRole=user") + .set("Authorization", `Bearer ${authToken}`); + expect(userResponse.body.data.isEnabled).toBe(false); + }); + }); +}); diff --git a/src/tests/services/ConfigurationService.test.ts b/src/tests/services/ConfigurationService.test.ts new file mode 100644 index 0000000..a917804 --- /dev/null +++ b/src/tests/services/ConfigurationService.test.ts @@ -0,0 +1,591 @@ +import { Repository } from "typeorm"; +import { ConfigurationService } from "../../services/ConfigurationService"; +import { + Configuration, + ConfigurationType, + ConfigurationCategory, + Environment, +} from "../../entities/Configuration"; +import { + FeatureFlag, + FeatureFlagScope, + FeatureFlagStatus, +} from "../../entities/FeatureFlag"; +import AppDataSource from "../../config/db"; +import { AuditService } from "../../services/AuditService"; + +// Mock the AuditService +jest.mock("../../services/AuditService"); +const mockAuditService = { + createAuditLog: jest.fn().mockResolvedValue({}), +}; + +(AuditService as jest.MockedClass).mockImplementation( + () => mockAuditService as unknown as AuditService, +); + +describe("ConfigurationService", () => { + let configurationService: ConfigurationService; + let mockConfigRepository: Partial>; + let mockFeatureFlagRepository: Partial>; + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + + // Mock repositories + mockConfigRepository = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + mockFeatureFlagRepository = { + findOne: jest.fn(), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + // Mock AppDataSource + jest.spyOn(AppDataSource, "getRepository").mockImplementation((entity) => { + if (entity === Configuration) { + return mockConfigRepository; + } + if (entity === FeatureFlag) { + return mockFeatureFlagRepository; + } + return {} as Partial>; + }); + + configurationService = new ConfigurationService(); + }); + + describe("getConfig", () => { + it("should return cached configuration value", async () => { + const mockConfig = { + key: "TEST_CONFIG", + value: "test_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(mockConfig); + + // First call should hit database + const result1 = await configurationService.getConfig("TEST_CONFIG"); + expect(result1).toBe("test_value"); + + // Second call should use cache + const result2 = await configurationService.getConfig("TEST_CONFIG"); + expect(result2).toBe("test_value"); + + // Should only call database once + expect(mockConfigRepository.findOne).toHaveBeenCalledTimes(1); + }); + + it("should return default value when configuration not found", async () => { + mockConfigRepository.findOne.mockResolvedValue(null); + + const result = await configurationService.getConfig( + "NON_EXISTENT", + "default_value", + ); + expect(result).toBe("default_value"); + }); + + it("should decrypt encrypted values", async () => { + const mockConfig = { + key: "ENCRYPTED_CONFIG", + value: "encrypted_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.SECURITY, + isEncrypted: true, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(mockConfig); + + // Mock the decryption method + jest + .spyOn( + configurationService as unknown as { + decryptValue: (value: string) => string; + }, + "decryptValue", + ) + .mockReturnValue("decrypted_value"); + + const result = await configurationService.getConfig("ENCRYPTED_CONFIG"); + expect(result).toBe("decrypted_value"); + }); + + it("should parse different data types correctly", async () => { + const testCases = [ + { type: ConfigurationType.NUMBER, value: "123", expected: 123 }, + { type: ConfigurationType.BOOLEAN, value: "true", expected: true }, + { + type: ConfigurationType.JSON, + value: '{"key":"value"}', + expected: { key: "value" }, + }, + { type: ConfigurationType.STRING, value: "test", expected: "test" }, + ]; + + for (const testCase of testCases) { + const mockConfig = { + key: `TEST_${testCase.type}`, + value: testCase.value, + type: testCase.type, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(mockConfig); + + const result = await configurationService.getConfig( + `TEST_${testCase.type}`, + ); + expect(result).toEqual(testCase.expected); + } + }); + }); + + describe("setConfig", () => { + it("should create new configuration", async () => { + const mockCreatedConfig = { + id: "config-id", + key: "NEW_CONFIG", + value: "new_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(null); + mockConfigRepository.create.mockReturnValue(mockCreatedConfig); + mockConfigRepository.save.mockResolvedValue(mockCreatedConfig); + + const result = await configurationService.setConfig( + "NEW_CONFIG", + "new_value", + { + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Test configuration", + updatedBy: "test-user", + }, + ); + + expect(result).toEqual(mockCreatedConfig); + expect(mockConfigRepository.create).toHaveBeenCalledWith({ + key: "NEW_CONFIG", + value: "new_value", + environment: Environment.DEVELOPMENT, + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + description: "Test configuration", + isEncrypted: false, + isRequired: false, + updatedBy: "test-user", + }); + }); + + it("should update existing configuration", async () => { + const existingConfig = { + id: "config-id", + key: "EXISTING_CONFIG", + value: "old_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }; + + const updatedConfig = { ...existingConfig, value: "new_value" }; + + mockConfigRepository.findOne.mockResolvedValue(existingConfig); + mockConfigRepository.save.mockResolvedValue(updatedConfig); + + const result = await configurationService.setConfig( + "EXISTING_CONFIG", + "new_value", + { + updatedBy: "test-user", + }, + ); + + expect(result).toEqual(updatedConfig); + expect(mockConfigRepository.save).toHaveBeenCalledWith({ + ...existingConfig, + value: "new_value", + updatedBy: "test-user", + }); + }); + + it("should encrypt sensitive values", async () => { + const mockCreatedConfig = { + id: "config-id", + key: "SENSITIVE_CONFIG", + value: "encrypted_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.SECURITY, + isEncrypted: true, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(null); + mockConfigRepository.create.mockReturnValue(mockCreatedConfig); + mockConfigRepository.save.mockResolvedValue(mockCreatedConfig); + + await configurationService.setConfig("SENSITIVE_CONFIG", "secret_value", { + isEncrypted: true, + updatedBy: "test-user", + }); + + expect(mockConfigRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + key: "SENSITIVE_CONFIG", + value: expect.stringContaining(":"), // Encrypted format + isEncrypted: true, + }), + ); + }); + + it("should clear cache when configuration is updated", async () => { + const mockConfig = { + id: "config-id", + key: "CACHE_TEST", + value: "old_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(mockConfig); + mockConfigRepository.save.mockResolvedValue(mockConfig); + + // First, get the config to populate cache + await configurationService.getConfig("CACHE_TEST"); + + // Then update it + await configurationService.setConfig("CACHE_TEST", "new_value", { + updatedBy: "test-user", + }); + + // Cache should be cleared, so next get should hit database + await configurationService.getConfig("CACHE_TEST"); + expect(mockConfigRepository.findOne).toHaveBeenCalledTimes(2); + }); + }); + + describe("deleteConfig", () => { + it("should delete configuration and clear cache", async () => { + const mockConfig = { + id: "config-id", + key: "TO_DELETE", + value: "value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }; + + mockConfigRepository.findOne.mockResolvedValue(mockConfig); + mockConfigRepository.remove.mockResolvedValue(undefined); + + await configurationService.deleteConfig("TO_DELETE", "test-user"); + + expect(mockConfigRepository.remove).toHaveBeenCalledWith(mockConfig); + expect(mockAuditService.createAuditLog).toHaveBeenCalledWith({ + entityType: "Configuration", + entityId: "config-id", + action: "DELETE", + oldValues: mockConfig, + context: { + ipAddress: "system", + userAgent: "ConfigurationService", + userId: "test-user", + }, + }); + }); + + it("should throw error when configuration not found", async () => { + mockConfigRepository.findOne.mockResolvedValue(null); + + await expect( + configurationService.deleteConfig("NON_EXISTENT"), + ).rejects.toThrow("Configuration not found: NON_EXISTENT"); + }); + }); + + describe("evaluateFeatureFlag", () => { + it("should return false when feature flag not found", async () => { + mockFeatureFlagRepository.findOne.mockResolvedValue(null); + + const result = + await configurationService.evaluateFeatureFlag("NON_EXISTENT"); + + expect(result).toEqual({ + isEnabled: false, + reason: "Feature flag not found or inactive", + }); + }); + + it("should return false when feature flag is disabled", async () => { + const mockFlag = { + id: "flag-id", + name: "test_flag", + description: "Test flag", + isEnabled: false, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.GLOBAL, + status: FeatureFlagStatus.ACTIVE, + }; + + mockFeatureFlagRepository.findOne.mockResolvedValue(mockFlag); + + const result = + await configurationService.evaluateFeatureFlag("test_flag"); + + expect(result).toEqual({ + isEnabled: false, + reason: "Feature flag is disabled", + targetingMatch: true, + }); + }); + + it("should return true when feature flag is enabled", async () => { + const mockFlag = { + id: "flag-id", + name: "test_flag", + description: "Test flag", + isEnabled: true, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.GLOBAL, + status: FeatureFlagStatus.ACTIVE, + }; + + mockFeatureFlagRepository.findOne.mockResolvedValue(mockFlag); + + const result = + await configurationService.evaluateFeatureFlag("test_flag"); + + expect(result).toEqual({ + isEnabled: true, + reason: "Feature flag is enabled", + targetingMatch: true, + }); + }); + + it("should check targeting rules", async () => { + const mockFlag = { + id: "flag-id", + name: "targeted_flag", + description: "Test flag", + isEnabled: true, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.USER, + status: FeatureFlagStatus.ACTIVE, + targetingRules: { + userIds: ["user1", "user2"], + }, + }; + + mockFeatureFlagRepository.findOne.mockResolvedValue(mockFlag); + + // Test with matching user + const result1 = await configurationService.evaluateFeatureFlag( + "targeted_flag", + { + userId: "user1", + }, + ); + expect(result1.isEnabled).toBe(true); + + // Test with non-matching user + const result2 = await configurationService.evaluateFeatureFlag( + "targeted_flag", + { + userId: "user3", + }, + ); + expect(result2.isEnabled).toBe(false); + expect(result2.reason).toBe("User does not match targeting rules"); + }); + + it("should handle percentage rollouts", async () => { + const mockFlag = { + id: "flag-id", + name: "percentage_flag", + description: "Test flag", + isEnabled: true, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.USER, + status: FeatureFlagStatus.ACTIVE, + targetingRules: { + percentage: 50, + }, + }; + + mockFeatureFlagRepository.findOne.mockResolvedValue(mockFlag); + + const result = await configurationService.evaluateFeatureFlag( + "percentage_flag", + { + userId: "test-user", + }, + ); + + // Result depends on hash, but should have percentageRollout info + expect(result).toHaveProperty("percentageRollout"); + }); + }); + + describe("setFeatureFlag", () => { + it("should create new feature flag", async () => { + const mockCreatedFlag = { + id: "flag-id", + name: "new_flag", + description: "New feature flag", + isEnabled: true, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.GLOBAL, + status: FeatureFlagStatus.ACTIVE, + }; + + mockFeatureFlagRepository.findOne.mockResolvedValue(null); + mockFeatureFlagRepository.create.mockReturnValue(mockCreatedFlag); + mockFeatureFlagRepository.save.mockResolvedValue(mockCreatedFlag); + + const result = await configurationService.setFeatureFlag( + "new_flag", + "New feature flag", + true, + { + scope: FeatureFlagScope.GLOBAL, + updatedBy: "test-user", + }, + ); + + expect(result).toEqual(mockCreatedFlag); + expect(mockFeatureFlagRepository.create).toHaveBeenCalledWith({ + name: "new_flag", + description: "New feature flag", + isEnabled: true, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.GLOBAL, + updatedBy: "test-user", + }); + }); + + it("should update existing feature flag", async () => { + const existingFlag = { + id: "flag-id", + name: "existing_flag", + description: "Old description", + isEnabled: false, + environment: Environment.DEVELOPMENT, + scope: FeatureFlagScope.GLOBAL, + status: FeatureFlagStatus.ACTIVE, + }; + + const updatedFlag = { + ...existingFlag, + isEnabled: true, + description: "New description", + }; + + mockFeatureFlagRepository.findOne.mockResolvedValue(existingFlag); + mockFeatureFlagRepository.save.mockResolvedValue(updatedFlag); + + const result = await configurationService.setFeatureFlag( + "existing_flag", + "New description", + true, + { + updatedBy: "test-user", + }, + ); + + expect(result).toEqual(updatedFlag); + }); + }); + + describe("validation", () => { + it("should validate required configurations", async () => { + const requiredConfigs = [ + { + key: "REQUIRED_CONFIG_1", + value: "value1", + isRequired: true, + isActive: true, + }, + { + key: "REQUIRED_CONFIG_2", + value: "", + isRequired: true, + isActive: true, + }, + ]; + + mockConfigRepository.find.mockResolvedValue(requiredConfigs); + + // Test the initialize method which calls validateRequiredConfigurations internally + await expect(configurationService.initialize()).rejects.toThrow(); + }); + + it("should check for expired configurations", async () => { + const expiredConfig = { + key: "EXPIRED_CONFIG", + value: "value", + isRequired: false, + isActive: true, + expiresAt: new Date(Date.now() - 1000), // Expired + }; + + mockConfigRepository.find.mockResolvedValue([expiredConfig]); + + // Test the initialize method which calls validateRequiredConfigurations internally + await expect(configurationService.initialize()).rejects.toThrow(); + }); + }); + + describe("cache management", () => { + it("should clear cache", () => { + configurationService.clearCache(); + // No assertions needed, just ensure no errors + }); + + it("should reload configurations", async () => { + const mockConfigs = [ + { + key: "RELOAD_TEST", + value: "test_value", + type: ConfigurationType.STRING, + category: ConfigurationCategory.GENERAL, + isEncrypted: false, + isRequired: false, + }, + ]; + + mockConfigRepository.find.mockResolvedValue(mockConfigs); + + await configurationService.reloadConfigurations(); + + expect(mockConfigRepository.find).toHaveBeenCalledWith({ + where: { + environment: Environment.DEVELOPMENT, + isActive: true, + }, + }); + }); + }); +}); diff --git a/src/types/express.d.ts b/src/types/express.d.ts index dbfcb51..01e5cd3 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,7 +1,5 @@ -import { User } from "../entities/User"; import { UserRole } from "../enums/UserRole"; import { Merchant } from "../interfaces/webhook.interfaces"; -import { MerchantEntity } from "../entities/Merchant.entity"; declare module "express-serve-static-core" { interface Request { @@ -23,5 +21,25 @@ declare module "express-serve-static-core" { validatedIp?: string; tokenExp?: number; requestId?: string; + config?: { + get: ( + key: string, + defaultValue?: string, + ) => Promise | null>; + isFeatureEnabled: ( + flagName: string, + context?: { + userId?: string; + merchantId?: string; + userRole?: string; + }, + ) => Promise; + }; + environment?: string; + appConfig?: { + name: string; + version: string; + environment: string; + }; } }