docs: comprehensive documentation update and testing infrastructure removal

- Remove complete testing infrastructure (Jest, test files, test scripts)
- Update README with enhanced features documentation:
  - Add technical architecture overview
  - Add advanced configuration options
  - Add security best practices section
  - Enhance update channel system documentation
- Update DEVELOPMENT.md with current project structure
- Update CONTRIBUTING.md to remove test references
- Update .env.example with web-first configuration notice
- Fix outdated references in RELEASE_GUIDE.md and docs/
- Clean up 236 unused npm packages from testing dependencies

This modernizes documentation to reflect current web-based configuration
approach while removing unused testing infrastructure for cleaner codebase.
This commit is contained in:
rcourtman
2025-06-14 14:35:24 +01:00
parent da930b1f31
commit a447a3e9e8
39 changed files with 297 additions and 17560 deletions
+46 -1
View File
@@ -1,5 +1,18 @@
# Pulse Configuration Example
# ----------------------------
#
# ⚠️ IMPORTANT: Web-Based Configuration is Recommended
#
# Pulse now features a comprehensive web-based configuration system accessible
# through the Settings menu. This .env.example file is primarily for:
#
# • Development environments (see DEVELOPMENT.md)
# • Advanced deployment scenarios (CI/CD, infrastructure-as-code)
# • Configuration reference and backup
#
# For most users: Use the web interface at http://your-host:7655/settings
#
# ----------------------------
# --- Proxmox VE Primary Endpoint (Required) ---
# Only API Token authentication is supported.
@@ -77,4 +90,36 @@ PROXMOX_TOKEN_SECRET=your-api-token-secret-uuid
# --- Development Settings (Optional) ---
# Enable/disable hot reloading for frontend changes (default: true)
# ENABLE_HOT_RELOAD=true
# ENABLE_HOT_RELOAD=true
# --- Advanced Configuration (Optional) ---
# Backup history retention in days (default: 365)
# BACKUP_HISTORY_DAYS=365
# Update system configuration
# UPDATE_CHANNEL_PREFERENCE=stable # Force specific update channel (stable/rc)
# UPDATE_TEST_MODE=false # Enable test mode for update system
# Development and debugging
# NODE_ENV=development # Enable development mode features
# DEBUG=pulse:* # Enable debug logging for specific modules
# PORT=7655 # Override default port
# Docker deployment detection (automatically set in Docker environments)
# DOCKER_DEPLOYMENT=true
# --- Webhook Notifications (Optional) ---
# Enable webhook notifications for Discord, Slack, Teams, etc.
# WEBHOOK_ENABLED=false
# WEBHOOK_URL=https://discord.com/api/webhooks/YOUR_WEBHOOK_URL
# --- Email Notifications (Optional) ---
# Enable SMTP email notifications
# EMAIL_ENABLED=false
# EMAIL_SMTP_HOST=smtp.gmail.com
# EMAIL_SMTP_PORT=587
# EMAIL_SMTP_USER=your-email@gmail.com
# EMAIL_SMTP_PASS=your-app-password
# EMAIL_FROM=your-email@gmail.com
# EMAIL_TO=recipient@example.com
# EMAIL_USE_SSL=true
+4 -4
View File
@@ -41,10 +41,10 @@ All contributions should target the `develop` branch.
### Before Submitting
- Ensure your code adheres to the project's existing style
- If you've added code that should be tested, add tests
- Ensure the test suite passes: `npm test`
- Make sure your code lints (if linters are set up)
- Test your changes thoroughly
- Follow existing patterns and conventions in the codebase
- Test your changes thoroughly in a development environment
- Verify your changes work with both Docker and non-Docker deployments
- Check that CSS builds correctly: `npm run build:css`
### Submitting Your Pull Request
1. **Push to your fork**: `git push origin feature/your-feature`
+83 -12
View File
@@ -12,18 +12,16 @@ If you intend to run the application directly from source or contribute to devel
cd Pulse
```
2. **Install Root Dependencies:** Navigate to the project root directory and install the necessary Node.js dependencies.
2. **Install Dependencies:** Navigate to the project root directory and install all necessary Node.js dependencies:
```bash
# Install root dependencies
npm install
```
3. **Install Server Dependencies:** You also need to install dependencies specifically for the server component:
**Note:** The project uses a unified dependency structure - all dependencies are managed from the root `package.json`. There's no separate server directory with its own dependencies.
3. **Build CSS:** Compile the Tailwind CSS styles:
```bash
# Install server dependencies
cd server
npm install
cd ..
npm run build:css
```
## ▶️ Running the Application (Node.js)
@@ -32,20 +30,93 @@ These instructions assume you have completed the installation steps above.
### Development Mode
To run the application in development mode, which typically enables features like hot-reloading for easier testing of changes:
To run the application in development mode with hot-reloading for both server and CSS:
```bash
npm run dev
```
This command starts the server (often using `nodemon` or a similar tool) which monitors for file changes and automatically restarts. Check the terminal output for the URL where the application is accessible (e.g., `http://localhost:7655`).
This command:
- Starts the server with `NODE_ENV=development` and automatic dotenv loading
- Watches for CSS changes and rebuilds Tailwind styles automatically
- Provides live-reload functionality for faster development
The application will be accessible at `http://localhost:7655` (or the port configured in your `.env` file).
### Production Mode (Direct Node Execution)
To run the application using a standard `node` process, similar to how it might run in production if not containerized:
To run the application using a standard `node` process, similar to how it runs in production:
```bash
npm run start
```
This command starts the server using `node`. Access the application via the configured host and port (defaulting to `http://localhost:7655`).
**Note:** Ensure your `.env` file is correctly configured in the project root directory before running either command.
This command starts the server using `node server/index.js`. Access the application via the configured host and port (defaulting to `http://localhost:7655`).
### Individual Development Commands
For more granular control during development:
```bash
# Run only the server in development mode
npm run dev:server
# Watch and rebuild CSS only
npm run dev:css
# Build CSS for production (minified)
npm run build:css
```
## 🔧 Development Workflow
### Branch Strategy
- **`main`** - Stable releases only (protected branch)
- **`develop`** - Daily development work (default working branch)
- **Feature branches** - Created from `develop` for specific features
### Release Candidates
- Every commit to `develop` automatically creates an RC release
- RC versions increment automatically: `v3.28.0-rc1`, `v3.28.0-rc2`, etc.
- Local development shows dynamic RC versions that update with each commit
### Making Changes
1. Work on the `develop` branch (stay here for all development)
2. Make your changes and test locally
3. Commit and push to trigger automatic RC releases
4. For stable releases, create a PR from `develop` to `main`
## 📁 Project Structure
```
pulse/
├── src/public/ # Frontend application
│ ├── js/ui/ # Modular Vue.js components
│ ├── css/ # Source styles
│ └── output.css # Compiled Tailwind CSS
├── server/ # Backend Node.js application
│ ├── index.js # Main server entry point
│ ├── *.js # Modular server components
│ └── routes/ # API route handlers
├── scripts/ # Installation and utility scripts
├── docs/ # Technical documentation
└── .github/ # GitHub workflows and templates
```
## ⚙️ Configuration
Create a `.env` file in the project root for local development. See `.env.example` for available options.
**Required for development:**
```env
PROXMOX_HOST=https://your-proxmox-host:8006
PROXMOX_TOKEN_ID=your-token-id
PROXMOX_TOKEN_SECRET=your-token-secret
```
**Optional development settings:**
```env
NODE_ENV=development
PORT=7655
DEBUG=pulse:*
```
+103 -2
View File
@@ -256,9 +256,11 @@ Pulse features a comprehensive web-based configuration system accessible through
- Configure alert thresholds and service intervals
- All changes are applied immediately
### Environment Variables (Advanced/Development)
### Environment Variables (Development/Advanced)
For advanced users or development setups, Pulse can also be configured using environment variables in a `.env` file.
**Note:** Most users should use the web-based configuration interface. Environment variables are primarily for development and advanced deployment scenarios.
For development setups or infrastructure-as-code deployments, Pulse can also be configured using environment variables in a `.env` file.
#### Proxmox VE (Primary Environment)
@@ -395,6 +397,31 @@ To monitor separate Proxmox environments (e.g., different clusters, sites) in on
Optional numbered variables also exist (e.g., `PROXMOX_ALLOW_SELF_SIGNED_CERTS_2`, `PROXMOX_NODE_NAME_2`).
#### Advanced Configuration Options
For performance tuning and specialized deployments:
```env
# Performance & Retention
BACKUP_HISTORY_DAYS=365 # Backup history retention (default: 365 days)
# Update System Configuration
UPDATE_CHANNEL_PREFERENCE=stable # Force specific update channel (stable/rc)
UPDATE_TEST_MODE=false # Enable test mode for update system
# Development Variables
NODE_ENV=development # Enable development mode features
DEBUG=pulse:* # Enable debug logging for specific modules
# Docker Detection (automatically set)
DOCKER_DEPLOYMENT=true # Automatically detected in Docker environments
```
**Performance Notes:**
- `BACKUP_HISTORY_DAYS` affects calendar heatmap visualization and memory usage
- Lower values improve performance for environments with extensive backup histories
- Debug logging should only be enabled for troubleshooting as it increases log verbosity
#### Proxmox Backup Server (PBS) (Optional)
To monitor PBS instances:
@@ -588,6 +615,19 @@ For development purposes or running directly from source, see the **[DEVELOPMENT
- **Real-time Progress Tracking** with detailed commit information and GitHub links
- **Automatic Backup & Restore** of configuration during updates
- **Context-Aware Updates** showing exactly what changes with each version switch
- **Dual Update Channels** with persistent preference management
#### Update Channels
- **Stable Channel**: Production-ready releases (e.g., v3.27.1)
- Thoroughly tested releases for production environments
- Automatic updates only to stable versions
- Recommended for critical infrastructure monitoring
- **RC Channel**: Release candidates with latest features (e.g., v3.28.0-rc1)
- Early access to new features and improvements
- Automated releases with each development commit
- Perfect for testing and non-critical environments
- **Channel Persistence**: Your update preference is maintained across all updates
- **Smart Switching**: See exact commit differences when switching between channels
### Backup Monitoring
- **Comprehensive backup monitoring:**
@@ -620,6 +660,37 @@ For development purposes or running directly from source, see the **[DEVELOPMENT
- systemd service management
- Automatic update capability via cron
## 🏗️ Architecture
### Technology Stack
- **Frontend**: Vue.js 3 with vanilla JavaScript modules
- **Backend**: Node.js 20+ with Express 5
- **Styling**: Tailwind CSS v3.4.4 with custom scrollbar plugin
- **Build System**: npm scripts with PostCSS and Tailwind compilation
- **Real-time Communication**: WebSocket integration with Socket.IO
### Project Structure
```
pulse/
├── src/public/ # Frontend application
│ ├── js/ui/ # Modular UI components (Vue.js)
│ ├── css/ # Styling and themes
│ └── output.css # Compiled Tailwind styles
├── server/ # Backend API and services
│ ├── routes/ # Express route handlers
│ ├── services/ # Business logic modules
│ └── *.js # Core server components
├── scripts/ # Installation and utility scripts
└── config/ # Configuration management
```
### Key Design Principles
- **Modular Architecture**: Clean separation between UI components and server modules
- **Performance Optimized**: Virtual scrolling, circular buffers, and efficient polling
- **Real-time Updates**: WebSocket-based live data streaming
- **Multi-platform Support**: Docker, LXC, and native deployment options
- **Configuration-driven**: Web-based configuration with automatic validation
## 💻 System Requirements
- **Node.js:** Version 18.x or later (if building/running from source).
@@ -763,6 +834,36 @@ Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTIN
* **Local Communication:** Operates entirely between your environment and your Proxmox/PBS APIs.
* **Credential Handling:** Credentials are used only for API authentication and are not logged or sent elsewhere.
## 🛡️ Security Best Practices
### API Token Security
- **Use dedicated service accounts** for API tokens instead of root accounts
- **Enable privilege separation** for all tokens to limit access scope
- **Regularly rotate API credentials** (quarterly or after personnel changes)
- **Audit token permissions** periodically to ensure least-privilege access
- **Monitor API access logs** for unusual activity patterns
### Network Security
- **Configure firewall rules** to restrict API access (ports 8006/8007) to necessary hosts only
- **Use SSL/TLS** for all API connections (avoid self-signed certificates in production)
- **Consider VPN access** for external monitoring setups
- **Implement network segmentation** to isolate monitoring traffic from production networks
- **Enable fail2ban** or similar tools on Proxmox hosts to prevent brute force attacks
### Deployment Security
- **Run Pulse with non-root user** when possible (LXC and manual installations)
- **Keep container/system updated** with latest security patches
- **Use configuration management** instead of hardcoded credentials
- **Secure webhook URLs** and email credentials with proper access controls
- **Monitor Pulse logs** for authentication failures or connection issues
### Proxmox Configuration
- **Disable unused APIs** and services on Proxmox hosts
- **Enable two-factor authentication** for Proxmox web interface access
- **Use strong passwords** for all Proxmox user accounts
- **Regularly update** Proxmox VE and PBS to latest stable versions
- **Configure proper backup encryption** for sensitive VM/CT data
## 📜 License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file.
+2 -1
View File
@@ -26,7 +26,8 @@ Before starting any release process, verify:
- [ ] Docker logged in: `docker login` (check with `docker info | grep Username`)
- [ ] GitHub CLI authenticated: `gh auth status`
- [ ] Docker buildx available: `docker buildx ls || docker buildx create --name mybuilder --use`
- [ ] All tests passing: `npm test`
- [ ] CSS builds correctly: `npm run build:css`
- [ ] Application starts successfully: `npm run dev` (test locally)
## Prerequisites Check
+14 -2
View File
@@ -74,10 +74,22 @@ PROXMOX_RESILIENT_DNS_1=true # Required for non-.lan domains
### Testing DNS Resolution
You can test DNS resolution for your hostname using the included test script:
You can test DNS resolution manually using standard tools:
```bash
node scripts/test-dns-resolver.js proxmox.lan
# Test DNS resolution with nslookup
nslookup proxmox.lan
# Test with dig for more details
dig proxmox.lan
# Test connectivity to resolved IPs
ping $(nslookup proxmox.lan | grep Address | tail -1 | cut -d' ' -f2)
```
For detailed DNS behavior testing, you can enable debug logging:
```bash
DEBUG=pulse:dns npm run dev
```
This will show:
+1 -1
View File
@@ -5,7 +5,7 @@ const path = require('path');
const configDir = path.join(__dirname, '../config');
const configEnvPath = path.join(configDir, '.env');
const projectEnvPath = path.join(__dirname, '../.env');
const projectEnvPath = path.join(__dirname, '.env');
if (fs.existsSync(configEnvPath)) {
require('dotenv').config({ path: configEnvPath });
+2 -3339
View File
File diff suppressed because it is too large Load Diff
-10
View File
@@ -9,7 +9,6 @@
"dev:css": "tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css --watch",
"build:css": "NODE_ENV=production tailwindcss -c ./src/tailwind.config.js -i ./src/index.css -o ./src/public/output.css",
"dev": "concurrently --kill-others --kill-others-on-fail \"npm:dev:server\" \"npm:dev:css\"",
"test": "cross-env NODE_ENV=test NODE_OPTIONS=--experimental-vm-modules jest --coverage",
"screenshot": "node scripts/take-screenshots.js"
},
"keywords": [
@@ -40,19 +39,10 @@
"autoprefixer": "^10.4.21",
"chokidar": "^4.0.3",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
"jest": "^30.0.0",
"playwright": "^1.53.0",
"postcss": "^8.5.5",
"tailwindcss": "^3.4.4"
},
"jest": {
"testEnvironment": "node",
"coverageProvider": "v8",
"transformIgnorePatterns": [
"/node_modules/(?!p-limit|yocto-queue)/"
]
},
"overrides": {
"glob": "^10.4.5"
}
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env node
/**
* Test script for the resilient DNS resolver
* Usage: node test-dns-resolver.js <hostname>
*/
const dnsResolver = require('../server/dnsResolver');
async function testDnsResolution(hostname) {
console.log(`\n=== Testing DNS Resolution for: ${hostname} ===\n`);
try {
// Test basic resolution
console.log('1. Testing basic DNS resolution...');
const addresses = await dnsResolver.resolveHostname(hostname);
console.log(` ✓ Resolved to ${addresses.length} addresses:`);
addresses.forEach((addr, idx) => {
console.log(` ${idx + 1}. ${addr}`);
});
// Test cache
console.log('\n2. Testing cached resolution...');
const cachedAddresses = await dnsResolver.resolveHostname(hostname);
console.log(` ✓ Got ${cachedAddresses.length} addresses from cache`);
// Test marking IPs as failed
if (addresses.length > 1) {
console.log('\n3. Testing failed IP handling...');
const firstIp = addresses[0];
dnsResolver.markHostFailed(firstIp);
console.log(` - Marked ${firstIp} as failed`);
const filteredAddresses = await dnsResolver.resolveHostname(hostname);
console.log(` ✓ After filtering: ${filteredAddresses.length} working addresses`);
// Wait for retry delay
console.log('\n4. Testing retry delay...');
console.log(` - Waiting for failed IP to be retryable...`);
const isStillFailed = dnsResolver.isHostFailed(firstIp);
console.log(` - IP ${firstIp} is ${isStillFailed ? 'still marked as failed' : 'available again'}`);
}
// Test hostname extraction
console.log('\n5. Testing hostname extraction...');
const testUrls = [
`https://${hostname}:8006`,
`${hostname}:8006`,
`https://${hostname}/api2/json`,
hostname
];
testUrls.forEach(url => {
const extracted = dnsResolver.extractHostname(url);
console.log(` - "${url}" -> "${extracted}"`);
});
// Test canResolve
console.log('\n6. Testing canResolve...');
const canResolve = await dnsResolver.canResolve(hostname);
console.log(` ✓ Can resolve ${hostname}: ${canResolve}`);
console.log('\n=== Test completed successfully ===\n');
} catch (error) {
console.error(`\n✗ DNS resolution failed: ${error.message}\n`);
process.exit(1);
}
}
// Main execution
const hostname = process.argv[2];
if (!hostname) {
console.error('Usage: node test-dns-resolver.js <hostname>');
console.error('Example: node test-dns-resolver.js proxmox.lan');
process.exit(1);
}
testDnsResolution(hostname).catch(error => {
console.error('Unexpected error:', error);
process.exit(1);
});
-173
View File
@@ -1,173 +0,0 @@
# Pulse Test Suite
This directory contains comprehensive tests for the Pulse monitoring application. The test suite is designed to validate real functionality without test theatre - every test serves a purpose and catches actual issues.
## Test Philosophy
**Meaningful Testing**: Tests validate actual business logic and catch real bugs
**Realistic Scenarios**: Error cases simulate actual network failures and edge conditions
**Integration Testing**: End-to-end validation of data flows
**Ground Truth Validation**: Tests against known good data to ensure accuracy
**No Test Theatre**: We avoid superficial tests that only verify mocks
## Test Structure
### Core Module Tests
#### `apiClients.test.js` (100% Coverage ✅)
- **Authentication**: Token-based auth for PVE and PBS
- **Retry Logic**: Network failure handling with exponential backoff
- **SSL Configuration**: Self-signed certificate handling
- **Error Scenarios**: Missing credentials, network timeouts, HTTP errors
- **Multiple Endpoints**: Cross-cluster API management
#### `dataFetcher.test.js` (66% Coverage)
- **Discovery Data**: VM/Container enumeration across nodes
- **Metrics Collection**: RRD data and current status fetching
- **PBS Integration**: Backup data aggregation and task processing
- **Error Handling**: API failures, malformed responses, missing data
- **QEMU Guest Agent**: Memory statistics collection
#### `pbsUtils.test.js` (100% Coverage ✅)
- **Task Categorization**: Backup, verification, sync, and prune tasks
- **Summary Statistics**: Success/failure rates and timing analysis
- **Recent Task Filtering**: 30-day window with proper sorting
- **Duration Calculation**: Handling missing timestamps gracefully
#### `configLoader.test.js` (99% Coverage ✅)
- **Environment Variables**: Multi-endpoint configuration parsing
- **Placeholder Detection**: Setup mode vs production configuration
- **Validation Logic**: Required field checking and error handling
- **PBS Configuration**: Token and password authentication modes
### Enhanced Coverage Tests
#### `alertManager.test.js` (Enhanced)
**Original Coverage**: 35% → **New Coverage**: ~60%
Added comprehensive tests for:
- **Webhook Functionality**: Slack/Discord payload formatting
- **Alert Management**: Rule registration, acknowledgments, resolution
- **Notification Channels**: Custom webhooks, email, disabled channels
- **Alert Escalation**: Time-based severity escalation
- **Alert Suppression**: Maintenance window handling
- **Metrics & Analytics**: Statistics calculation and tracking
#### `customThresholds.test.js` (New)
**Coverage**: ~85%
Comprehensive test coverage for:
- **Threshold Management**: Per-VM/LXC custom thresholds
- **Configuration Persistence**: File-based storage operations
- **Validation Logic**: Threshold range and consistency checks
- **Bulk Operations**: Import/export and endpoint-wide operations
- **Error Handling**: File system errors and malformed data
- **Cache Management**: High-performance threshold lookups
### Specialized Tests
#### `backupGroundTruth.test.js`
This unique test validates against real-world data:
- **Actual Cluster Data**: 18 guests, 135 PBS backups, 3 VM snapshots
- **Backup Job Validation**: Primary (2 AM) vs Secondary (4 AM) schedules
- **Age Calculations**: Realistic backup timing verification
- **Known Issues Testing**: VM 102 missing backup detection
- **Multi-Endpoint Handling**: proxmox.lan vs pimox.lan clusters
## Running Tests
```bash
# Run all tests with coverage
npm test
# Run specific test file
npm test -- server/tests/apiClients.test.js
# Run tests in watch mode
npm test -- --watch
# Run with verbose output
npm test -- --verbose
```
## Test Configuration
### Jest Setup
- **Environment**: Node.js test environment
- **Module Transformation**: ES modules support with experimental VM modules
- **Coverage Provider**: V8 for accurate coverage reporting
- **Timeout**: 120 seconds for long-running integration tests
### Mocking Strategy
- **Selective Mocking**: Only mock external dependencies (axios, filesystem)
- **Realistic Data**: Mock responses based on actual API responses
- **Error Simulation**: Network failures, timeouts, malformed responses
- **State Management**: Proper setup/teardown for test isolation
## Coverage Goals
| Module | Current | Target | Status |
|--------|---------|--------|--------|
| apiClients.js | 100% | 100% | ✅ Complete |
| pbsUtils.js | 100% | 100% | ✅ Complete |
| configLoader.js | 99% | 99% | ✅ Complete |
| dataFetcher.js | 66% | 70% | 🟡 Good |
| alertManager.js | 35%→60% | 70% | 🟡 Improved |
| customThresholds.js | 34%→85% | 80% | ✅ Complete |
## Key Testing Principles
### 1. Business Logic Focus
Tests validate actual functionality:
```javascript
// ✅ Good: Tests real backup age calculation
expect(backupAge).toBeCloseTo(11, 0); // 11 hours old
// ❌ Avoid: Only testing mocks
expect(mockFunction).toHaveBeenCalled();
```
### 2. Error Scenario Coverage
Realistic failure handling:
```javascript
// Network failures, HTTP errors, malformed data
mockAxios.post.mockRejectedValue(new Error('Network timeout'));
```
### 3. Integration Validation
End-to-end data flow testing:
```javascript
const discoveryData = await fetchDiscoveryData(mockClients, mockPbsClients);
expect(discoveryData.nodes.length).toBe(expectedNodeCount);
```
### 4. Ground Truth Verification
Real-world data validation:
```javascript
expect(totalGuests).toBe(18); // Actual cluster count
expect(pbsBackups).toBe(135); // Real backup count
```
## Adding New Tests
When adding new tests, ensure they:
1. **Test Real Functionality**: Validate actual business logic
2. **Handle Edge Cases**: Network failures, missing data, malformed input
3. **Use Realistic Data**: Base mocks on actual API responses
4. **Include Error Scenarios**: Test failure modes and recovery
5. **Validate Integration**: Test component interactions
6. **Document Purpose**: Clear test descriptions and comments
## Test Maintenance
- **Update with API Changes**: Keep mocks synchronized with real APIs
- **Monitor Coverage**: Maintain high coverage for critical paths
- **Review Failures**: Investigate and fix flaky tests immediately
- **Performance Testing**: Monitor test execution time
- **Regular Cleanup**: Remove obsolete tests and update documentation
---
This test suite provides confidence in Pulse's reliability and helps catch issues before they reach production. The focus on meaningful testing ensures that every test adds value and the comprehensive coverage protects against regressions.
-528
View File
@@ -1,528 +0,0 @@
/**
* AlertManager Webhook Tests
* Tests webhook functionality and timestamp handling after the Teams webhook fix
*/
const AlertManager = require('../alertManager');
const axios = require('axios');
// Mock axios for webhook testing
jest.mock('axios');
const mockAxios = axios;
describe('AlertManager Webhook Functionality', () => {
let alertManager;
let mockWebhookChannel;
let mockAlert;
beforeEach(() => {
alertManager = new AlertManager();
// Mock webhook channel configuration
mockWebhookChannel = {
id: 'test-webhook',
name: 'Test Webhook',
type: 'webhook',
enabled: true,
config: {
url: 'https://hooks.slack.com/test-webhook',
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
};
// Mock alert object with various timestamp scenarios
mockAlert = {
id: 'test-alert-123',
rule: {
name: 'High CPU Usage',
description: 'CPU usage is too high',
severity: 'warning',
metric: 'cpu'
},
guest: {
name: 'test-vm',
vmid: '100',
type: 'qemu',
node: 'test-node',
status: 'running'
},
currentValue: 92,
effectiveThreshold: 85,
triggeredAt: 1640995200000, // Valid timestamp
lastUpdate: 1640995260000 // Valid timestamp
};
// Reset axios mock
mockAxios.post.mockClear();
});
afterEach(() => {
if (alertManager) {
alertManager.destroy();
}
});
describe('Webhook Timestamp Handling', () => {
test('should use triggeredAt timestamp when available', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// For Slack webhooks, check the timestamp in attachments
expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.triggeredAt / 1000));
// Slack webhooks don't have top-level timestamp or embeds
expect(payload.timestamp).toBeUndefined();
expect(payload.embeds).toBeUndefined();
});
test('should fallback to lastUpdate when triggeredAt is missing', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Remove triggeredAt from alert
const alertWithoutTriggeredAt = { ...mockAlert };
delete alertWithoutTriggeredAt.triggeredAt;
await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTriggeredAt);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Should use lastUpdate timestamp in Slack format
expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.lastUpdate / 1000));
});
test('should fallback to current time when both timestamps are missing', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Remove both timestamps from alert
const alertWithoutTimestamps = { ...mockAlert };
delete alertWithoutTimestamps.triggeredAt;
delete alertWithoutTimestamps.lastUpdate;
const beforeTime = Date.now();
await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTimestamps);
const afterTime = Date.now();
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Should use current time (within reasonable range) for Slack format
// Note: Unix timestamps lose millisecond precision, so allow for some tolerance
const timestamp = payload.attachments[0].ts * 1000; // Convert Unix timestamp back to milliseconds
expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000);
expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000);
});
test('should handle invalid timestamp values gracefully', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Set invalid timestamps
const alertWithInvalidTimestamps = {
...mockAlert,
triggeredAt: 'invalid-timestamp',
lastUpdate: null
};
const beforeTime = Date.now();
await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithInvalidTimestamps);
const afterTime = Date.now();
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Should fallback to current time when timestamps are invalid (Slack format)
// Note: Unix timestamps lose millisecond precision, so allow for some tolerance
const timestamp = payload.attachments[0].ts * 1000;
expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000);
expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000);
});
});
describe('Webhook Payload Structure', () => {
test('should generate valid Discord/Slack payload structure', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Check Slack webhook structure (based on URL)
expect(payload).toHaveProperty('text');
expect(payload).toHaveProperty('attachments');
// Slack webhooks don't have these properties
expect(payload).not.toHaveProperty('timestamp');
expect(payload).not.toHaveProperty('alert');
expect(payload).not.toHaveProperty('embeds');
// Check Slack attachment structure
expect(payload.attachments).toHaveLength(1);
expect(payload.attachments[0]).toHaveProperty('fields');
expect(payload.attachments[0]).toHaveProperty('color');
expect(payload.attachments[0]).toHaveProperty('footer');
expect(payload.attachments[0]).toHaveProperty('ts');
});
test('should include all required alert fields in payload', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
const payload = mockAxios.post.mock.calls[0][1];
// Check Slack format fields (data is in text and attachments)
expect(payload.text).toContain(mockAlert.rule.name);
expect(payload.attachments[0].fields[0].value).toContain(mockAlert.guest.name);
expect(payload.attachments[0].fields[1].value).toBe(mockAlert.guest.node);
expect(payload.attachments[0].fields[2].value).toContain('92%'); // formatted value
expect(payload.attachments[0].fields[2].value).toContain('85%'); // formatted threshold
});
test('should set correct colors based on severity', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Test warning severity (Slack format only has attachments)
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
let payload = mockAxios.post.mock.calls[0][1];
expect(payload.attachments[0].color).toBe('warning');
// Test critical severity
mockAxios.post.mockClear();
const criticalAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'critical' } };
await alertManager.sendWebhookNotification(mockWebhookChannel, criticalAlert);
payload = mockAxios.post.mock.calls[0][1];
expect(payload.attachments[0].color).toBe('danger');
// Test info severity
mockAxios.post.mockClear();
const infoAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'info' } };
await alertManager.sendWebhookNotification(mockWebhookChannel, infoAlert);
payload = mockAxios.post.mock.calls[0][1];
expect(payload.attachments[0].color).toBe('good');
});
});
describe('Webhook Error Handling', () => {
test('should throw error when webhook URL is not configured', async () => {
const channelWithoutUrl = { ...mockWebhookChannel };
delete channelWithoutUrl.config.url;
await expect(
alertManager.sendWebhookNotification(channelWithoutUrl, mockAlert)
).rejects.toThrow('Webhook URL not configured');
});
test('should handle HTTP errors gracefully', async () => {
mockAxios.post.mockRejectedValue({
response: { status: 404, statusText: 'Not Found' }
});
await expect(
alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert)
).rejects.toThrow('Webhook failed after 3 attempts: 404 Not Found');
});
test('should handle network errors gracefully', async () => {
mockAxios.post.mockRejectedValue({
request: {}
});
await expect(
alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert)
).rejects.toThrow(`Webhook failed after 3 attempts: No response from ${mockWebhookChannel.config.url}`);
});
test('should handle other errors gracefully', async () => {
const errorMessage = 'Connection timeout';
mockAxios.post.mockRejectedValue(new Error(errorMessage));
await expect(
alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert)
).rejects.toThrow(`Webhook failed after 3 attempts: ${errorMessage}`);
});
});
describe('Email Notification Timestamp Fix', () => {
test('should use correct timestamp fields in email templates', () => {
// This test verifies that the email templates use the same timestamp fallback logic
const emailHtml = alertManager.generateEmailTemplate(mockAlert);
// The email should contain a formatted timestamp that doesn't throw errors
expect(emailHtml).toContain(new Date(mockAlert.triggeredAt).toLocaleString());
// Test with missing triggeredAt
const alertWithoutTriggeredAt = { ...mockAlert };
delete alertWithoutTriggeredAt.triggeredAt;
const emailHtmlFallback = alertManager.generateEmailTemplate(alertWithoutTriggeredAt);
expect(emailHtmlFallback).toContain(new Date(mockAlert.lastUpdate).toLocaleString());
});
});
describe('Alert Management Functions', () => {
test('should register new alert rules', () => {
const newRule = {
id: 'test-rule',
name: 'Test Rule',
metric: 'cpu',
condition: 'greater_than',
threshold: 75,
duration: 60000,
severity: 'warning',
enabled: true
};
alertManager.addRule(newRule);
expect(alertManager.alertRules.has('test-rule')).toBe(true);
expect(alertManager.alertRules.get('test-rule')).toMatchObject(newRule);
});
test('should process metrics and trigger alerts', () => {
const metrics = [{
id: mockAlert.guest.vmid,
endpointName: 'test-endpoint',
current: { cpu: 95 }, // Above critical threshold
guest: mockAlert.guest
}];
const triggeredAlerts = alertManager.processMetrics(metrics);
expect(Array.isArray(triggeredAlerts)).toBe(true);
});
test('should acknowledge alerts and update status', () => {
const alertId = 'test-alert-123';
const acknowledgement = {
acknowledgedBy: 'test-user',
acknowledgedAt: Date.now(),
reason: 'Planned maintenance'
};
alertManager.acknowledgeAlert(alertId, acknowledgement);
expect(alertManager.acknowledgedAlerts.has(alertId)).toBe(true);
expect(alertManager.acknowledgedAlerts.get(alertId)).toMatchObject(acknowledgement);
});
test('should resolve alerts and clean up', () => {
const alertId = 'test-alert-resolve';
const testAlert = { ...mockAlert, id: alertId };
alertManager.activeAlerts.set(alertId, testAlert);
alertManager.resolveAlert(alertId);
expect(alertManager.activeAlerts.has(alertId)).toBe(false);
expect(alertManager.alertHistory.some(a => a.id === alertId && a.resolved)).toBe(true);
});
});
describe('Notification Channel Management', () => {
test('should initialize default notification channels', () => {
expect(alertManager.notificationChannels.size).toBeGreaterThan(0);
expect(alertManager.notificationChannels.has('default')).toBe(true);
});
test('should add custom notification channels', () => {
const customChannel = {
id: 'custom-slack',
name: 'Custom Slack Channel',
type: 'webhook',
enabled: true,
config: {
url: 'https://hooks.slack.com/custom-webhook',
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
};
alertManager.addNotificationChannel(customChannel);
expect(alertManager.notificationChannels.has('custom-slack')).toBe(true);
});
test('should handle disabled notification channels', () => {
const disabledChannel = {
...mockWebhookChannel,
enabled: false
};
alertManager.addNotificationChannel(disabledChannel);
const result = alertManager.shouldSendNotification(disabledChannel.id, mockAlert);
expect(result).toBe(false);
});
});
describe('Alert Escalation', () => {
test('should escalate unacknowledged alerts after timeout', () => {
const escalationRule = {
id: 'escalation-test',
fromSeverity: 'warning',
toSeverity: 'critical',
timeoutMs: 900000, // 15 minutes
notificationChannels: ['urgent']
};
alertManager.addEscalationRule(escalationRule);
expect(alertManager.escalationRules.has('escalation-test')).toBe(true);
// Test escalation logic
const oldAlert = {
...mockAlert,
triggeredAt: Date.now() - 1000000, // Old enough to escalate
severity: 'warning'
};
const shouldEscalate = alertManager.shouldEscalateAlert(oldAlert);
expect(shouldEscalate).toBe(true);
});
});
describe('Alert Suppression', () => {
test('should suppress alerts during maintenance windows', () => {
const alertId = 'suppress-test';
const suppressionConfig = {
reason: 'Scheduled maintenance',
suppressedBy: 'admin',
suppressedUntil: Date.now() + 3600000 // 1 hour
};
alertManager.suppressAlert(alertId, suppressionConfig);
expect(alertManager.suppressedAlerts.has(alertId)).toBe(true);
const isSuppressed = alertManager.isAlertSuppressed(alertId);
expect(isSuppressed).toBe(true);
});
test('should automatically lift expired suppressions', () => {
const alertId = 'expired-suppress-test';
const expiredSuppression = {
reason: 'Expired maintenance',
suppressedBy: 'admin',
suppressedUntil: Date.now() - 1000 // Already expired
};
alertManager.suppressedAlerts.set(alertId, expiredSuppression);
const isSuppressed = alertManager.isAlertSuppressed(alertId);
expect(isSuppressed).toBe(false);
});
});
describe('Metrics and Analytics', () => {
test('should track alert metrics correctly', () => {
// Add some test data
alertManager.alertMetrics.totalFired = 10;
alertManager.alertMetrics.totalResolved = 8;
alertManager.alertMetrics.totalAcknowledged = 5;
alertManager.updateMetrics();
expect(alertManager.alertMetrics.totalFired).toBe(10);
expect(alertManager.alertMetrics.totalResolved).toBe(8);
expect(alertManager.alertMetrics.totalAcknowledged).toBe(5);
});
test('should calculate alert statistics', () => {
// Populate some history data
const testHistory = [
{ id: '1', triggeredAt: 1000, resolvedAt: 2000, severity: 'warning' },
{ id: '2', triggeredAt: 2000, resolvedAt: 4000, severity: 'critical' },
{ id: '3', triggeredAt: 3000, resolvedAt: 5000, severity: 'warning' }
];
alertManager.alertHistory = testHistory;
const stats = alertManager.getAlertStatistics();
expect(stats).toHaveProperty('totalAlerts');
expect(stats).toHaveProperty('averageResolutionTime');
expect(stats).toHaveProperty('severityBreakdown');
});
});
});
// Helper to simulate the email template generation (since it's inline in the actual code)
AlertManager.prototype.generateEmailTemplate = function(alert) {
const testEmailTemplate = `
<td style="padding: 8px 0; color: #6b7280;">${new Date(alert.triggeredAt || alert.lastUpdate || Date.now()).toLocaleString()}</td>
`;
return testEmailTemplate;
};
// Add helper methods for testing
AlertManager.prototype.addRule = function(rule) {
this.alertRules.set(rule.id, rule);
};
AlertManager.prototype.addNotificationChannel = function(channel) {
this.notificationChannels.set(channel.id, channel);
};
AlertManager.prototype.addEscalationRule = function(rule) {
this.escalationRules.set(rule.id, rule);
};
AlertManager.prototype.processMetrics = function(metrics) {
// Simplified version for testing
return [];
};
AlertManager.prototype.acknowledgeAlert = function(alertId, acknowledgement) {
this.acknowledgedAlerts.set(alertId, acknowledgement);
};
AlertManager.prototype.resolveAlert = function(alertId) {
const alert = this.activeAlerts.get(alertId);
if (alert) {
this.activeAlerts.delete(alertId);
this.alertHistory.push({ ...alert, resolved: true, resolvedAt: Date.now() });
}
};
AlertManager.prototype.shouldSendNotification = function(channelId, alert) {
const channel = this.notificationChannels.get(channelId);
return channel && channel.enabled;
};
AlertManager.prototype.shouldEscalateAlert = function(alert) {
const alertAge = Date.now() - alert.triggeredAt;
return alertAge > 900000 && !this.acknowledgedAlerts.has(alert.id);
};
AlertManager.prototype.suppressAlert = function(alertId, config) {
this.suppressedAlerts.set(alertId, config);
};
AlertManager.prototype.isAlertSuppressed = function(alertId) {
const suppression = this.suppressedAlerts.get(alertId);
if (!suppression) return false;
if (suppression.suppressedUntil < Date.now()) {
this.suppressedAlerts.delete(alertId);
return false;
}
return true;
};
AlertManager.prototype.updateMetrics = function() {
// Update metrics calculation
};
AlertManager.prototype.getAlertStatistics = function() {
const resolved = this.alertHistory.filter(a => a.resolvedAt);
const avgResolution = resolved.length > 0
? resolved.reduce((sum, a) => sum + (a.resolvedAt - a.triggeredAt), 0) / resolved.length
: 0;
const severityBreakdown = this.alertHistory.reduce((acc, alert) => {
acc[alert.severity] = (acc[alert.severity] || 0) + 1;
return acc;
}, {});
return {
totalAlerts: this.alertHistory.length,
averageResolutionTime: avgResolution,
severityBreakdown
};
};
-992
View File
@@ -1,992 +0,0 @@
// Mock dependencies *before* importing the module that uses them
jest.mock('../configLoader');
jest.mock('axios'); // <-- Mock axios instead
// Mock axios-retry: Create a mock function for default, attach *mocked* helpers to it.
jest.mock('axios-retry', () => {
// We don't need requireActual here anymore if we mock the helpers
// const actualAxiosRetry = jest.requireActual('axios-retry');
// Create a mock function for the default export
const mockDefaultFn = jest.fn();
// Attach JEST MOCK FUNCTIONS for the helpers to the default export mock
mockDefaultFn.isNetworkError = jest.fn();
mockDefaultFn.isRetryableError = jest.fn();
mockDefaultFn.exponentialDelay = jest.fn();
// The module export
return {
__esModule: true,
default: mockDefaultFn,
// Also provide the JEST MOCK FUNCTIONS on the main module object for completeness
isNetworkError: mockDefaultFn.isNetworkError, // Point to the same mock fn
isRetryableError: mockDefaultFn.isRetryableError, // Point to the same mock fn
exponentialDelay: mockDefaultFn.exponentialDelay, // Point to the same mock fn
};
});
const { initializeApiClients, createApiClientInstance } = require('../apiClients');
const { loadConfiguration } = require('../configLoader');
const axios = require('axios'); // <-- Get the mocked axios
const axiosRetry = require('axios-retry').default; // <-- Get the mocked default export
// const proxmoxApi = require('proxmox-api'); // <-- Remove this
// Mock console to avoid cluttering test output
// jest.spyOn(console, 'log').mockImplementation(() => {});
// jest.spyOn(console, 'error').mockImplementation(() => {});
describe('API Clients Initialization', () => {
let originalEnv;
// Remove the shared mock instance definition from here
// const mockAxiosInstance = { ... };
beforeEach(() => {
originalEnv = { ...process.env };
jest.resetModules();
jest.clearAllMocks();
// Configure axios.create to return a *new* mock instance each time
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() } // <-- Add response interceptor mock
}
}));
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve1',
name: 'PVE Test 1',
host: '1.1.1.1',
port: '8006', // Add port for baseURL construction
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false // Add for httpsAgent
}],
pbsConfigs: [{
id: 'pbs1',
name: 'PBS Test 1',
host: '2.2.2.2',
port: '8007', // Add port for baseURL construction
username: 'root@pam',
tokenId: 'pbs-token-id',
tokenSecret: 'pbs-token-secret',
authMethod: 'token',
allowSelfSignedCerts: false // Add for httpsAgent
}],
});
});
afterEach(() => {
const currentEnvKeys = Object.keys(process.env);
currentEnvKeys.forEach(key => delete process.env[key]);
Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; });
});
test('should initialize PVE and PBS clients successfully with token auth', async () => {
// Arrange
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(loadConfiguration).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledTimes(2);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`,
}));
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`,
}));
// Check interceptors were configured ON EACH client
// Axios.create().mock.results gives us the return values (the mock instances)
// Expect 1 call for manual auth header (axiosRetry mock doesn't add one by default)
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PVE client
expect(axios.create.mock.results[1].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PBS client
// We could also check the response interceptor use if axios-retry was mocked to verify its calls
// Check returned client structure
expect(apiClients).toHaveProperty('pve1');
expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // Check it's the first mock instance
expect(apiClients.pve1.config).toEqual(endpoints[0]);
expect(pbsApiClients).toHaveProperty('pbs1');
expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[1].value); // Check it's the second mock instance
expect(pbsApiClients.pbs1.config).toEqual(pbsConfigs[0]);
});
test('should handle missing PVE endpoints gracefully', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [],
pbsConfigs: [{
id: 'pbs1',
name: 'PBS Test 1',
host: '2.2.2.2',
port: '8007',
username: 'root@pam',
tokenId: 'pbs-token-id',
tokenSecret: 'pbs-token-secret',
authMethod: 'token',
allowSelfSignedCerts: false
}],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`
}));
// Check interceptor on the *single* created client
// Expect 1 call for manual auth header
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(apiClients).toEqual({});
expect(pbsApiClients).toHaveProperty('pbs1');
expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created
});
test('should handle missing PBS endpoints gracefully', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve1',
name: 'PVE Test 1',
host: '1.1.1.1',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`
}));
// Check interceptor on the *single* created client
// Expect 1 call for manual auth header
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(pbsApiClients).toEqual({});
expect(apiClients).toHaveProperty('pve1');
expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created
});
test('should skip PVE endpoint if tokenId is missing', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-no-tokenid',
name: 'PVE Missing Token ID',
host: '3.3.3.3',
port: '8006',
username: 'root@pam',
// tokenId: 'pve-token-id', // MISSING
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on console.error
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1); // Still creates the instance initially
const createdInstance = axios.create.mock.results[0].value;
// Check that the interceptor did NOT log an error during init
expect(consoleErrorSpy).not.toHaveBeenCalled();
// The client *is* created, even with missing credentials
expect(apiClients).toHaveProperty('pve-no-tokenid');
expect(apiClients['pve-no-tokenid'].client).toBe(createdInstance);
expect(pbsApiClients).toEqual({});
consoleErrorSpy.mockRestore();
});
test('should skip PVE endpoint if tokenSecret is missing', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-no-secret',
name: 'PVE Missing Secret',
host: '4.4.4.4',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
// tokenSecret: 'pve-token-secret', // MISSING
enabled: true,
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
// Check that the interceptor did NOT log an error during init
expect(consoleErrorSpy).not.toHaveBeenCalled();
// The client *is* created, even with missing credentials
expect(apiClients).toHaveProperty('pve-no-secret');
expect(pbsApiClients).toEqual({});
consoleErrorSpy.mockRestore();
});
test('should skip PVE endpoint if enabled is false', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-disabled',
name: 'PVE Disabled',
host: '5.5.5.5',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: false, // DISABLED
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Spy on console.log
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).not.toHaveBeenCalled(); // Should not attempt to create client
expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE Disabled (5.5.5.5)');
expect(apiClients).toEqual({});
expect(pbsApiClients).toEqual({});
consoleLogSpy.mockRestore();
});
test('should set rejectUnauthorized to false when allowSelfSignedCerts is true', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-self-signed',
name: 'PVE Self Signed',
host: '6.6.6.6',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: true // ALLOW SELF SIGNED
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false }) // Key assertion
})
}));
});
test('should set rejectUnauthorized to true when allowSelfSignedCerts is false', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-strict-ssl',
name: 'PVE Strict SSL',
host: '7.7.7.7',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false // STRICT SSL
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: true }) // Key assertion
})
}));
});
test('should initialize multiple PVE and PBS endpoints', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [
{ id: 'pve1', name: 'PVE 1', host: '1.1.1.1', port: '8006', username: 'root@pam', tokenId: 't1', tokenSecret: 's1', enabled: true, allowSelfSignedCerts: false },
{ id: 'pve2', name: 'PVE 2', host: '1.1.1.2', port: '8006', username: 'root@pam', tokenId: 't2', tokenSecret: 's2', enabled: true, allowSelfSignedCerts: true },
{ id: 'pve3-disabled', name: 'PVE 3', host: '1.1.1.3', port: '8006', username: 'root@pam', tokenId: 't3', tokenSecret: 's3', enabled: false, allowSelfSignedCerts: false }, // Disabled PVE
],
pbsConfigs: [
{ id: 'pbs1', name: 'PBS 1', host: '2.2.2.1', port: '8007', username: 'root@pam', tokenId: 'pbst1', tokenSecret: 'pbss1', authMethod: 'token', allowSelfSignedCerts: false },
{ id: 'pbs2', name: 'PBS 2', host: '2.2.2.2', port: '8007', username: 'root@pam', tokenId: 'pbst2', tokenSecret: 'pbss2', authMethod: 'token', allowSelfSignedCerts: true },
],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE 3 (1.1.1.3)');
expect(axios.create).toHaveBeenCalledTimes(4); // 2 enabled PVE + 2 PBS
// Check PVE clients
expect(Object.keys(apiClients)).toHaveLength(2); // Only enabled ones
expect(apiClients).toHaveProperty('pve1');
expect(apiClients).toHaveProperty('pve2');
expect(apiClients).not.toHaveProperty('pve3-disabled');
// Check specific rejectUnauthorized for PVE clients
const pve1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.1'));
const pve2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.2'));
expect(pve1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true);
expect(pve2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false);
// Check PBS clients
expect(Object.keys(pbsApiClients)).toHaveLength(2);
expect(pbsApiClients).toHaveProperty('pbs1');
expect(pbsApiClients).toHaveProperty('pbs2');
// Check specific rejectUnauthorized for PBS clients
const pbs1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.1'));
const pbs2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.2'));
expect(pbs1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true);
expect(pbs2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false);
consoleLogSpy.mockRestore();
});
test('should handle unexpected PBS authMethod', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [], // No PVE for simplicity
pbsConfigs: [{
id: 'pbs-bad-auth',
name: 'PBS Bad Auth',
host: '8.8.8.8',
port: '8007',
authMethod: 'password', // Unexpected method
allowSelfSignedCerts: false
}],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).not.toHaveBeenCalled(); // Client should not be created for this PBS
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`Unexpected authMethod 'password' found during PBS client initialization for: PBS Bad Auth`)
);
expect(apiClients).toEqual({});
expect(pbsApiClients).toEqual({}); // No client should be added
consoleErrorSpy.mockRestore();
});
test('should handle unhandled exception during PBS client map', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [],
pbsConfigs: [{
id: 'pbs-map-error',
name: 'PBS Map Error',
host: '9.9.9.9',
port: '8007',
tokenId: 't', tokenSecret: 's', // Valid creds
authMethod: 'token',
allowSelfSignedCerts: false
}],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const mapError = new Error('Simulated map error');
// Force axios.create to throw error only for this specific host
const originalAxiosCreate = axios.create;
axios.create.mockImplementation((config) => {
if (config.baseURL.includes('9.9.9.9')) {
throw mapError;
}
// Call original mock impl for other cases (if any)
return originalAxiosCreate();
});
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1); // Attempted to create
// Check the first argument contains the core message, allow anything for the second (stack trace)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`ERROR: Unhandled exception during PBS client initialization for PBS Map Error: ${mapError.message}`),
expect.anything() // Allow the stack trace as the second argument
);
expect(apiClients).toEqual({});
expect(pbsApiClients).toEqual({}); // Client not added due to error
// Restore original mock implementation if needed for other tests
axios.create.mockImplementation(originalAxiosCreate);
consoleErrorSpy.mockRestore();
});
// --- Tests for Retry Logic ---
test('should call axiosRetry during initialization', async () => {
// Simple test to ensure axiosRetry is called during init
const { endpoints, pbsConfigs } = loadConfiguration();
await initializeApiClients(endpoints, pbsConfigs);
// Expect 1 call for PVE client + 1 call for PBS client from default setup
expect(axiosRetry).toHaveBeenCalledTimes(2);
// Check args for the PVE client call
expect(axiosRetry).toHaveBeenCalledWith(
axios.create.mock.results[0].value, // The first created axios instance
expect.objectContaining({ retries: 3 }) // Check if retry config is passed
);
});
test('should log error when PVE request interceptor encounters missing credentials', async () => {
// Arrange
const missingCredsEndpoint = {
id: 'pve-bad-creds',
name: 'PVE Missing Creds',
host: '11.11.11.11',
port: '8006',
// Missing tokenId and tokenSecret
enabled: true,
allowSelfSignedCerts: false
};
loadConfiguration.mockReturnValue({ endpoints: [missingCredsEndpoint], pbsConfigs: [] });
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock axios.create specifically for this test
let capturedInterceptor = null; // Variable to hold the interceptor function
const mockGet = jest.fn().mockResolvedValue({ data: 'ignored' });
const mockAxiosInstance = {
get: async (url, config) => {
// Simulate running the interceptor before the request
if (capturedInterceptor) {
// Pass a mock config object, interceptor might modify it
const mockConfig = { headers: {}, url, ...config };
try {
await capturedInterceptor(mockConfig); // Run the interceptor
} catch (interceptorError) {
// If interceptor throws (e.g., Promise.reject), rethrow it
throw interceptorError;
}
}
return mockGet(url, config); // Run the actual mock get
},
interceptors: {
request: {
use: jest.fn(successFn => { // Capture the interceptor function
capturedInterceptor = successFn;
})
},
response: { use: jest.fn() }
}
};
axios.create.mockReturnValue(mockAxiosInstance);
// Act: Initialize clients (this adds the interceptor via the mock .use)
const { apiClients } = await initializeApiClients(endpoints, pbsConfigs);
const pveClient = apiClients['pve-bad-creds']?.client;
expect(pveClient).toBeDefined();
expect(capturedInterceptor).not.toBeNull(); // Check interceptor was captured
// Act: Attempt an API call which should trigger the interceptor via the mock .get
try {
await pveClient.get('/nodes');
} catch (e) {
// We don't expect the get call itself to throw here,
// the interceptor just logs an error in this case.
}
// Assert: Check that the console error was logged by the interceptor
expect(consoleErrorSpy).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
`ERROR: Endpoint ${missingCredsEndpoint.name} is missing required API token credentials.`
);
consoleErrorSpy.mockRestore();
// Restore default axios.create mock from beforeEach
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }
}));
});
// Removing the complex/brittle retry simulation tests below as the core logic
// is now tested via the helper function tests (pbsRetryDelayLogger, pbsRetryConditionChecker)
// and the basic call is verified by 'should call axiosRetry during initialization'.
/*
test('should retry PVE API calls on network errors', async () => {
// ... (Removed Test Code) ...
});
*/
/*
test('should retry PBS API calls on retryable errors and log warning', async () => {
// ... (Removed Test Code) ...
});
*/
// Add more tests here for:
// - Config validation errors (missing fields in loadConfiguration result)
// - Axios errors during initialization (e.g., interceptor setup fails? unlikely)
// - Multiple endpoints for PVE/PBS
// - Different auth methods (if implemented)
// - rejectUnauthorized logic
test('should correctly build baseURL for hosts with and without protocol', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [
{ id: 'pve-no-proto', name: 'PVE No Protocol', host: '1.1.1.1', port: '8006', enabled: true, tokenId: 't1', tokenSecret: 's1', allowSelfSignedCerts: false },
{ id: 'pve-with-proto', name: 'PVE With Protocol', host: 'https://1.1.1.2', port: '8006', enabled: true, tokenId: 't2', tokenSecret: 's2', allowSelfSignedCerts: false },
],
pbsConfigs: [
{ id: 'pbs-no-proto', name: 'PBS No Protocol', host: '2.2.2.1', port: '8007', authMethod: 'token', tokenId: 'pt1', tokenSecret: 'ps1', allowSelfSignedCerts: false },
{ id: 'pbs-with-proto', name: 'PBS With Protocol', host: 'https://2.2.2.2', port: '8007', authMethod: 'token', tokenId: 'pt2', tokenSecret: 'ps2', allowSelfSignedCerts: false },
],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(4); // 2 PVE + 2 PBS
// Check PVE Base URLs
const pveNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.1'));
const pveWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.2'));
expect(pveNoProtoArgs[0].baseURL).toBe('https://1.1.1.1:8006/api2/json'); // Checks the ':' branch (line 63)
expect(pveWithProtoArgs[0].baseURL).toBe('https://1.1.1.2/api2/json'); // Checks the '?' branch (line 62)
// Check PBS Base URLs
const pbsNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.1'));
const pbsWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.2'));
expect(pbsNoProtoArgs[0].baseURL).toBe('https://2.2.2.1:8007/api2/json'); // Checks the ':' branch (line 144)
expect(pbsWithProtoArgs[0].baseURL).toBe('https://2.2.2.2/api2/json'); // Checks the '?' branch (line 143)
});
});
// --- Direct Tests for Helper Functions ---
describe('API Client Helper Functions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// --- Tests for createApiClientInstance ---
describe('createApiClientInstance', () => {
const { createApiClientInstance } = require('../apiClients');
const axios = require('axios'); // Mocked axios
const axiosRetry = require('axios-retry').default; // Mocked axiosRetry
beforeEach(() => {
// Reset axios.create and axiosRetry mocks
axios.create.mockClear();
axiosRetry.mockClear();
// Reconfigure axios.create to return a mock instance with spied interceptors
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() }
}
}));
});
test('should create an instance with provided baseURL and httpsAgent config', () => {
const baseURL = 'https://test.com/api';
const allowSelfSignedCerts = true;
createApiClientInstance(baseURL, allowSelfSignedCerts);
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: baseURL,
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false })
}),
headers: { 'Content-Type': 'application/json' }
}));
});
test('should call request.use when authInterceptor is provided', () => {
const mockInterceptor = jest.fn();
const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig
expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor);
});
test('should NOT call request.use when authInterceptor is NOT provided', () => {
const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(apiClient.interceptors.request.use).not.toHaveBeenCalled();
});
test('should call axiosRetry when retryConfig is provided', () => {
const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() };
const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig);
expect(axiosRetry).toHaveBeenCalledTimes(1);
expect(axiosRetry).toHaveBeenCalledWith(apiClient, {
retries: mockRetryConfig.retries,
retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger
retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker
});
});
test('should NOT call axiosRetry when retryConfig is NOT provided', () => {
createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(axiosRetry).not.toHaveBeenCalled();
});
});
// --- createPveAuthInterceptor Tests ---
// --- createPveAuthInterceptor Tests ---
describe('createPveAuthInterceptor', () => {
const { createPveAuthInterceptor } = require('../apiClients');
const mockEndpoint = { name: 'Test PVE', tokenId: 'test-id', tokenSecret: 'test-secret' };
const mockEndpointMissingCreds = { name: 'Test PVE Bad' }; // Missing credentials
test('should return a function', () => {
const interceptor = createPveAuthInterceptor(mockEndpoint);
expect(typeof interceptor).toBe('function');
});
test('should add Authorization header if credentials exist', () => {
const interceptor = createPveAuthInterceptor(mockEndpoint);
const mockConfig = { headers: {} };
const resultConfig = interceptor(mockConfig);
expect(resultConfig.headers.Authorization).toBe(`PVEAPIToken=test-id=test-secret`);
});
test('should NOT add Authorization header and log error if credentials missing', () => {
const interceptor = createPveAuthInterceptor(mockEndpointMissingCreds);
const mockConfig = { headers: {} };
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const resultConfig = interceptor(mockConfig);
expect(resultConfig.headers.Authorization).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`ERROR: Endpoint ${mockEndpointMissingCreds.name} is missing required API token credentials.`
);
consoleErrorSpy.mockRestore();
});
});
// --- createPbsAuthInterceptor Tests ---
describe('createPbsAuthInterceptor', () => {
const { createPbsAuthInterceptor } = require('../apiClients');
const mockConfig = { tokenId: 'pbs-id', tokenSecret: 'pbs-secret' };
test('should return a function', () => {
const interceptor = createPbsAuthInterceptor(mockConfig);
expect(typeof interceptor).toBe('function');
});
test('should add correct PBS Authorization header', () => {
const interceptor = createPbsAuthInterceptor(mockConfig);
const mockReqConfig = { headers: {} };
const resultConfig = interceptor(mockReqConfig);
expect(resultConfig.headers.Authorization).toBe(`PBSAPIToken=pbs-id:pbs-secret`);
});
// Note: Add test for missing creds if validation doesn't happen before calling this
});
// --- Tests for createApiClientInstance ---
describe('createApiClientInstance', () => {
const { createApiClientInstance } = require('../apiClients');
const axios = require('axios'); // Mocked axios
const axiosRetry = require('axios-retry').default; // Mocked axiosRetry
beforeEach(() => {
// Reset axios.create and axiosRetry mocks
axios.create.mockClear();
axiosRetry.mockClear();
// Reconfigure axios.create to return a mock instance with spied interceptors
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() }
}
}));
});
test('should create an instance with provided baseURL and httpsAgent config', () => {
const baseURL = 'https://test.com/api';
const allowSelfSignedCerts = true;
createApiClientInstance(baseURL, allowSelfSignedCerts);
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: baseURL,
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false })
}),
headers: { 'Content-Type': 'application/json' }
}));
});
test('should call request.use when authInterceptor is provided', () => {
const mockInterceptor = jest.fn();
const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig
expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor);
});
test('should NOT call request.use when authInterceptor is NOT provided', () => {
const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(apiClient.interceptors.request.use).not.toHaveBeenCalled();
});
test('should call axiosRetry when retryConfig is provided', () => {
const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() };
const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig);
expect(axiosRetry).toHaveBeenCalledTimes(1);
expect(axiosRetry).toHaveBeenCalledWith(apiClient, {
retries: mockRetryConfig.retries,
retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger
retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker
});
});
test('should NOT call axiosRetry when retryConfig is NOT provided', () => {
createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(axiosRetry).not.toHaveBeenCalled();
});
});
// --- pveRetryDelayLogger Tests ---
describe('pveRetryDelayLogger', () => {
const { pveRetryDelayLogger } = require('../apiClients');
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
axiosRetry.exponentialDelay.mockClear();
axiosRetry.exponentialDelay.mockReturnValue(500); // Use different value for clarity
});
test('should log warning with correct PVE details', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const testError = new Error('PVE Failed');
pveRetryDelayLogger('TestPVE', 3, testError);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Retrying PVE API request for TestPVE (attempt 3) due to error: PVE Failed'
);
consoleWarnSpy.mockRestore();
});
test('should call mocked axiosRetry.exponentialDelay and return its value', () => {
const result = pveRetryDelayLogger('TestPVE', 2, new Error('Test'));
expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1);
expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(2); // Called with retryCount
expect(result).toBe(500); // Returns the mock value
});
});
// --- pbsRetryDelayLogger Tests ---
describe('pbsRetryDelayLogger', () => {
const { pbsRetryDelayLogger } = require('../apiClients');
// Get the mocked default export which has the mocked helpers
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
// Reset mocks before each test in this suite
axiosRetry.exponentialDelay.mockClear();
axiosRetry.exponentialDelay.mockReturnValue(1000); // Set default mock return for simplicity
});
test('should log warning with correct details', () => {
// ... (this test remains the same, just checking console.warn) ...
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const testError = new Error('PBS Failed');
pbsRetryDelayLogger('TestPBS', 2, testError);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Retrying PBS API request for TestPBS (Token Auth - attempt 2) due to error: PBS Failed'
);
consoleWarnSpy.mockRestore();
});
test('should call mocked axiosRetry.exponentialDelay and return its value', () => {
// No spy needed, just call the function and check the pre-existing mock
const result = pbsRetryDelayLogger('TestPBS', 1, new Error('Test'));
expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1);
expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(1);
expect(result).toBe(1000); // Should return the mock value
});
});
// --- pbsRetryConditionChecker Tests ---
describe('pbsRetryConditionChecker', () => {
const { pbsRetryConditionChecker } = require('../apiClients');
// Get the mocked default export which has the mocked helpers
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
// Reset mocks and set default return values before each test
axiosRetry.isNetworkError.mockClear().mockReturnValue(false);
axiosRetry.isRetryableError.mockClear().mockReturnValue(false);
});
// No afterEach needed as we clear in beforeEach
test('should return true for network errors', () => {
const networkError = new Error('Network Error');
axiosRetry.isNetworkError.mockReturnValue(true); // Override default mock return
axiosRetry.isRetryableError.mockReturnValue(false); // Ensure this stays false for the test
expect(pbsRetryConditionChecker(networkError)).toBe(true);
// Verify mocks were called (or not called due to short-circuit)
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError);
expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Corrected assertion
});
test('should return true for retryable errors', () => {
const retryableError = new Error('Retryable Error');
retryableError.response = { status: 503 };
axiosRetry.isRetryableError.mockReturnValue(true); // Override default mock return
expect(pbsRetryConditionChecker(retryableError)).toBe(true);
// Verify mocks were called
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError);
});
test('should return false for non-network, non-retryable errors', () => {
const otherError = new Error('Other Error');
// Default mock returns (false, false) are already set in beforeEach
expect(pbsRetryConditionChecker(otherError)).toBe(false);
// Verify mocks were called
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError);
});
});
// --- pveRetryConditionChecker Tests ---
describe('pveRetryConditionChecker', () => {
const { pveRetryConditionChecker } = require('../apiClients');
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
axiosRetry.isNetworkError.mockClear().mockReturnValue(false);
axiosRetry.isRetryableError.mockClear().mockReturnValue(false);
});
test('should return true for network errors', () => {
const networkError = new Error('Network Error');
axiosRetry.isNetworkError.mockReturnValue(true);
expect(pveRetryConditionChecker(networkError)).toBe(true);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError);
expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Short-circuits
});
test('should return true for retryable errors', () => {
const retryableError = new Error('Retryable Error');
axiosRetry.isRetryableError.mockReturnValue(true);
expect(pveRetryConditionChecker(retryableError)).toBe(true);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError);
});
test('should return true for error with status 596', () => {
const status596Error = new Error('Status 596 Error');
status596Error.response = { status: 596 };
// Ensure other checks are false
axiosRetry.isNetworkError.mockReturnValue(false);
axiosRetry.isRetryableError.mockReturnValue(false);
expect(pveRetryConditionChecker(status596Error)).toBe(true);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status596Error);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status596Error);
});
test('should return false for other errors without status 596', () => {
const otherError = new Error('Other Error');
// Ensure other checks are false (default from beforeEach)
expect(pveRetryConditionChecker(otherError)).toBe(false);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError);
});
test('should return false for error with different response status', () => {
const status500Error = new Error('Status 500 Error');
status500Error.response = { status: 500 };
// Ensure other checks are false (default from beforeEach)
expect(pveRetryConditionChecker(status500Error)).toBe(false);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status500Error);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status500Error);
});
});
});
-437
View File
@@ -1,437 +0,0 @@
/**
* Backup Data Validator
*
* This module provides utilities to validate backup data against known ground truths
* and help identify discrepancies in the backup system.
*/
// Ground truth data based on research
const groundTruthData = {
totalGuests: 18, // Actual cluster count
pbsBackupsTotal: 135,
vmSnapshots: 3, // Only 3 actual VM/CT snapshots
// Backup job schedules
primaryBackupJob: {
id: 'backup-2759a200-3e11',
schedule: '02:00 AM',
excludes: [102, 200, 400],
retention: { daily: 7, weekly: 4, monthly: 3 }
},
secondaryBackupJob: {
id: 'backup-79ce96ee-6527',
schedule: '04:00 AM',
includes: [102, 200, 400],
retention: { keepLast: 3 }
},
// Expected backup ages (as of June 2, 12:50 PM BST)
expectedBackupAges: {
primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups
secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups
vm102: 'no_recent_backup' // Issue found in research
},
// Known issues from research
knownIssues: {
guestCountDiscrepancy: true, // Pulse shows 20, actual is 18
vm102BackupMissing: true,
multipleEndpoints: 2, // proxmox.lan and pimox.lan
snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots
}
};
/**
* Validates guest count against expected values
* @param {Object} discoveryData - The discovery data from fetchDiscoveryData
* @returns {Object} Validation result with details
*/
function validateGuestCount(discoveryData) {
const actualVMs = discoveryData.vms?.length || 0;
const actualContainers = discoveryData.containers?.length || 0;
const actualTotal = actualVMs + actualContainers;
const result = {
valid: actualTotal === groundTruthData.totalGuests,
expected: groundTruthData.totalGuests,
actual: actualTotal,
vms: actualVMs,
containers: actualContainers,
discrepancy: actualTotal - groundTruthData.totalGuests,
details: []
};
if (!result.valid) {
result.details.push(`Guest count mismatch: Expected ${result.expected}, got ${result.actual}`);
// Check for known issue
if (actualTotal === 20 && groundTruthData.totalGuests === 18) {
result.details.push('Known issue: Pulse showing 20 guests instead of actual 18');
}
}
// Group by endpoint for detailed analysis
const guestsByEndpoint = {};
[...(discoveryData.vms || []), ...(discoveryData.containers || [])].forEach(guest => {
const endpoint = guest.endpointId || 'unknown';
if (!guestsByEndpoint[endpoint]) {
guestsByEndpoint[endpoint] = { vms: 0, containers: 0 };
}
if (guest.type === 'qemu') {
guestsByEndpoint[endpoint].vms++;
} else {
guestsByEndpoint[endpoint].containers++;
}
});
result.byEndpoint = guestsByEndpoint;
return result;
}
/**
* Validates PBS backup counts vs VM snapshots
* @param {Object} pbsData - PBS data from fetchPbsData
* @param {Object} pveBackups - PVE backup data
* @returns {Object} Validation result
*/
function validateBackupCounts(pbsData, pveBackups) {
let pbsBackupCount = 0;
let pbsBackupsByGuest = {};
// Count PBS backups
if (pbsData && pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
(ds.snapshots || []).forEach(snap => {
pbsBackupCount++;
const guestKey = `${snap['backup-type']}/${snap['backup-id']}`;
pbsBackupsByGuest[guestKey] = (pbsBackupsByGuest[guestKey] || 0) + 1;
});
});
}
const vmSnapshotCount = pveBackups?.guestSnapshots?.length || 0;
const result = {
valid: pbsBackupCount > 100 && vmSnapshotCount < 10, // Expected pattern
pbsBackups: {
total: pbsBackupCount,
expected: groundTruthData.pbsBackupsTotal,
byGuest: pbsBackupsByGuest
},
vmSnapshots: {
total: vmSnapshotCount,
expected: groundTruthData.vmSnapshots,
list: pveBackups?.guestSnapshots || []
},
details: []
};
if (Math.abs(pbsBackupCount - groundTruthData.pbsBackupsTotal) > 10) {
result.details.push(`PBS backup count differs from expected: ${pbsBackupCount} vs ${groundTruthData.pbsBackupsTotal}`);
}
if (vmSnapshotCount > groundTruthData.vmSnapshots) {
result.details.push(`More VM snapshots than expected: ${vmSnapshotCount} vs ${groundTruthData.vmSnapshots}`);
}
return result;
}
/**
* Validates backup ages for all guests
* @param {Object} pbsData - PBS data
* @param {Date} currentTime - Current time for age calculations
* @returns {Object} Validation result with age analysis
*/
function validateBackupAges(pbsData, currentTime = new Date()) {
const backupAges = new Map();
const guestsWithoutBackups = new Set();
const expectedGuests = new Set();
// Build expected guest list
for (let i = 100; i <= 106; i++) {
expectedGuests.add(String(i));
}
for (let i = 200; i <= 400; i += 100) {
expectedGuests.add(String(i));
}
// Analyze PBS backups
if (pbsData && pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
(ds.snapshots || []).forEach(snap => {
const backupTime = snap['backup-time'] * 1000; // Convert to milliseconds
const ageHours = (currentTime.getTime() - backupTime) / (1000 * 60 * 60);
const guestId = snap['backup-id'];
if (!backupAges.has(guestId) || ageHours < backupAges.get(guestId)) {
backupAges.set(guestId, ageHours);
}
});
});
}
// Find guests without recent backups
expectedGuests.forEach(guestId => {
if (!backupAges.has(guestId) || backupAges.get(guestId) > 24) {
guestsWithoutBackups.add(guestId);
}
});
// Categorize by backup schedule
const primaryJobGuests = [];
const secondaryJobGuests = [];
const issues = [];
backupAges.forEach((age, guestId) => {
const id = parseInt(guestId);
if ([102, 200, 400].includes(id)) {
secondaryJobGuests.push({ id: guestId, age });
if (age < groundTruthData.expectedBackupAges.secondaryJobGuests.minHours ||
age > groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours + 1) {
issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`);
}
} else {
primaryJobGuests.push({ id: guestId, age });
if (age < groundTruthData.expectedBackupAges.primaryJobGuests.minHours ||
age > groundTruthData.expectedBackupAges.primaryJobGuests.maxHours + 1) {
issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`);
}
}
});
// Check for VM 102 issue
if (guestsWithoutBackups.has('102')) {
issues.push('VM 102 has no recent backup (known issue)');
}
return {
valid: issues.length === 0,
backupAges: Object.fromEntries(backupAges),
primaryJobGuests,
secondaryJobGuests,
guestsWithoutBackups: Array.from(guestsWithoutBackups),
issues,
summary: {
totalGuests: expectedGuests.size,
guestsWithBackups: backupAges.size,
guestsWithRecentBackups: Array.from(backupAges.entries())
.filter(([_, age]) => age < 24).length
}
};
}
/**
* Validates PBS task categorization
* @param {Array} pbsTasks - Raw PBS tasks
* @param {Object} processedTasks - Processed tasks from processPbsTasks
* @returns {Object} Validation result
*/
function validateTaskProcessing(pbsTasks, processedTasks) {
const result = {
valid: true,
totalTasks: pbsTasks?.length || 0,
categorized: {
backup: processedTasks.backupTasks?.summary?.total || 0,
verify: processedTasks.verificationTasks?.summary?.total || 0,
sync: processedTasks.syncTasks?.summary?.total || 0,
prune: processedTasks.pruneTasks?.summary?.total || 0
},
uncategorized: [],
issues: []
};
// Check if all tasks were categorized
const categorizedTotal = Object.values(result.categorized).reduce((a, b) => a + b, 0);
if (categorizedTotal !== result.totalTasks) {
result.valid = false;
result.issues.push(`Task count mismatch: ${categorizedTotal} categorized out of ${result.totalTasks} total`);
// Find uncategorized tasks
const taskTypeMap = {
backup: 'backup',
verify: 'verify',
sync: 'sync',
prune: 'prune',
garbage_collection: 'prune',
gc: 'prune'
};
pbsTasks?.forEach(task => {
const type = task.worker_type || task.type;
if (!taskTypeMap[type]) {
result.uncategorized.push(type);
}
});
}
// Check for backup task details
const backupTasks = processedTasks.backupTasks?.recentTasks || [];
const pbsBackupTasks = backupTasks.filter(t => t.pbsBackupRun);
if (pbsBackupTasks.length === 0 && result.categorized.backup > 0) {
result.issues.push('No PBS backup runs found in recent tasks');
}
return result;
}
/**
* Performs comprehensive validation of all backup data
* @param {Object} data - Object containing discoveryData, pbsData, etc.
* @returns {Object} Complete validation report
*/
function validateAllBackupData(data) {
const report = {
timestamp: new Date().toISOString(),
validations: {},
overallValid: true,
criticalIssues: [],
warnings: []
};
// Guest count validation
if (data.discoveryData) {
report.validations.guestCount = validateGuestCount(data.discoveryData);
if (!report.validations.guestCount.valid) {
report.warnings.push('Guest count discrepancy detected');
}
}
// Backup count validation
if (data.pbsData && data.discoveryData?.pveBackups) {
report.validations.backupCounts = validateBackupCounts(
data.pbsData,
data.discoveryData.pveBackups
);
if (!report.validations.backupCounts.valid) {
report.criticalIssues.push('Backup count validation failed');
report.overallValid = false;
}
}
// Backup age validation
if (data.pbsData) {
report.validations.backupAges = validateBackupAges(data.pbsData);
if (!report.validations.backupAges.valid) {
report.validations.backupAges.issues.forEach(issue => {
if (issue.includes('VM 102')) {
report.warnings.push(issue);
} else {
report.criticalIssues.push(issue);
report.overallValid = false;
}
});
}
}
// Task processing validation
if (data.pbsTasks && data.processedTasks) {
report.validations.taskProcessing = validateTaskProcessing(
data.pbsTasks,
data.processedTasks
);
if (!report.validations.taskProcessing.valid) {
report.warnings.push('Task processing issues detected');
}
}
// Summary
report.summary = {
criticalIssues: report.criticalIssues.length,
warnings: report.warnings.length,
recommendation: report.overallValid
? 'Backup data appears valid'
: 'Critical issues found - investigate backup system'
};
return report;
}
/**
* Generates a human-readable report from validation results
* @param {Object} validationReport - Report from validateAllBackupData
* @returns {String} Formatted report
*/
function generateValidationReport(validationReport) {
let report = `Backup Data Validation Report
Generated: ${validationReport.timestamp}
========================================
`;
// Overall Status
report += `Overall Status: ${validationReport.overallValid ? '✓ PASS' : '✗ FAIL'}\n`;
report += `Critical Issues: ${validationReport.criticalIssues.length}\n`;
report += `Warnings: ${validationReport.warnings.length}\n\n`;
// Guest Count
if (validationReport.validations.guestCount) {
const gc = validationReport.validations.guestCount;
report += `Guest Count Validation:\n`;
report += ` Expected: ${gc.expected} guests\n`;
report += ` Actual: ${gc.actual} guests (${gc.vms} VMs, ${gc.containers} CTs)\n`;
if (gc.byEndpoint) {
report += ` By Endpoint:\n`;
Object.entries(gc.byEndpoint).forEach(([endpoint, counts]) => {
report += ` ${endpoint}: ${counts.vms} VMs, ${counts.containers} CTs\n`;
});
}
report += '\n';
}
// Backup Counts
if (validationReport.validations.backupCounts) {
const bc = validationReport.validations.backupCounts;
report += `Backup Count Validation:\n`;
report += ` PBS Backups: ${bc.pbsBackups.total} (expected ~${bc.pbsBackups.expected})\n`;
report += ` VM Snapshots: ${bc.vmSnapshots.total} (expected ${bc.vmSnapshots.expected})\n`;
report += '\n';
}
// Backup Ages
if (validationReport.validations.backupAges) {
const ba = validationReport.validations.backupAges;
report += `Backup Age Validation:\n`;
report += ` Total Guests: ${ba.summary.totalGuests}\n`;
report += ` Guests with backups: ${ba.summary.guestsWithBackups}\n`;
report += ` Guests with recent backups (<24h): ${ba.summary.guestsWithRecentBackups}\n`;
if (ba.guestsWithoutBackups.length > 0) {
report += ` Guests without recent backups: ${ba.guestsWithoutBackups.join(', ')}\n`;
}
report += '\n';
}
// Issues
if (validationReport.criticalIssues.length > 0) {
report += `Critical Issues:\n`;
validationReport.criticalIssues.forEach(issue => {
report += ` - ${issue}\n`;
});
report += '\n';
}
if (validationReport.warnings.length > 0) {
report += `Warnings:\n`;
validationReport.warnings.forEach(warning => {
report += ` - ${warning}\n`;
});
report += '\n';
}
report += `Recommendation: ${validationReport.summary.recommendation}\n`;
return report;
}
module.exports = {
validateGuestCount,
validateBackupCounts,
validateBackupAges,
validateTaskProcessing,
validateAllBackupData,
generateValidationReport
};
-571
View File
@@ -1,571 +0,0 @@
const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher');
const { processPbsTasks } = require('../pbsUtils');
// Mock data based on your ground truth research
const groundTruthData = {
totalGuests: 18, // Actual cluster count
pbsBackupsTotal: 135,
vmSnapshots: 3, // Only 3 actual VM/CT snapshots
// Backup job schedules
primaryBackupJob: {
id: 'backup-2759a200-3e11',
schedule: '02:00 AM',
excludes: [102, 200, 400],
retention: { daily: 7, weekly: 4, monthly: 3 }
},
secondaryBackupJob: {
id: 'backup-79ce96ee-6527',
schedule: '04:00 AM',
includes: [102, 200, 400],
retention: { keepLast: 3 }
},
// Expected backup ages (as of June 2, 12:50 PM BST)
expectedBackupAges: {
primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups
secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups
vm102: 'no_recent_backup' // Issue found in research
},
// Known issues from research
knownIssues: {
guestCountDiscrepancy: true, // Pulse shows 20, actual is 18
vm102BackupMissing: true,
multipleEndpoints: 2, // proxmox.lan and pimox.lan
snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots
}
};
describe('Backup Ground Truth Verification Tests', () => {
let mockApiClients;
let mockPbsApiClients;
let discoveryData;
beforeEach(() => {
// Mock the API clients with realistic data
mockApiClients = {
'proxmox-lan': {
client: {
get: jest.fn()
},
config: {
name: 'proxmox.lan',
tokenId: 'test@pve!test',
tokenSecret: 'test-secret'
}
},
'pimox-lan': {
client: {
get: jest.fn()
},
config: {
name: 'pimox.lan',
tokenId: 'test@pve!test',
tokenSecret: 'test-secret'
}
}
};
mockPbsApiClients = {
'pbs-main': {
client: {
get: jest.fn(),
post: jest.fn()
},
config: {
name: 'PBS Storage',
nodeName: 'pbs-node'
}
}
};
});
describe('Guest Count Verification', () => {
test('should correctly count total guests across all endpoints', async () => {
// Mock PVE nodes response
mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'cluster', name: 'proxmox-cluster', nodes: 3 },
{ type: 'node', name: 'desktop', ip: '192.168.1.10' },
{ type: 'node', name: 'delly', ip: '192.168.1.11' },
{ type: 'node', name: 'minipc', ip: '192.168.1.12' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'desktop', status: 'online' },
{ node: 'delly', status: 'online' },
{ node: 'minipc', status: 'online' }
]
}
});
}
if (path.includes('/qemu')) {
// Each node has different VMs
if (path.includes('/nodes/desktop/')) {
return Promise.resolve({ data: { data: [
{ vmid: 102, name: 'windows11', status: 'stopped' },
{ vmid: 200, name: 'UnraidServer', status: 'stopped' },
{ vmid: 400, name: 'ubuntu-gpu-vm', status: 'stopped' }
]}});
}
return Promise.resolve({ data: { data: [] }});
}
if (path.includes('/lxc')) {
// Distribute containers across nodes
if (path.includes('/nodes/desktop/')) {
return Promise.resolve({ data: { data: [
{ vmid: 100, name: 'pbs', status: 'running' },
{ vmid: 109, name: 'pbs2', status: 'stopped' },
{ vmid: 111, name: 'debian', status: 'stopped' }
]}});
} else if (path.includes('/nodes/delly/')) {
return Promise.resolve({ data: { data: [
{ vmid: 101, name: 'homeassistant', status: 'running' },
{ vmid: 105, name: 'homepage', status: 'running' },
{ vmid: 108, name: 'frigate', status: 'running' },
{ vmid: 110, name: 'tailscale-router', status: 'running' },
{ vmid: 122, name: 'influxdb-telegraf', status: 'running' }
]}});
} else if (path.includes('/nodes/minipc/')) {
return Promise.resolve({ data: { data: [
{ vmid: 103, name: 'pihole', status: 'running' },
{ vmid: 104, name: 'cloudflared', status: 'running' },
{ vmid: 106, name: 'pulse', status: 'running' },
{ vmid: 107, name: 'jellyfin', status: 'running' },
{ vmid: 120, name: 'mqtt', status: 'running' },
{ vmid: 121, name: 'zigbee2mqtt', status: 'running' },
{ vmid: 124, name: 'grafana', status: 'running' }
]}});
}
return Promise.resolve({ data: { data: [] }});
}
return Promise.resolve({ data: { data: [] } });
});
mockApiClients['pimox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'node', name: 'pi', ip: '192.168.1.20' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [{ node: 'pi', status: 'online' }]
}
});
}
if (path.includes('/qemu')) {
return Promise.resolve({ data: { data: [] }});
}
if (path.includes('/lxc')) {
return Promise.resolve({ data: { data: [] }});
}
return Promise.resolve({ data: { data: [] } });
});
discoveryData = await fetchDiscoveryData(mockApiClients, {});
const totalVMs = discoveryData.vms.length;
const totalContainers = discoveryData.containers.length;
const totalGuests = totalVMs + totalContainers;
// Verify against ground truth
expect(totalGuests).toBe(groundTruthData.totalGuests);
expect(totalVMs).toBe(3); // VMs 102, 200, 400
expect(totalContainers).toBe(15); // All containers across all nodes
// Check for known discrepancy
if (totalGuests !== 20) {
console.log(`Guest count discrepancy detected: Actual ${totalGuests}, Pulse might show 20`);
}
});
});
describe('PBS Backup Count Verification', () => {
test('should correctly count PBS backups vs VM snapshots', async () => {
// Mock PBS datastore groups and snapshots
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'pbs-node' }] }
});
}
if (path === '/config/datastore') {
return Promise.resolve({
data: { data: [{ name: 'main-datastore' }] }
});
}
if (path.includes('/admin/datastore/main-datastore/snapshots')) {
// This is called by fetchPbsDatastoreSnapshots - return all 135 snapshots
const allSnapshots = [];
const now = Math.floor(Date.now() / 1000);
// Create snapshots for all guests
const guests = [
{ type: 'ct', id: '100', count: 9 },
{ type: 'ct', id: '101', count: 9 },
{ type: 'vm', id: '102', count: 0 }, // VM 102 has no backups
{ type: 'ct', id: '103', count: 9 },
{ type: 'ct', id: '104', count: 9 },
{ type: 'ct', id: '105', count: 9 },
{ type: 'ct', id: '106', count: 9 },
{ type: 'ct', id: '107', count: 9 },
{ type: 'ct', id: '108', count: 9 },
{ type: 'ct', id: '109', count: 9 },
{ type: 'ct', id: '110', count: 9 },
{ type: 'ct', id: '111', count: 9 },
{ type: 'ct', id: '120', count: 9 },
{ type: 'ct', id: '121', count: 9 },
{ type: 'ct', id: '122', count: 9 },
{ type: 'ct', id: '124', count: 9 },
{ type: 'vm', id: '200', count: 3 },
{ type: 'vm', id: '400', count: 3 }
];
guests.forEach(guest => {
for (let i = 0; i < guest.count; i++) {
allSnapshots.push({
'backup-time': now - (i * 24 * 60 * 60),
'backup-type': guest.type,
'backup-id': guest.id,
'backup-group': `${guest.type}/${guest.id}`,
size: 1024 * 1024 * 100
});
}
});
return Promise.resolve({ data: { data: allSnapshots } });
}
if (path.includes('/status/datastore-usage')) {
return Promise.resolve({
data: { data: [{
store: 'main-datastore',
total: 1000000000000,
used: 135000000000, // 135GB for 135 backups
avail: 865000000000
}]}
});
}
return Promise.resolve({ data: { data: [] } });
});
// Mock PVE snapshots (the real VM/CT snapshots)
mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => {
if (path.includes('/snapshot')) {
if (path.includes('/400/')) {
return Promise.resolve({
data: { data: [
{ name: 'current' }, // Filtered out
{ name: 'ubuntuserver', snaptime: 1700000000 },
{ name: 'precursor', snaptime: 1699000000 }
]}
});
}
if (path.includes('/106/')) {
return Promise.resolve({
data: { data: [
{ name: 'current' }, // Filtered out
{ name: 'before_helper', snaptime: 1701000000 }
]}
});
}
return Promise.resolve({ data: { data: [{ name: 'current' }] } });
}
return Promise.resolve({ data: { data: [] } });
});
const pbsData = await fetchPbsData(mockPbsApiClients);
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// Count PBS backups
let totalPbsBackups = 0;
if (pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
totalPbsBackups += ds.snapshots?.length || 0;
});
}
// Count VM/CT snapshots
const vmSnapshots = discoveryData.pveBackups?.guestSnapshots?.length || 0;
console.log(`PBS Backups: ${totalPbsBackups}, VM Snapshots: ${vmSnapshots}`);
// Verify the distinction
expect(totalPbsBackups).toBeGreaterThan(50); // Should have many PBS backups
expect(vmSnapshots).toBeLessThan(5); // Should have very few VM snapshots
// This verifies the logging confusion issue
if (totalPbsBackups > 100 && vmSnapshots < 5) {
console.log('Confirmed: PBS backups are distinct from VM snapshots');
console.log('DataFetcher logs showing "Found X snapshots" likely refer to VM snapshots, not PBS backups');
}
});
});
describe('Backup Age Verification', () => {
test('should correctly calculate backup ages', async () => {
const now = new Date('2025-06-02T12:50:00Z'); // Test time from research
const twoAM = new Date('2025-06-02T02:00:00Z');
const fourAM = new Date('2025-06-02T04:00:00Z');
const primaryBackupAge = (now - twoAM) / (1000 * 60 * 60); // Hours
const secondaryBackupAge = (now - fourAM) / (1000 * 60 * 60); // Hours
expect(primaryBackupAge).toBeCloseTo(10.83, 1); // ~11 hours
expect(secondaryBackupAge).toBeCloseTo(8.83, 1); // ~9 hours
// Verify these match the ground truth expectations
expect(primaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.minHours);
expect(primaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.maxHours);
expect(secondaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.minHours);
expect(secondaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours);
});
test('should identify guests with missing backups', async () => {
// Mock PBS tasks to simulate VM 102 missing recent backup
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path.includes('/snapshots') && path.includes('backup-id=102')) {
// Return no recent snapshots for VM 102
return Promise.resolve({ data: { data: [] } });
}
if (path.includes('/snapshots')) {
// Return recent snapshots for other guests
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: { data: [{
'backup-time': now - (11 * 60 * 60), // 11 hours ago
'backup-type': 'vm',
'backup-id': '100'
}]}
});
}
return Promise.resolve({ data: { data: [] } });
});
const pbsData = await fetchPbsData(mockPbsApiClients);
// Check for VM 102 backup status
const vm102Backups = pbsData[0]?.datastores?.[0]?.snapshots?.filter(
snap => snap['backup-id'] === '102'
) || [];
expect(vm102Backups.length).toBe(0);
console.log('Confirmed: VM 102 has no recent backups despite being in backup job');
});
});
describe('PBS Task Processing Verification', () => {
test('should correctly differentiate backup tasks from admin tasks', () => {
const mockTasks = [
// Backup tasks (from synthetic snapshots)
{
type: 'backup',
status: 'OK',
starttime: Date.now() / 1000 - 11 * 60 * 60,
endtime: Date.now() / 1000 - 10.5 * 60 * 60,
guest: 'vm/100',
guestType: 'vm',
guestId: '100',
pbsBackupRun: true
},
// Admin tasks
{
type: 'prune',
worker_type: 'prune',
status: 'OK',
starttime: Date.now() / 1000 - 24 * 60 * 60
},
{
type: 'garbage_collection',
worker_type: 'garbage_collection',
status: 'OK',
starttime: Date.now() / 1000 - 48 * 60 * 60
},
{
type: 'verify',
worker_type: 'verify',
status: 'OK',
starttime: Date.now() / 1000 - 6 * 60 * 60
}
];
const processed = processPbsTasks(mockTasks);
expect(processed.backupTasks.summary.total).toBe(1);
expect(processed.pruneTasks.summary.total).toBe(2); // prune + gc
expect(processed.verificationTasks.summary.total).toBe(1);
// Verify task categorization
expect(processed.backupTasks.recentTasks[0].pbsBackupRun).toBe(true);
expect(processed.backupTasks.recentTasks[0].guestId).toBe('100');
});
});
describe('Multiple Endpoint Handling', () => {
test('should handle multiple PVE endpoints correctly', async () => {
// Need to set up mockApiClients for this test
mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'cluster', name: 'proxmox-cluster', nodes: 3 },
{ type: 'node', name: 'desktop' },
{ type: 'node', name: 'delly' },
{ type: 'node', name: 'minipc' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'desktop', status: 'online' },
{ node: 'delly', status: 'online' },
{ node: 'minipc', status: 'online' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
mockApiClients['pimox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'node', name: 'pi' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [{ node: 'pi', status: 'online' }]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// Check that nodes are properly tagged with endpoints
const proxmoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'proxmox-lan');
const pimoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'pimox-lan');
expect(proxmoxNodes.length).toBe(3); // desktop, delly, minipc
expect(pimoxNodes.length).toBe(1); // pi
// Verify endpoint identification
expect(discoveryData.nodes.every(n => n.endpointId)).toBe(true);
expect(discoveryData.vms.every(vm => vm.endpointId)).toBe(true);
expect(discoveryData.containers.every(ct => ct.endpointId)).toBe(true);
});
});
describe('Integration Test: Full Backup Status Verification', () => {
test('should produce accurate backup status for dashboard', async () => {
// This test simulates the full data flow to verify dashboard accuracy
// Mock current time
const mockNow = new Date('2025-06-02T13:10:00+01:00'); // 1:10 PM BST
jest.spyOn(Date, 'now').mockImplementation(() => mockNow.getTime());
// Mock comprehensive PBS data
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path.includes('/nodes')) {
return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } });
}
if (path.includes('/config/datastore')) {
return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } });
}
if (path.includes('/admin/datastore/main-datastore/snapshots')) {
// Return snapshots for all guests with proper timing
const snapshots = [];
const fourAM = Math.floor(new Date('2025-06-02T04:00:00+01:00').getTime() / 1000);
const twoAM = Math.floor(new Date('2025-06-02T02:00:00+01:00').getTime() / 1000);
// Primary job guests (2 AM)
[100, 101, 103, 104, 105, 106, 107, 108, 109, 110, 111, 120, 121, 122, 124].forEach(id => {
snapshots.push({
'backup-time': twoAM,
'backup-type': id >= 100 && id <= 102 ? 'vm' : 'ct',
'backup-id': String(id)
});
});
// Secondary job guests (4 AM) - except VM 102
[200, 400].forEach(id => {
snapshots.push({
'backup-time': fourAM,
'backup-type': 'vm',
'backup-id': String(id)
});
});
// VM 102 has no backups
return Promise.resolve({ data: { data: snapshots } });
}
return Promise.resolve({ data: { data: [] } });
});
const pbsData = await fetchPbsData(mockPbsApiClients);
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// Analyze backup status
const guestsWithRecentBackups = new Set();
const backupAges = new Map();
if (pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
ds.snapshots?.forEach(snap => {
const guestKey = `${snap['backup-type']}/${snap['backup-id']}`;
const ageHours = (mockNow.getTime() / 1000 - snap['backup-time']) / 3600;
if (ageHours < 24) {
guestsWithRecentBackups.add(snap['backup-id']);
backupAges.set(snap['backup-id'], ageHours);
}
});
});
}
// Verify results match ground truth
expect(guestsWithRecentBackups.size).toBe(17); // 18 total - 1 (VM 102)
expect(guestsWithRecentBackups.has('102')).toBe(false); // VM 102 missing
// Verify backup ages (allow for slight time differences)
expect(backupAges.get('100')).toBeCloseTo(11, 0);
expect(backupAges.get('200')).toBeCloseTo(9, 0);
expect(backupAges.get('106')).toBeCloseTo(11, 0);
console.log('Dashboard accuracy: 17/18 guests show backups <24h old (94.4% accurate)');
console.log('Issue identified: VM 102 missing recent backup');
// Cleanup
jest.restoreAllMocks();
});
});
});
module.exports = { groundTruthData };
-486
View File
@@ -1,486 +0,0 @@
const { loadConfiguration, ConfigurationError } = require('../configLoader');
// Mock dotenv
jest.mock('dotenv', () => ({
config: jest.fn(),
}));
const dotenv = require('dotenv'); // require after mock
// Helper function to temporarily set environment variables for a test
const setEnvVars = (vars) => {
const originalEnv = { ...process.env }; // Store original env
Object.keys(vars).forEach(key => {
process.env[key] = vars[key];
});
return originalEnv; // Return original env for restoration
};
// Helper function to restore environment variables
const restoreEnvVars = (originalEnv) => {
// Clear potentially set test variables first
Object.keys(process.env).forEach(key => {
if (!(key in originalEnv)) {
delete process.env[key];
}
});
// Restore original values
Object.keys(originalEnv).forEach(key => {
process.env[key] = originalEnv[key];
});
};
// Set NODE_ENV to test *before* describing the suite
process.env.NODE_ENV = 'test';
// Mock console
let consoleWarnSpy; // Declare spies outside beforeEach/afterEach
let consoleLogSpy;
describe('Configuration Loading (loadConfiguration)', () => {
let originalEnv;
beforeEach(() => {
// Store original environment
originalEnv = { ...process.env };
// --- More robust clearing of process.env ---
// Get all keys BEFORE modifying
const currentEnvKeys = Object.keys(process.env);
// Delete all keys
currentEnvKeys.forEach(key => delete process.env[key]);
// --- End robust clearing ---
// Restore NODE_ENV as it's crucial for the logic
process.env.NODE_ENV = 'test';
// Assign spies in beforeEach
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// --- Restore original environment more carefully ---
// Clear any keys potentially added during the test
const currentEnvKeys = Object.keys(process.env);
currentEnvKeys.forEach(key => delete process.env[key]);
// Restore the original keys and values
Object.keys(originalEnv).forEach(key => {
process.env[key] = originalEnv[key];
});
// --- End restore ---
// Restore specific spies
consoleWarnSpy.mockRestore();
consoleLogSpy.mockRestore();
});
// Test Case 1: Minimal Valid PVE Config
test('should load minimal PVE config successfully', () => {
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
});
// Expect no error to be thrown for valid config
let loadedConfig;
expect(() => {
loadedConfig = loadConfiguration();
}).not.toThrow();
// Check the returned structure
expect(loadedConfig).toBeDefined();
expect(loadedConfig.endpoints).toHaveLength(1); // Check endpoints array
expect(loadedConfig.pbsConfigs).toHaveLength(0); // Expect no PBS configs
// Check the primary PVE endpoint details within the endpoints array
const primaryEndpoint = loadedConfig.endpoints[0];
expect(primaryEndpoint.id).toBe('primary');
expect(primaryEndpoint.host).toBe('pve.example.com');
expect(primaryEndpoint.tokenId).toBe('user@pam!pve');
expect(primaryEndpoint.tokenSecret).toBe('secretpve');
});
// Test Case 2: Missing Primary Proxmox Variables
test('should return setup mode configuration if primary Proxmox variables are missing', () => {
setEnvVars({
PROXMOX_HOST: '192.168.1.100',
// Missing TOKEN_ID and TOKEN_SECRET
});
const config = loadConfiguration();
expect(config.endpoints).toEqual([]);
expect(config.pbsConfigs).toEqual([]);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 3: Placeholder Primary Proxmox Variables
test('should warn and set flag if primary Proxmox variables contain placeholders', () => {
const envSetup = {
PROXMOX_HOST: 'your-proxmox-ip-or-hostname',
PROXMOX_TOKEN_ID: 'user@pam!token', // A placeholder not exactly in the list
PROXMOX_TOKEN_SECRET: 'secret-uuid', // Another placeholder not exactly in the list
};
setEnvVars(envSetup);
let config;
// Expect no error to be thrown, but placeholders to be detected
expect(() => {
config = loadConfiguration();
}).not.toThrow();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 4: Valid Primary + Additional Proxmox Endpoints
test('should load successfully with additional valid Proxmox endpoints', () => {
setEnvVars({
PROXMOX_HOST: 'pve1.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token1',
PROXMOX_TOKEN_SECRET: 'secret1',
PROXMOX_NODE_NAME: 'PVE Node 1', // Custom name
PROXMOX_PORT: '8007', // Custom port
PROXMOX_ALLOW_SELF_SIGNED_CERTS: 'true', // Explicitly true
PROXMOX_HOST_2: 'pve2.example.com',
PROXMOX_TOKEN_ID_2: 'user@pam!token2',
PROXMOX_TOKEN_SECRET_2: 'secret2',
PROXMOX_ENABLED_2: 'false', // Disabled endpoint
PROXMOX_HOST_3: 'pve3.example.com',
PROXMOX_TOKEN_ID_3: 'user@pam!token3',
PROXMOX_TOKEN_SECRET_3: 'secret3',
PROXMOX_NODE_NAME_3: 'PVE Node 3', // Custom name
PROXMOX_PORT_3: '8008',
PROXMOX_ALLOW_SELF_SIGNED_CERTS_3: 'false', // Explicitly false
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(3);
// Check primary
expect(config.endpoints[0].id).toBe('primary');
expect(config.endpoints[0].name).toBe('PVE Node 1');
expect(config.endpoints[0].host).toBe('pve1.example.com');
expect(config.endpoints[0].port).toBe('8007');
expect(config.endpoints[0].enabled).toBe(true);
expect(config.endpoints[0].allowSelfSignedCerts).toBe(true);
// Check second (disabled)
expect(config.endpoints[1].id).toBe('endpoint_2');
expect(config.endpoints[1].name).toBe(null); // No custom name configured
expect(config.endpoints[1].host).toBe('pve2.example.com');
expect(config.endpoints[1].port).toBe('8006'); // Default port
expect(config.endpoints[1].enabled).toBe(false);
expect(config.endpoints[1].allowSelfSignedCerts).toBe(true); // Default
// Check third
expect(config.endpoints[2].id).toBe('endpoint_3');
expect(config.endpoints[2].name).toBe('PVE Node 3');
expect(config.endpoints[2].host).toBe('pve3.example.com');
expect(config.endpoints[2].port).toBe('8008');
expect(config.endpoints[2].enabled).toBe(true); // Default
expect(config.endpoints[2].allowSelfSignedCerts).toBe(false);
expect(config.pbsConfigs).toHaveLength(0);
});
// Test Case 5: Incomplete Additional Proxmox Endpoint
test('should skip additional Proxmox endpoint if token details are missing', () => {
setEnvVars({
PROXMOX_HOST: 'pve1.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token1',
PROXMOX_TOKEN_SECRET: 'secret1',
PROXMOX_HOST_2: 'pve2.example.com', // Missing token ID/secret for #2
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.endpoints[0].id).toBe('primary');
});
// Test Case 6: Placeholder Additional Proxmox Endpoint
test('should skip additional Proxmox endpoint if details contain placeholders', () => {
setEnvVars({
PROXMOX_HOST: 'pve1.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token1',
PROXMOX_TOKEN_SECRET: 'secret1',
PROXMOX_HOST_2: 'your-proxmox-ip-or-hostname', // Placeholder host
PROXMOX_TOKEN_ID_2: 'user@pam!token2',
PROXMOX_TOKEN_SECRET_2: 'secret2',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1); // Only primary should load
expect(config.endpoints[0].id).toBe('primary');
});
// Test Case 7: Valid Primary PBS Config
test('should load successfully with a valid primary PBS config', () => {
setEnvVars({
// Minimal valid PVE
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
// Valid PBS
PBS_HOST: 'https://pbs.example.com:8007', // Full URL
PBS_TOKEN_ID: 'user@pbs!token',
PBS_TOKEN_SECRET: 'secretpbs',
PBS_NODE_NAME: 'PBS Backup Server',
PBS_ALLOW_SELF_SIGNED_CERTS: 'false',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(1);
const pbs = config.pbsConfigs[0];
expect(pbs.id).toBe('pbs_primary_token');
expect(pbs.name).toBe('PBS Backup Server');
expect(pbs.host).toBe('https://pbs.example.com:8007');
expect(pbs.port).toBe('8007'); // Port from env var
expect(pbs.tokenId).toBe('user@pbs!token');
expect(pbs.tokenSecret).toBe('secretpbs');
expect(pbs.authMethod).toBe('token');
expect(pbs.allowSelfSignedCerts).toBe(false);
expect(pbs.enabled).toBe(true);
});
test('should not add primary PBS config if host is set but tokens are missing', () => {
setEnvVars({
PROXMOX_HOST: '192.168.1.100',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
PBS_HOST: 'pbs.example.com',
// Missing TOKEN_ID and TOKEN_SECRET for PBS
});
let config;
expect(() => {
config = loadConfiguration();
}).not.toThrow();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(0); // PBS should NOT load
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST. Please set (PBS_TOKEN_ID + PBS_TOKEN_SECRET)')
);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1); // Only one warning expected from this test
});
// Test Case 8: Valid Primary + Additional PBS Configs
test('should load successfully with additional valid PBS configs', () => {
setEnvVars({
// PVE
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
// PBS 1 (Primary)
PBS_HOST: 'pbs1.example.com', // No protocol/port
PBS_TOKEN_ID: 'user@pbs!token1',
PBS_TOKEN_SECRET: 'secretpbs1',
// PBS 2
PBS_HOST_2: 'https://pbs2.example.com:8008',
PBS_TOKEN_ID_2: 'user@pbs!token2',
PBS_TOKEN_SECRET_2: 'secretpbs2',
PBS_NODE_NAME_2: 'PBS Server 2',
PBS_PORT_2: '9000', // Custom port
// PBS 3 (Placeholder - should skip)
PBS_HOST_3: 'pbs3.example.com',
PBS_TOKEN_ID_3: 'your-api-token-id@pam!your-token-name',
PBS_TOKEN_SECRET_3: 'secretpbs3',
// PBS 4 (Missing Token Secret - should skip)
PBS_HOST_4: 'pbs4.example.com',
PBS_TOKEN_ID_4: 'user@pbs!token4',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(2);
// Check PBS 1 (Primary)
expect(config.pbsConfigs[0].id).toBe('pbs_primary_token');
expect(config.pbsConfigs[0].name).toBe('pbs1.example.com'); // Defaults to host
expect(config.pbsConfigs[0].host).toBe('pbs1.example.com');
expect(config.pbsConfigs[0].port).toBe('8007'); // Default port
expect(config.pbsConfigs[0].allowSelfSignedCerts).toBe(true); // Default
// Check PBS 2
expect(config.pbsConfigs[1].id).toBe('pbs_endpoint_2_token');
expect(config.pbsConfigs[1].name).toBe('PBS Server 2');
expect(config.pbsConfigs[1].host).toBe('https://pbs2.example.com:8008');
expect(config.pbsConfigs[1].port).toBe('9000'); // Custom port
expect(config.pbsConfigs[1].allowSelfSignedCerts).toBe(true); // Default
// PBS 3 and 4 should have been skipped
});
// Test Case 9: Incomplete Additional PBS Endpoint (NEW TEST)
test('should skip additional PBS endpoint if token details are missing but host is present', () => {
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
// Valid Primary PBS
PBS_HOST: 'pbs1.example.com',
PBS_TOKEN_ID: 'user@pbs!token1',
PBS_TOKEN_SECRET: 'secretpbs1',
// Additional PBS host, missing tokens
PBS_HOST_2: 'pbs2.example.com',
// PBS_TOKEN_ID_2: 'user@pbs!token2', // Missing
// PBS_TOKEN_SECRET_2: 'secretpbs2', // Missing
// Valid third PBS
PBS_HOST_3: 'pbs3.example.com',
PBS_TOKEN_ID_3: 'user@pbs!token3',
PBS_TOKEN_SECRET_3: 'secretpbs3',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(2); // Should load primary (PBS1) and PBS3
expect(config.pbsConfigs.map(p => p.host)).toEqual(['pbs1.example.com', 'pbs3.example.com']);
// Check that the warning for the partial config _2 was logged
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST_2. Please set (PBS_TOKEN_ID_2 + PBS_TOKEN_SECRET_2)')
);
// Verify the config for PBS_HOST_2 was not added
expect(config.pbsConfigs.find(p => p.host === 'pbs2.example.com')).toBeUndefined();
});
// Test Case 10: No Enabled Endpoints
test('should throw ConfigurationError if no enabled PVE or PBS endpoints are configured', () => {
setEnvVars({
// Valid PVE, but disabled
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
PROXMOX_ENABLED: 'false',
// Valid PBS details, but only HOST is present, no tokens
PBS_HOST: 'pbs.example.com'
});
// Expect the final check in loadConfiguration to throw
expect(() => loadConfiguration()).toThrow(ConfigurationError);
expect(() => loadConfiguration()).toThrow(/No enabled Proxmox VE or PBS endpoints could be configured/);
});
// New Test Case for dotenv loading
test('should call dotenv.config() when NODE_ENV is not \'test\'', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development'; // Set to non-test environment
// Minimal valid PVE config to allow loadConfiguration to proceed far enough
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
});
loadConfiguration();
expect(dotenv.config).toHaveBeenCalled();
// Restore original NODE_ENV and clear mocks for other tests
process.env.NODE_ENV = originalNodeEnv;
dotenv.config.mockClear(); // Clear the mock for other tests
});
// Test Case 11: Placeholder detection with PROXMOX_TOKEN_ID in env
test('should insert PROXMOX_TOKEN_ID in correct position when placeholders detected', () => {
setEnvVars({
PROXMOX_HOST: 'your-proxmox-ip-or-hostname',
PROXMOX_TOKEN_ID: 'user@pam!token',
PROXMOX_TOKEN_SECRET: 'your-api-token-uuid',
});
const config = loadConfiguration();
// Should detect placeholders - the actual implementation includes PROXMOX_TOKEN_ID when it's set
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 12: Placeholder detection - TOKEN_ID not in list but exists
test('should add PROXMOX_TOKEN_ID at end if not in placeholder list but exists', () => {
// Only secret is a placeholder, but TOKEN_ID exists and should be added
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!mytoken', // exists but not a placeholder
PROXMOX_TOKEN_SECRET: 'your-api-token-uuid', // placeholder
});
const config = loadConfiguration();
// Debug: Check if console.warn was called at all
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
// Should detect the secret placeholder and add TOKEN_ID
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('PROXMOX_TOKEN_SECRET')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 13: Test line 138 - Add TOKEN_ID when no PROXMOX_HOST in placeholderVars
test('should push PROXMOX_TOKEN_ID when PROXMOX_HOST not in placeholder list', () => {
// Only PROXMOX_PORT is placeholder (not PROXMOX_HOST)
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token', // This IS identified as a placeholder
PROXMOX_TOKEN_SECRET: 'secret123',
PROXMOX_PORT: 'your-port' // This is a placeholder, but not checked in the primary warning
});
const config = loadConfiguration();
// Should detect a placeholder in PROXMOX_TOKEN_ID and warn about it.
// PROXMOX_PORT is not part of the primary placeholder check that generates this specific warning.
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('PROXMOX_TOKEN_ID')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case: Config file path loading
test('should load config from config directory when it exists', () => {
// Set NODE_ENV to non-test to enable dotenv loading
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
// Mock fs.existsSync to return true for config dir path
const fs = require('fs');
const originalExistsSync = fs.existsSync;
fs.existsSync = jest.fn((path) => {
if (path.includes('config/.env')) {
return true; // Config dir .env exists
}
return false;
});
// Set up environment variables
setEnvVars({
PROXMOX_HOST: '192.168.1.100',
PROXMOX_TOKEN_ID: 'user@pam!token',
PROXMOX_TOKEN_SECRET: 'secret'
});
const config = loadConfiguration();
// Verify that dotenv.config was called with config dir path
expect(dotenv.config).toHaveBeenCalledWith({ path: expect.stringContaining('config/.env') });
// Restore fs.existsSync and NODE_ENV
fs.existsSync = originalExistsSync;
process.env.NODE_ENV = originalNodeEnv;
});
});
-519
View File
@@ -1,519 +0,0 @@
// Mock fs module before requiring the threshold manager
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn()
}
}));
const fs = require('fs').promises;
const path = require('path');
const thresholdManagerInstance = require('../customThresholds');
// Mock console to avoid test output clutter
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
describe('Custom Threshold Manager', () => {
let thresholdManager;
let mockConfigPath;
beforeEach(() => {
thresholdManager = thresholdManagerInstance;
mockConfigPath = thresholdManager.configPath;
// Reset all mocks
jest.clearAllMocks();
// Clear cache for clean state
thresholdManager.cache.clear();
});
afterEach(() => {
// Clean up cache
if (thresholdManager) {
thresholdManager.cache.clear();
}
});
describe('Initialization', () => {
test('should initialize successfully with existing config file', async () => {
const mockThresholds = {
'endpoint1:100': {
endpointId: 'endpoint1',
vmid: '100',
thresholds: {
cpu: { warning: 70, critical: 90 },
memory: { warning: 80, critical: 95 }
},
enabled: true,
createdAt: new Date().toISOString()
}
};
fs.mkdir.mockResolvedValue();
fs.readFile.mockResolvedValue(JSON.stringify(mockThresholds));
await thresholdManager.init();
expect(thresholdManager.initialized).toBe(true);
expect(thresholdManager.cache.size).toBe(1);
});
test('should create new config file when none exists', async () => {
const enoentError = new Error('File not found');
enoentError.code = 'ENOENT';
fs.mkdir.mockResolvedValue();
fs.readFile.mockRejectedValue(enoentError);
fs.writeFile.mockResolvedValue();
await thresholdManager.init();
expect(thresholdManager.initialized).toBe(true);
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('Key Generation', () => {
test('should generate correct cache key format', () => {
const key = thresholdManager.generateKey('pve-main', 'node1', '100');
expect(key).toBe('pve-main:100');
});
test('should handle special characters in endpoint and vmid', () => {
const key = thresholdManager.generateKey('pve-test.local', 'node-1', 'ct-200');
expect(key).toBe('pve-test.local:ct-200');
});
test('should be consistent regardless of node parameter', () => {
const key1 = thresholdManager.generateKey('pve1', 'node1', '100');
const key2 = thresholdManager.generateKey('pve1', 'node2', '100');
expect(key1).toBe(key2); // Node migration support
});
});
describe('Getting Thresholds', () => {
beforeEach(async () => {
fs.writeFile.mockResolvedValue();
// Set up cache with test data using the real API
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 75, critical: 90 },
memory: { warning: 85, critical: 95 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
cpu: { warning: 60, critical: 80 },
disk: { warning: 90, critical: 98 }
});
});
test('should return custom thresholds when configured', () => {
const thresholds = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(thresholds).not.toBeNull();
expect(thresholds.thresholds.cpu.warning).toBe(75);
expect(thresholds.thresholds.cpu.critical).toBe(90);
expect(thresholds.thresholds.memory.warning).toBe(85);
});
test('should return null when no custom thresholds exist', () => {
const thresholds = thresholdManager.getThresholds('pve1', 'node1', '999');
expect(thresholds).toBeNull();
});
test('should return null for different endpoint', () => {
const thresholds = thresholdManager.getThresholds('pve2', 'node1', '100');
expect(thresholds).toBeNull();
});
test('should work regardless of node name due to migration support', () => {
const thresholds1 = thresholdManager.getThresholds('pve1', 'node1', '100');
const thresholds2 = thresholdManager.getThresholds('pve1', 'node2', '100');
expect(thresholds1).toEqual(thresholds2);
});
});
describe('Setting Thresholds', () => {
beforeEach(() => {
fs.writeFile.mockResolvedValue();
});
test('should set valid threshold configuration', async () => {
const validThresholds = {
cpu: { warning: 70, critical: 85 },
memory: { warning: 80, critical: 90 }
};
const result = await thresholdManager.setThresholds('pve1', 'node1', '300', validThresholds);
expect(result).toBe(true);
const stored = thresholdManager.getThresholds('pve1', 'node1', '300');
expect(stored).not.toBeNull();
expect(stored.thresholds.cpu.warning).toBe(70);
expect(stored.createdAt).toBeDefined();
expect(fs.writeFile).toHaveBeenCalled();
});
test('should validate threshold values', async () => {
const invalidThresholds = {
cpu: { warning: 95, critical: 85 } // Warning higher than critical
};
await expect(
thresholdManager.setThresholds('pve1', 'node1', '400', invalidThresholds)
).rejects.toThrow(/critical threshold must be greater than warning threshold/);
});
test('should reject thresholds outside valid range', async () => {
const outOfRangeThresholds = {
cpu: { warning: 150, critical: 200 } // Over 100%
};
await expect(
thresholdManager.setThresholds('pve1', 'node1', '500', outOfRangeThresholds)
).rejects.toThrow();
});
test('should handle partial thresholds gracefully', async () => {
const partialThresholds = {
cpu: { warning: 70, critical: 85 }
// memory and disk thresholds missing
};
const result = await thresholdManager.setThresholds('pve1', 'node1', '700', partialThresholds);
expect(result).toBe(true);
const stored = thresholdManager.getThresholds('pve1', 'node1', '700');
expect(stored.thresholds.cpu).toBeDefined();
expect(stored.thresholds.memory).toBeUndefined();
});
test('should update existing thresholds', async () => {
// Set initial thresholds
const initial = {
cpu: { warning: 70, critical: 85 }
};
await thresholdManager.setThresholds('pve1', 'node1', '800', initial);
// Update with new values
const updated = {
cpu: { warning: 75, critical: 90 },
memory: { warning: 80, critical: 95 }
};
await thresholdManager.setThresholds('pve1', 'node1', '800', updated);
const stored = thresholdManager.getThresholds('pve1', 'node1', '800');
expect(stored.thresholds.cpu.warning).toBe(75);
expect(stored.thresholds.memory.warning).toBe(80);
expect(fs.writeFile).toHaveBeenCalledTimes(2);
});
});
describe('Removing Thresholds', () => {
beforeEach(async () => {
fs.writeFile.mockResolvedValue();
// Set up some test thresholds
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
});
test('should remove existing threshold configuration', async () => {
expect(thresholdManager.getThresholds('pve1', 'node1', '100')).not.toBeNull();
const result = await thresholdManager.removeThresholds('pve1', 'node1', '100');
expect(result).toBe(true);
expect(thresholdManager.getThresholds('pve1', 'node1', '100')).toBeNull();
expect(fs.writeFile).toHaveBeenCalled();
});
test('should handle removal of non-existent thresholds gracefully', async () => {
const result = await thresholdManager.removeThresholds('pve1', 'node1', '999');
expect(result).toBe(false);
});
test('should not affect other threshold configurations', async () => {
await thresholdManager.removeThresholds('pve1', 'node1', '100');
expect(thresholdManager.getThresholds('pve1', 'node1', '200')).not.toBeNull();
});
});
describe('File Operations', () => {
test('should handle file save errors gracefully', async () => {
const saveError = new Error('Disk full');
fs.writeFile.mockRejectedValue(saveError);
await expect(
thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
})
).rejects.toThrow('Disk full');
});
test('should create data directory if it does not exist', async () => {
fs.mkdir.mockResolvedValue();
fs.readFile.mockResolvedValue('{}');
await thresholdManager.loadThresholds();
expect(fs.mkdir).toHaveBeenCalledWith(
path.dirname(mockConfigPath),
{ recursive: true }
);
});
test('should save thresholds in correct JSON format', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
const saveCall = fs.writeFile.mock.calls[0];
expect(saveCall[0]).toBe(mockConfigPath);
expect(saveCall[2]).toBe('utf8');
const savedData = JSON.parse(saveCall[1]);
expect(savedData).toHaveProperty('pve1:100');
});
});
describe('Edge Cases and Error Handling', () => {
test('should handle empty threshold configuration', async () => {
fs.writeFile.mockResolvedValue();
const emptyThresholds = {};
const result = await thresholdManager.setThresholds('pve1', 'node1', '100', emptyThresholds);
expect(result).toBe(true);
const stored = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(stored.createdAt).toBeDefined();
});
test('should handle very large cache sizes', async () => {
fs.writeFile.mockResolvedValue();
// Add many threshold configurations
for (let i = 0; i < 100; i++) {
await thresholdManager.setThresholds('pve1', 'node1', String(i), {
cpu: { warning: 70, critical: 85 }
});
}
expect(thresholdManager.cache.size).toBe(100);
expect(thresholdManager.getThresholds('pve1', 'node1', '50')).not.toBeNull();
});
});
describe('Bulk Operations', () => {
test('should get all threshold configurations', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve2', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
const allConfigs = thresholdManager.getAllThresholds();
expect(Array.isArray(allConfigs)).toBe(true);
expect(allConfigs.length).toBe(2);
expect(allConfigs.some(config => config.vmid === '100')).toBe(true);
expect(allConfigs.some(config => config.vmid === '200')).toBe(true);
});
test('should get thresholds by endpoint', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
await thresholdManager.setThresholds('pve2', 'node1', '300', {
cpu: { warning: 60, critical: 75 }
});
const pve1Configs = thresholdManager.getThresholdsByEndpoint('pve1');
const pve2Configs = thresholdManager.getThresholdsByEndpoint('pve2');
expect(pve1Configs.length).toBe(2);
expect(pve2Configs.length).toBe(1);
expect(pve1Configs.every(config => config.endpointId === 'pve1')).toBe(true);
expect(pve2Configs.every(config => config.endpointId === 'pve2')).toBe(true);
});
test('should export threshold configurations', () => {
// Add some test data directly to cache
thresholdManager.cache.set('pve1:100', {
endpointId: 'pve1',
vmid: '100',
thresholds: { cpu: { warning: 70, critical: 85 } },
createdAt: '2024-01-01T00:00:00.000Z'
});
const exported = thresholdManager.exportThresholds();
expect(exported).toHaveProperty('exportedAt');
expect(exported).toHaveProperty('version');
expect(exported.version).toBe('1.0');
expect(exported.thresholds).toHaveLength(1);
expect(exported.thresholds[0].vmid).toBe('100');
});
test('should get threshold statistics', async () => {
fs.writeFile.mockResolvedValue();
// Add multiple configurations
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
const stats = thresholdManager.getStatistics();
expect(stats).toHaveProperty('total');
expect(stats).toHaveProperty('byEndpoint');
expect(stats.total).toBe(2);
});
});
describe('Threshold Management', () => {
test('should toggle threshold configurations', async () => {
fs.writeFile.mockResolvedValue();
// Set up a threshold configuration
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
// Disable it
const result = await thresholdManager.toggleThresholds('pve1', 'node1', '100', false);
expect(result).toBe(true);
const config = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(config.enabled).toBe(false);
expect(config.updatedAt).toBeDefined();
// Re-enable it
await thresholdManager.toggleThresholds('pve1', 'node1', '100', true);
const updatedConfig = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(updatedConfig.enabled).toBe(true);
});
test('should handle toggle for non-existent configuration', async () => {
await expect(
thresholdManager.toggleThresholds('pve1', 'node1', '999', true)
).rejects.toThrow('Threshold configuration not found');
});
test('should validate threshold values correctly', () => {
// Test CPU thresholds
const validCpuThresholds = {
cpu: { warning: 70, critical: 85 }
};
const validated = thresholdManager.validateThresholds(validCpuThresholds);
expect(validated.cpu.warning).toBe(70);
expect(validated.cpu.critical).toBe(85);
// Test invalid CPU thresholds (warning >= critical)
const invalidCpuThresholds = {
cpu: { warning: 90, critical: 85 }
};
expect(() => {
thresholdManager.validateThresholds(invalidCpuThresholds);
}).toThrow('CPU critical threshold must be greater than warning threshold');
});
test('should validate memory thresholds correctly', () => {
const validMemoryThresholds = {
memory: { warning: 80, critical: 95 }
};
const validated = thresholdManager.validateThresholds(validMemoryThresholds);
expect(validated.memory.warning).toBe(80);
expect(validated.memory.critical).toBe(95);
// Test invalid memory thresholds
const invalidMemoryThresholds = {
memory: { warning: 95, critical: 80 }
};
expect(() => {
thresholdManager.validateThresholds(invalidMemoryThresholds);
}).toThrow('Memory critical threshold must be greater than warning threshold');
});
test('should validate disk thresholds correctly', () => {
const validDiskThresholds = {
disk: { warning: 85, critical: 95 }
};
const validated = thresholdManager.validateThresholds(validDiskThresholds);
expect(validated.disk.warning).toBe(85);
expect(validated.disk.critical).toBe(95);
// Test invalid disk thresholds
const invalidDiskThresholds = {
disk: { warning: 98, critical: 90 }
};
expect(() => {
thresholdManager.validateThresholds(invalidDiskThresholds);
}).toThrow('Disk critical threshold must be greater than warning threshold');
});
});
describe('Integration with Alert System', () => {
test('should store threshold configurations with proper structure', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 75, critical: 90 },
memory: { warning: 80, critical: 95 }
});
const config = thresholdManager.getThresholds('pve1', 'node1', '100');
// Verify structure for alert system integration
expect(config).toHaveProperty('endpointId', 'pve1');
expect(config).toHaveProperty('vmid', '100');
expect(config).toHaveProperty('thresholds');
expect(config).toHaveProperty('enabled', true);
expect(config).toHaveProperty('createdAt');
expect(config).toHaveProperty('updatedAt');
// Verify threshold values are accessible
expect(config.thresholds.cpu.warning).toBe(75);
expect(config.thresholds.cpu.critical).toBe(90);
expect(config.thresholds.memory.warning).toBe(80);
expect(config.thresholds.memory.critical).toBe(95);
});
test('should handle partial threshold configurations', async () => {
fs.writeFile.mockResolvedValue();
// Set only CPU thresholds
await thresholdManager.setThresholds('pve1', 'node1', '200', {
cpu: { warning: 70, critical: 85 }
});
const config = thresholdManager.getThresholds('pve1', 'node1', '200');
expect(config.thresholds.cpu).toBeDefined();
expect(config.thresholds.memory).toBeUndefined();
expect(config.thresholds.disk).toBeUndefined();
});
});
});
File diff suppressed because it is too large Load Diff
-123
View File
@@ -1,123 +0,0 @@
const dnsResolver = require('../dnsResolver');
const dns = require('dns').promises;
// Mock the dns module
jest.mock('dns', () => ({
promises: {
resolve4: jest.fn(),
resolve6: jest.fn()
}
}));
// Mock the util.promisify
jest.mock('util', () => ({
promisify: () => jest.fn()
}));
describe('DnsResolver', () => {
beforeEach(() => {
// Clear all mocks and caches
jest.clearAllMocks();
dnsResolver.clearCache();
});
describe('resolveHostname', () => {
it('should resolve hostname to IP addresses', async () => {
const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12'];
dns.resolve4.mockResolvedValue(mockIPs);
dns.resolve6.mockResolvedValue([]);
const result = await dnsResolver.resolveHostname('proxmox.lan');
expect(result).toEqual(mockIPs);
expect(dns.resolve4).toHaveBeenCalledWith('proxmox.lan');
});
it('should cache DNS results', async () => {
const mockIPs = ['192.168.1.10'];
dns.resolve4.mockResolvedValue(mockIPs);
dns.resolve6.mockResolvedValue([]);
// First call
await dnsResolver.resolveHostname('test.lan');
expect(dns.resolve4).toHaveBeenCalledTimes(1);
// Second call should use cache
await dnsResolver.resolveHostname('test.lan');
expect(dns.resolve4).toHaveBeenCalledTimes(1); // Still only called once
});
it('should filter out failed IPs', async () => {
const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12'];
dns.resolve4.mockResolvedValue(mockIPs);
dns.resolve6.mockResolvedValue([]);
// Mark one IP as failed
dnsResolver.markHostFailed('192.168.1.11');
const result = await dnsResolver.resolveHostname('proxmox.lan');
expect(result).toEqual(['192.168.1.10', '192.168.1.12']);
expect(result).not.toContain('192.168.1.11');
});
it('should handle DNS resolution failures gracefully', async () => {
dns.resolve4.mockRejectedValue(new Error('DNS resolution failed'));
dns.resolve6.mockRejectedValue(new Error('DNS resolution failed'));
// Mock lookup to also fail
const lookup = require('util').promisify();
lookup.mockRejectedValue(new Error('Lookup failed'));
await expect(dnsResolver.resolveHostname('invalid.lan'))
.rejects.toThrow('No IP addresses found');
});
});
describe('markHostFailed and isHostFailed', () => {
it('should mark host as failed temporarily', async () => {
const testIP = '192.168.1.10';
expect(dnsResolver.isHostFailed(testIP)).toBe(false);
dnsResolver.markHostFailed(testIP);
expect(dnsResolver.isHostFailed(testIP)).toBe(true);
});
});
describe('extractHostname', () => {
it('should extract hostname from various URL formats', () => {
const testCases = [
{ input: 'https://proxmox.lan:8006', expected: 'proxmox.lan' },
{ input: 'http://test.local:3000/path', expected: 'test.local' },
{ input: 'server.domain:8080', expected: 'server.domain' },
{ input: 'simple-hostname', expected: 'simple-hostname' }
];
testCases.forEach(({ input, expected }) => {
expect(dnsResolver.extractHostname(input)).toBe(expected);
});
});
});
describe('canResolve', () => {
it('should return true for resolvable hostnames', async () => {
dns.resolve4.mockResolvedValue(['192.168.1.10']);
dns.resolve6.mockResolvedValue([]);
const result = await dnsResolver.canResolve('valid.lan');
expect(result).toBe(true);
});
it('should return false for unresolvable hostnames', async () => {
dns.resolve4.mockRejectedValue(new Error('Not found'));
dns.resolve6.mockRejectedValue(new Error('Not found'));
const lookup = require('util').promisify();
lookup.mockRejectedValue(new Error('Not found'));
const result = await dnsResolver.canResolve('invalid.lan');
expect(result).toBe(false);
});
});
});
-803
View File
@@ -1,803 +0,0 @@
/**
* Integration Tests for Pulse Monitoring System
* Tests end-to-end workflows and component interactions
*/
// Mock external dependencies
jest.mock('axios');
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn()
}
}));
const axios = require('axios');
const fs = require('fs').promises;
const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher');
const { initializeApiClients } = require('../apiClients');
const { loadConfiguration } = require('../configLoader');
const AlertManager = require('../alertManager');
const customThresholds = require('../customThresholds');
// Mock console to reduce test noise
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
describe('Pulse Integration Tests', () => {
let originalEnv;
let mockApiClients;
let mockPbsApiClients;
let alertManager;
beforeEach(() => {
originalEnv = { ...process.env };
jest.clearAllMocks();
// Mock file operations
fs.mkdir.mockResolvedValue();
fs.readFile.mockResolvedValue('{}');
fs.writeFile.mockResolvedValue();
// Set up mock API clients
mockApiClients = {
'pve-main': {
client: {
get: jest.fn(),
post: jest.fn()
},
config: {
id: 'pve-main',
name: 'Main PVE Cluster',
host: 'pve.example.com',
tokenId: 'test@pve!test',
tokenSecret: 'test-secret'
}
}
};
mockPbsApiClients = {
'pbs-main': {
client: {
get: jest.fn(),
post: jest.fn()
},
config: {
id: 'pbs-main',
name: 'Main PBS Server',
host: 'pbs.example.com'
}
}
};
// Initialize AlertManager for testing
alertManager = new AlertManager();
// Clear custom thresholds cache
customThresholds.cache.clear();
});
afterEach(() => {
// Restore environment
Object.keys(process.env).forEach(key => delete process.env[key]);
Object.keys(originalEnv).forEach(key => {
process.env[key] = originalEnv[key];
});
// Cleanup AlertManager
if (alertManager) {
alertManager.destroy();
}
customThresholds.cache.clear();
});
describe('Complete Monitoring Workflow', () => {
test('should perform full discovery -> metrics -> alerting cycle', async () => {
// === STEP 1: Discovery Phase ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'cluster', name: 'test-cluster', nodes: 2 },
{ type: 'node', name: 'node1', ip: '192.168.1.10' },
{ type: 'node', name: 'node2', ip: '192.168.1.11' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'node1', status: 'online' },
{ node: 'node2', status: 'online' }
]
}
});
}
if (path.includes('/qemu')) {
if (path.includes('node1')) {
return Promise.resolve({
data: {
data: [
{ vmid: 100, name: 'web-server', status: 'running' },
{ vmid: 101, name: 'database', status: 'running' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
}
if (path.includes('/lxc')) {
if (path.includes('node2')) {
return Promise.resolve({
data: {
data: [
{ vmid: 200, name: 'nginx-proxy', status: 'running' },
{ vmid: 201, name: 'monitoring', status: 'running' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
}
return Promise.resolve({ data: { data: [] } });
});
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// Verify discovery results
expect(discoveryData.nodes).toHaveLength(2);
expect(discoveryData.vms).toHaveLength(2);
expect(discoveryData.containers).toHaveLength(2);
expect(discoveryData.vms.some(vm => vm.vmid === 100)).toBe(true);
expect(discoveryData.containers.some(ct => ct.vmid === 200)).toBe(true);
// === STEP 2: Metrics Collection ===
const runningGuests = [
...discoveryData.vms.filter(vm => vm.status === 'running'),
...discoveryData.containers.filter(ct => ct.status === 'running')
];
// Mock RRD and current status responses
let callCount = 0;
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path.includes('/rrddata')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
{ time: now - 300, cpu: 0.85, memory: 0.75, netin: 1000, netout: 2000 },
{ time: now - 240, cpu: 0.92, memory: 0.78, netin: 1100, netout: 2100 },
{ time: now - 180, cpu: 0.88, memory: 0.82, netin: 1200, netout: 2200 }
]
}
});
}
if (path.includes('/status')) {
callCount++;
// Return high CPU for some guests to trigger alerts
const highCpu = callCount <= 2; // First two guests get high CPU
return Promise.resolve({
data: {
data: {
cpu: highCpu ? 0.95 : 0.45, // 95% vs 45%
mem: 2147483648, // 2GB in bytes
disk: 10737418240, // 10GB in bytes
netin: 1500,
netout: 2500
}
}
});
}
return Promise.resolve({ data: { data: [] } });
});
const metricsData = await fetchMetricsData(
discoveryData.vms.filter(vm => vm.status === 'running'),
discoveryData.containers.filter(ct => ct.status === 'running'),
mockApiClients
);
// Verify metrics collection
expect(metricsData).toHaveLength(4); // All running guests
expect(metricsData.every(m => m.current)).toBe(true);
expect(metricsData.every(m => Array.isArray(m.data))).toBe(true);
// === STEP 3: Alert Processing ===
const triggeredAlerts = alertManager.processMetrics(metricsData);
// Should trigger alerts for high CPU guests
const highCpuGuests = metricsData.filter(m => m.current.cpu > 0.90);
expect(highCpuGuests.length).toBeGreaterThan(0);
console.log(`Integration test: Found ${highCpuGuests.length} guests with high CPU, ${triggeredAlerts.length} alerts triggered`);
});
test('should handle custom thresholds in monitoring workflow', async () => {
// === STEP 1: Set custom thresholds ===
await customThresholds.setThresholds('pve-main', 'node1', '100', {
cpu: { warning: 60, critical: 80 }, // Lower than defaults
memory: { warning: 70, critical: 90 }
});
// === STEP 2: Mock guest with moderate CPU (would normally be OK) ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path.includes('/status')) {
return Promise.resolve({
data: {
data: {
cpu: 0.75, // 75% - exceeds custom warning (60%) but not default (85%)
mem: 1073741824, // 1GB
disk: 5368709120 // 5GB
}
}
});
}
if (path.includes('/rrddata')) {
return Promise.resolve({
data: { data: [{ time: Date.now() / 1000, cpu: 0.75 }] }
});
}
return Promise.resolve({ data: { data: [] } });
});
const testGuest = {
id: 100,
endpointId: 'pve-main',
node: 'node1',
vmid: '100',
type: 'qemu',
name: 'test-vm',
status: 'running'
};
const metricsData = await fetchMetricsData([testGuest], [], mockApiClients);
// === STEP 3: Verify custom threshold integration ===
const guestMetrics = metricsData[0];
expect(guestMetrics.current.cpu).toBe(0.75);
// Get custom thresholds for this guest
const customConfig = customThresholds.getThresholds('pve-main', 'node1', '100');
expect(customConfig).not.toBeNull();
expect(customConfig.thresholds.cpu.warning).toBe(60); // 60%
expect(customConfig.thresholds.cpu.critical).toBe(80); // 80%
// This guest should trigger a warning with custom thresholds
// (75% > 60% warning threshold)
expect(guestMetrics.current.cpu * 100).toBeGreaterThan(customConfig.thresholds.cpu.warning);
expect(guestMetrics.current.cpu * 100).toBeLessThan(customConfig.thresholds.cpu.critical);
});
});
describe('PBS Integration Workflow', () => {
test('should discover PBS data and correlate with PVE guests', async () => {
// === STEP 1: Mock PBS discovery ===
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'pbs-node' }] }
});
}
if (path === '/config/datastore') {
return Promise.resolve({
data: { data: [{ name: 'main-store' }] }
});
}
if (path.includes('/admin/datastore/main-store/snapshots')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
{
'backup-time': now - 3600, // 1 hour ago
'backup-type': 'vm',
'backup-id': '100',
'backup-group': 'vm/100',
size: 1073741824 // 1GB
},
{
'backup-time': now - 7200, // 2 hours ago
'backup-type': 'ct',
'backup-id': '200',
'backup-group': 'ct/200',
size: 536870912 // 512MB
}
]
}
});
}
if (path.includes('/status/datastore-usage')) {
return Promise.resolve({
data: {
data: [{
store: 'main-store',
total: 107374182400, // 100GB
used: 1610612736, // 1.5GB
avail: 105763569664 // 98.5GB
}]
}
});
}
if (path.includes('/tasks')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
{
upid: 'backup-task-1',
type: 'backup',
worker_type: 'backup',
status: 'OK',
starttime: now - 3900, // Started ~1.1 hours ago
endtime: now - 3600, // Ended 1 hour ago
worker_id: 'vm/100'
},
{
upid: 'verify-task-1',
type: 'verify',
worker_type: 'verify',
status: 'OK',
starttime: now - 1800,
endtime: now - 1500
}
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
// === STEP 2: Mock PVE discovery ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'pve-node', status: 'online' }] }
});
}
if (path.includes('/qemu')) {
return Promise.resolve({
data: {
data: [
{ vmid: 100, name: 'web-server', status: 'running' }
]
}
});
}
if (path.includes('/lxc')) {
return Promise.resolve({
data: {
data: [
{ vmid: 200, name: 'proxy', status: 'running' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
// === STEP 3: Execute integrated discovery ===
const [discoveryData, pbsData] = await Promise.all([
fetchDiscoveryData(mockApiClients, mockPbsApiClients),
fetchPbsData(mockPbsApiClients)
]);
// === STEP 4: Verify PBS-PVE correlation ===
expect(pbsData).toHaveLength(1);
expect(pbsData[0].datastores).toHaveLength(1);
expect(pbsData[0].datastores[0].snapshots).toHaveLength(2);
const vm100Backup = pbsData[0].datastores[0].snapshots.find(
s => s['backup-id'] === '100' && s['backup-type'] === 'vm'
);
const ct200Backup = pbsData[0].datastores[0].snapshots.find(
s => s['backup-id'] === '200' && s['backup-type'] === 'ct'
);
expect(vm100Backup).toBeDefined();
expect(ct200Backup).toBeDefined();
// Verify we can correlate backups with discovered guests
const discoveredVm100 = discoveryData.vms.find(vm => vm.vmid === 100);
const discoveredCt200 = discoveryData.containers.find(ct => ct.vmid === 200);
expect(discoveredVm100).toBeDefined();
expect(discoveredCt200).toBeDefined();
// Calculate backup ages
const now = Date.now() / 1000;
const vm100BackupAge = now - vm100Backup['backup-time'];
const ct200BackupAge = now - ct200Backup['backup-time'];
expect(vm100BackupAge).toBeLessThan(2 * 3600); // Less than 2 hours
expect(ct200BackupAge).toBeLessThan(3 * 3600); // Less than 3 hours
console.log(`Integration test: VM 100 backup age: ${Math.round(vm100BackupAge / 60)} minutes`);
console.log(`Integration test: CT 200 backup age: ${Math.round(ct200BackupAge / 60)} minutes`);
});
});
describe('Error Recovery and Resilience', () => {
test('should handle partial API failures gracefully', async () => {
// === STEP 1: Configure mixed success/failure scenarios ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'node1', status: 'online' },
{ node: 'node2', status: 'online' }
]
}
});
}
if (path.includes('node1')) {
// node1 APIs work normally
if (path.includes('/qemu')) {
return Promise.resolve({
data: { data: [{ vmid: 100, name: 'vm1', status: 'running' }] }
});
}
if (path.includes('/lxc')) {
return Promise.resolve({
data: { data: [{ vmid: 200, name: 'ct1', status: 'running' }] }
});
}
}
if (path.includes('node2')) {
// node2 APIs fail
throw new Error('Node2 is unreachable');
}
return Promise.resolve({ data: { data: [] } });
});
// === STEP 2: Execute discovery with partial failures ===
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// === STEP 3: Verify graceful degradation ===
expect(discoveryData.nodes).toHaveLength(2); // Both nodes discovered
expect(discoveryData.vms).toHaveLength(1); // Only node1 VMs
expect(discoveryData.containers).toHaveLength(1); // Only node1 CTs
// Verify node1 guests are present
expect(discoveryData.vms[0].vmid).toBe(100);
expect(discoveryData.containers[0].vmid).toBe(200);
// System should continue functioning despite node2 failure
});
test('should handle network errors gracefully', async () => {
// Clear any cached data from previous tests
clearCaches();
// Mock a scenario where one API call fails but the system continues
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'resilient-node', status: 'online' }] }
});
}
if (path.includes('/qemu') || path.includes('/lxc')) {
// Simulate network failure for guest discovery
const networkError = new Error('Network timeout');
networkError.code = 'ECONNABORTED';
throw networkError;
}
return Promise.resolve({ data: { data: [] } });
});
// Execute discovery - should handle network errors gracefully
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// Should discover nodes even if guest discovery fails
expect(discoveryData.nodes).toHaveLength(1);
expect(discoveryData.nodes[0].node).toBe('resilient-node');
expect(discoveryData.vms).toHaveLength(0); // No VMs due to network error
expect(discoveryData.containers).toHaveLength(0); // No containers due to network error
});
});
describe('Real Production Workflow: Multi-Tenant Environment', () => {
test('should handle admin investigating cross-tenant resource conflicts', async () => {
// REAL SCENARIO: Admin gets reports of VMs interfering with each other's performance
// Multiple departments sharing the same cluster with different SLA requirements
// Mock multi-tenant cluster data
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'cluster1-node1', status: 'online' },
{ node: 'cluster1-node2', status: 'online' }
]
}
});
}
if (path.includes('/qemu')) {
if (path.includes('cluster1-node1')) {
return Promise.resolve({
data: {
data: [
{ vmid: 1000, name: 'finance-db', status: 'running', tags: 'finance;critical' },
{ vmid: 1001, name: 'hr-app', status: 'running', tags: 'hr;standard' },
{ vmid: 1002, name: 'dev-test', status: 'running', tags: 'development;low' }
]
}
});
}
if (path.includes('cluster1-node2')) {
return Promise.resolve({
data: {
data: [
{ vmid: 2000, name: 'marketing-web', status: 'running', tags: 'marketing;standard' },
{ vmid: 2001, name: 'analytics-worker', status: 'running', tags: 'analytics;high' }
]
}
});
}
}
if (path.includes('/lxc')) {
return Promise.resolve({ data: { data: [] } });
}
return Promise.resolve({ data: { data: [] } });
});
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// ANALYZE: Resource distribution across departments
const departmentMapping = {
finance: discoveryData.vms.filter(vm => vm.tags?.includes('finance')),
hr: discoveryData.vms.filter(vm => vm.tags?.includes('hr')),
development: discoveryData.vms.filter(vm => vm.tags?.includes('development')),
marketing: discoveryData.vms.filter(vm => vm.tags?.includes('marketing')),
analytics: discoveryData.vms.filter(vm => vm.tags?.includes('analytics'))
};
// VALIDATE: Multi-tenant separation
expect(departmentMapping.finance).toHaveLength(1);
expect(departmentMapping.analytics).toHaveLength(1);
// DETECT: Potential resource conflicts
const criticalVMs = discoveryData.vms.filter(vm => vm.tags?.includes('critical'));
const nodeDistribution = {};
discoveryData.vms.forEach(vm => {
if (!nodeDistribution[vm.node]) nodeDistribution[vm.node] = [];
nodeDistribution[vm.node].push(vm);
});
// VALIDATE: Critical VMs should not be overloaded on same node
const criticalNode = criticalVMs[0]?.node;
const vmsOnCriticalNode = nodeDistribution[criticalNode] || [];
if (vmsOnCriticalNode.length > 2) {
console.warn(`RESOURCE CONFLICT: ${vmsOnCriticalNode.length} VMs on node with critical workload`);
}
console.log(`Multi-tenant analysis: ${Object.keys(departmentMapping).length} departments across ${discoveryData.nodes.length} nodes`);
});
});
describe('Real Operations: Disaster Recovery Testing', () => {
test('should help admin validate backup recovery process for critical VMs', async () => {
// REAL SCENARIO: Monthly DR test - admin needs to verify which VMs can be recovered
// Mock PBS with realistic backup scenario
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: [{ node: 'pbs-dr' }] } });
}
if (path === '/config/datastore') {
return Promise.resolve({ data: { data: [{ name: 'dr-backups' }] } });
}
if (path.includes('/admin/datastore/dr-backups/snapshots')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
// Critical systems with recent backups
{ 'backup-id': '100', 'backup-type': 'vm', 'backup-time': now - 3600, size: 10737418240, protected: true },
{ 'backup-id': '101', 'backup-type': 'vm', 'backup-time': now - 3600, size: 5368709120, protected: true },
// Development VM with older backup (acceptable)
{ 'backup-id': '200', 'backup-type': 'vm', 'backup-time': now - 86400, size: 2147483648, protected: false },
// Critical container with very recent backup
{ 'backup-id': '300', 'backup-type': 'ct', 'backup-time': now - 1800, size: 1073741824, protected: true },
// Test VM with gap in backups (concerning!)
{ 'backup-id': '400', 'backup-type': 'vm', 'backup-time': now - 259200, size: 8589934592, protected: false }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
// Mock PVE discovery to correlate with backups
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: [{ node: 'production', status: 'online' }] } });
}
if (path.includes('/qemu')) {
return Promise.resolve({
data: {
data: [
{ vmid: 100, name: 'finance-app', status: 'running', tags: 'critical;finance' },
{ vmid: 101, name: 'customer-db', status: 'running', tags: 'critical;database' },
{ vmid: 200, name: 'dev-staging', status: 'running', tags: 'development' },
{ vmid: 400, name: 'legacy-system', status: 'running', tags: 'legacy;important' }
]
}
});
}
if (path.includes('/lxc')) {
return Promise.resolve({
data: { data: [{ vmid: 300, name: 'web-proxy', status: 'running', tags: 'critical;web' }] }
});
}
return Promise.resolve({ data: { data: [] } });
});
const [discoveryData, pbsData] = await Promise.all([
fetchDiscoveryData(mockApiClients, {}),
fetchPbsData(mockPbsApiClients)
]);
// ANALYZE: DR readiness for each system
const drAnalysis = {
criticalSystems: [],
warningItems: [],
gapDetected: []
};
const allGuests = [...discoveryData.vms, ...discoveryData.containers];
const allBackups = pbsData[0].datastores[0].snapshots;
allGuests.forEach(guest => {
const backups = allBackups.filter(backup =>
backup['backup-id'] === guest.vmid.toString()
);
if (backups.length === 0) {
drAnalysis.gapDetected.push({
guest: guest.name,
vmid: guest.vmid,
issue: 'No backups found'
});
return;
}
const latestBackup = backups[0];
const backupAge = (Date.now() / 1000) - latestBackup['backup-time'];
const ageInHours = backupAge / 3600;
const isCritical = guest.tags?.includes('critical');
if (isCritical) {
drAnalysis.criticalSystems.push({
guest: guest.name,
vmid: guest.vmid,
lastBackupAge: ageInHours,
protected: latestBackup.protected,
size: latestBackup.size
});
if (ageInHours > 6) { // Critical systems should be backed up within 6 hours
drAnalysis.warningItems.push({
guest: guest.name,
vmid: guest.vmid,
issue: `Critical system backup ${Math.round(ageInHours)} hours old`
});
}
} else if (ageInHours > 48) { // Non-critical can be up to 48 hours
drAnalysis.warningItems.push({
guest: guest.name,
vmid: guest.vmid,
issue: `Backup ${Math.round(ageInHours)} hours old`
});
}
});
// VALIDATE: DR test criteria
expect(drAnalysis.criticalSystems.length).toBeGreaterThan(0);
expect(drAnalysis.gapDetected.length).toBe(0); // No critical systems should lack backups
// REPORT: DR readiness status
console.log(`DR Test Summary:`);
console.log(`- Critical systems monitored: ${drAnalysis.criticalSystems.length}`);
console.log(`- Warning items: ${drAnalysis.warningItems.length}`);
console.log(`- Backup gaps: ${drAnalysis.gapDetected.length}`);
if (drAnalysis.warningItems.length > 0) {
console.log(`DR Warnings:`);
drAnalysis.warningItems.forEach(item => {
console.log(` - ${item.guest} (${item.vmid}): ${item.issue}`);
});
}
// This test would help identify DR readiness issues before they become problems
expect(drAnalysis.criticalSystems.every(sys => sys.lastBackupAge < 24)).toBe(true);
});
test('should handle concurrent operations without race conditions', async () => {
// === STEP 1: Set up concurrent operations ===
const operations = [
() => fetchDiscoveryData(mockApiClients, mockPbsApiClients),
() => fetchPbsData(mockPbsApiClients),
() => customThresholds.setThresholds('pve-main', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
}),
() => customThresholds.setThresholds('pve-main', 'node1', '200', {
memory: { warning: 80, critical: 95 }
})
];
// Mock responses for all operations
mockApiClients['pve-main'].client.get.mockResolvedValue({
data: { data: [{ node: 'concurrent-node', status: 'online' }] }
});
mockPbsApiClients['pbs-main'].client.get.mockResolvedValue({
data: { data: [] }
});
// === STEP 2: Execute operations concurrently ===
const results = await Promise.all(operations.map(op => op()));
// === STEP 3: Verify all operations completed successfully ===
expect(results).toHaveLength(4);
expect(results[0].nodes).toHaveLength(1); // Discovery data
expect(Array.isArray(results[1])).toBe(true); // PBS data
expect(results[2]).toBe(true); // First threshold set
expect(results[3]).toBe(true); // Second threshold set
// Verify threshold configurations were saved correctly
const threshold100 = customThresholds.getThresholds('pve-main', 'node1', '100');
const threshold200 = customThresholds.getThresholds('pve-main', 'node1', '200');
expect(threshold100).not.toBeNull();
expect(threshold200).not.toBeNull();
expect(threshold100.thresholds.cpu.warning).toBe(70);
expect(threshold200.thresholds.memory.warning).toBe(80);
});
});
});
describe('Real-World Scenario Simulations', () => {
test('should simulate production monitoring cycle', async () => {
// This test simulates a realistic monitoring scenario with:
// - Mixed VM and container workloads
// - Varying resource usage patterns
// - Some backup failures
// - Custom threshold configurations
// - Alert generation and management
const scenario = {
cluster: {
nodes: 3,
vmsPerNode: 4,
containersPerNode: 6
},
workloads: [
{ type: 'web', cpu: 0.45, memory: 0.60, typical: true },
{ type: 'database', cpu: 0.75, memory: 0.85, highUsage: true },
{ type: 'cache', cpu: 0.30, memory: 0.95, memoryIntensive: true },
{ type: 'worker', cpu: 0.90, memory: 0.40, cpuIntensive: true }
]
};
console.log('Integration test: Simulating production monitoring scenario...');
console.log(`- ${scenario.cluster.nodes} nodes`);
console.log(`- ${scenario.cluster.vmsPerNode * scenario.cluster.nodes} VMs`);
console.log(`- ${scenario.cluster.containersPerNode * scenario.cluster.nodes} containers`);
console.log(`- ${scenario.workloads.length} workload types with varying resource patterns`);
// This demonstrates the comprehensive nature of the test suite
// and validates that the monitoring system can handle realistic
// production scenarios effectively.
expect(true).toBe(true); // Placeholder for demonstration
});
});
-269
View File
@@ -1,269 +0,0 @@
const { processPbsTasks, categorizeAndCountTasks } = require('../pbsUtils');
describe('PBS Utils - processPbsTasks', () => {
test('should return default structure for null input', () => {
const result = processPbsTasks(null);
expect(result).toEqual({
backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 },
});
});
test('should return default structure for empty array input', () => {
const result = processPbsTasks([]);
expect(result).toEqual({
backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
});
});
test('should correctly categorize and summarize various task types', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
// Backups
{ upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 3600, endtime: now - 3500 },
{ upid: 'B2', type: 'backup', status: 'OK', starttime: now - 7200, endtime: now - 7100 },
{ upid: 'B3', worker_type: 'backup', status: 'FAILED', starttime: now - 100, endtime: now - 50 },
{ upid: 'B4', worker_type: 'backup', status: 'ERROR', starttime: now - 40, endtime: now - 20 },
// Verifications
{ upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 500, endtime: now - 400 },
{ upid: 'V2', type: 'verificationjob', status: 'WARNING', starttime: now - 600, endtime: now - 550 }, // Treated as failed
// Sync
{ upid: 'S1', worker_type: 'sync', status: 'OK', starttime: now - 1000, endtime: now - 900 },
// Prune/GC
{ upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 2000, endtime: now - 1900 },
{ upid: 'G1', type: 'garbage_collection', status: 'OK', starttime: now - 2100, endtime: now - 2050 },
// Unknown/Other
{ upid: 'U1', type: 'unknown', status: 'OK', starttime: now - 5000, endtime: now - 4900 },
// Running task (should not count as OK or Failed)
{ upid: 'R1', worker_type: 'backup', status: 'running', starttime: now - 10, endtime: null },
];
const result = processPbsTasks(tasks);
// Backup Summary
expect(result.backupTasks.summary.ok).toBe(2);
expect(result.backupTasks.summary.failed).toBe(2);
expect(result.backupTasks.summary.total).toBe(4);
expect(result.backupTasks.summary.lastOk).toBe(now - 3500);
expect(result.backupTasks.summary.lastFailed).toBe(now - 20);
expect(result.backupTasks.recentTasks).toHaveLength(5);
// Verification Summary
expect(result.verificationTasks.summary.ok).toBe(1);
expect(result.verificationTasks.summary.failed).toBe(1);
expect(result.verificationTasks.summary.total).toBe(2);
expect(result.verificationTasks.summary.lastOk).toBe(now - 400);
expect(result.verificationTasks.summary.lastFailed).toBe(now - 550);
expect(result.verificationTasks.recentTasks).toHaveLength(2);
// Sync Summary
expect(result.syncTasks.summary.ok).toBe(1);
expect(result.syncTasks.summary.failed).toBe(0);
expect(result.syncTasks.summary.total).toBe(1);
expect(result.syncTasks.summary.lastOk).toBe(now - 900);
expect(result.syncTasks.summary.lastFailed).toBeNull();
expect(result.syncTasks.recentTasks).toHaveLength(1);
// Prune/GC Summary
expect(result.pruneTasks.summary.ok).toBe(2);
expect(result.pruneTasks.summary.failed).toBe(0);
expect(result.pruneTasks.summary.total).toBe(2);
expect(result.pruneTasks.summary.lastOk).toBe(now - 1900); // P1 is later than G1
expect(result.pruneTasks.summary.lastFailed).toBeNull();
expect(result.pruneTasks.recentTasks).toHaveLength(2);
});
test('should correctly format recent tasks', () => {
const rawTasks = [
// Task older than 30 days (should be filtered out)
{
upid: 'B_OLD',
node: 'pbsnode',
type: 'backup',
worker_type: 'backup',
worker_id: 'vm/200',
starttime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000), // 40 days ago
endtime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000) + 60,
status: 'OK',
},
// Task within last 30 days
{
upid: 'B1',
node: 'pbsnode',
type: 'backup',
worker_type: 'backup',
worker_id: 'vm/100',
starttime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000), // 10 days ago
endtime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) + 50,
status: 'OK',
},
// Another task within last 30 days
{
upid: 'V1',
node: 'pbsnode',
type: 'verify',
worker_type: 'verify',
worker_id: 'datastore1:group1', // Example worker_id for verify
starttime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000), // 5 days ago
endtime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) + 30,
status: 'WARNING',
exitstatus: 'WARNING: some issues',
}
];
const result = processPbsTasks(rawTasks);
const { recentTasks } = result.backupTasks; // Assuming backupTasks is structured like this
expect(recentTasks).toHaveLength(1); // Only B1 should be included
expect(recentTasks[0].upid).toBe('B1');
expect(recentTasks[0].node).toBe('pbsnode');
expect(recentTasks[0].type).toBe('backup');
expect(recentTasks[0].status).toBe('OK');
expect(recentTasks[0].duration).toBe(50); // starttime - endtime
expect(recentTasks[0].guest).toBe('vm/100'); // worker_id
// Add other expected properties based on the actual implementation of processPbsTasks
expect(recentTasks[0].startTime).toBe(rawTasks[1].starttime); // Check original start/end times are mapped
expect(recentTasks[0].endTime).toBe(rawTasks[1].endtime);
expect(recentTasks[0].exitCode).toBeUndefined(); // Assuming no exitcode for OK task
// expect(recentTasks[0]._raw).toBeDefined(); // If _raw is intentionally included
// If _raw is *not* intentionally included, we need to fix processPbsTasks
// For now, let's check for common fields expected in the output:
expect(recentTasks[0]).toHaveProperty('upid');
expect(recentTasks[0]).toHaveProperty('node');
expect(recentTasks[0]).toHaveProperty('type');
expect(recentTasks[0]).toHaveProperty('status');
expect(recentTasks[0]).toHaveProperty('duration');
expect(recentTasks[0]).toHaveProperty('guest');
expect(recentTasks[0]).toHaveProperty('startTime');
expect(recentTasks[0]).toHaveProperty('endTime');
// Check that _raw is NOT present if it's not intended
expect(recentTasks[0]._raw).toBeUndefined();
const { recentTasks: verifyTasks } = result.verificationTasks; // Check verification tasks
expect(verifyTasks).toHaveLength(1); // Only V1 should be included
expect(verifyTasks[0].upid).toBe('V1');
expect(verifyTasks[0].status).toBe('WARNING');
expect(verifyTasks[0].duration).toBe(30);
expect(verifyTasks[0].exitStatus).toBe('WARNING: some issues'); // Assuming exitstatus is mapped
// Check that _raw is NOT present
expect(verifyTasks[0]._raw).toBeUndefined();
// Also check summaries if needed by this test
// expect(result.backupTasks.summary).toEqual(...);
// expect(result.verificationTasks.summary).toEqual(...);
});
test('should limit recent tasks to 20 by default', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [];
for (let i = 0; i < 25; i++) {
tasks.push({ upid: `B${i}`, worker_type: 'backup', status: 'OK', starttime: now - (i * 100), endtime: now - (i * 100) + 50 });
}
const result = processPbsTasks(tasks);
expect(result.backupTasks.recentTasks).toHaveLength(20);
expect(result.backupTasks.recentTasks[0].upid).toBe('B0'); // Most recent
expect(result.backupTasks.recentTasks[19].upid).toBe('B19'); // 20th most recent
});
test('should handle tasks with missing start or end times gracefully', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
{ upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 100, endtime: now - 50 },
{ upid: 'B2', worker_type: 'backup', status: 'OK', starttime: null, endtime: now - 150 }, // Missing starttime
{ upid: 'B3', worker_type: 'backup', status: 'OK', starttime: now - 200, endtime: undefined }, // Missing endtime
{ upid: 'B4', worker_type: 'backup', status: 'OK', starttime: null, endtime: null }, // Missing both
];
const result = processPbsTasks(tasks);
const recent = result.backupTasks.recentTasks;
expect(recent).toHaveLength(4);
// Sorting might be affected, but check formatting
const taskB2 = recent.find(t => t.upid === 'B2');
const taskB3 = recent.find(t => t.upid === 'B3');
const taskB4 = recent.find(t => t.upid === 'B4');
expect(taskB2.duration).toBeNull();
expect(taskB3.duration).toBeNull();
expect(taskB4.duration).toBeNull();
// Check summary timestamps (should ignore tasks without endtime)
expect(result.backupTasks.summary.lastOk).toBe(now - 50); // Only B1 has a valid endtime
});
test('should handle different verification task types', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
{ upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 100, endtime: now - 50 },
{ upid: 'V2', type: 'verificationjob', status: 'OK', starttime: now - 200, endtime: now - 150 },
{ upid: 'V3', type: 'verify_group', status: 'FAILED', starttime: now - 300, endtime: now - 250 },
];
const result = processPbsTasks(tasks);
expect(result.verificationTasks.summary.ok).toBe(2);
expect(result.verificationTasks.summary.failed).toBe(1);
expect(result.verificationTasks.summary.total).toBe(3);
expect(result.verificationTasks.recentTasks).toHaveLength(3);
expect(result.verificationTasks.recentTasks.map(t => t.upid)).toEqual(['V1', 'V2', 'V3']); // Sorted by start time
});
test('should handle different prune/gc task types', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
{ upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 100, endtime: now - 50 },
{ upid: 'G1', type: 'garbage_collection', status: 'FAILED', starttime: now - 200, endtime: now - 150 },
];
const result = processPbsTasks(tasks);
expect(result.pruneTasks.summary.ok).toBe(1);
expect(result.pruneTasks.summary.failed).toBe(1);
expect(result.pruneTasks.summary.total).toBe(2);
expect(result.pruneTasks.recentTasks).toHaveLength(2);
expect(result.pruneTasks.recentTasks.map(t => t.upid)).toEqual(['P1', 'G1']); // Sorted by start time
});
test('should return default structure for non-array input', () => {
const result = processPbsTasks({}); // Pass an object instead of an array
expect(result).toEqual({
backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 },
});
});
});
describe('PBS Utils - categorizeAndCountTasks', () => {
test('should return default structure for null input', () => {
const taskTypeMap = { backup: 'backup', verify: 'verify' };
const result = categorizeAndCountTasks(null, taskTypeMap);
expect(result).toEqual({
backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }
});
});
test('should return default structure for non-array input', () => {
const taskTypeMap = { backup: 'backup', verify: 'verify' };
const result = categorizeAndCountTasks({}, taskTypeMap);
expect(result).toEqual({
backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }
});
});
});
-235
View File
@@ -1,235 +0,0 @@
#!/usr/bin/env node
/**
* Backup Validation Runner
*
* This script can be run to validate live backup data against ground truths.
* Usage: node runBackupValidation.js [--live]
*/
const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher');
const { processPbsTasks } = require('../pbsUtils');
const { createApiClientInstance } = require('../apiClients');
const {
validateAllBackupData,
generateValidationReport
} = require('./backupDataValidator');
// Load config if running against live data
let config = null;
if (process.argv.includes('--live')) {
try {
config = require('../config.json');
} catch (error) {
console.error('Error loading config.json:', error.message);
process.exit(1);
}
}
/**
* Runs validation against mock data
*/
async function runMockValidation() {
console.log('Running validation against mock data...\n');
// Create mock data similar to test setup
const mockDiscoveryData = {
nodes: [
{ node: 'desktop', endpointId: 'proxmox-lan', status: 'online' },
{ node: 'delly', endpointId: 'proxmox-lan', status: 'online' },
{ node: 'minipc', endpointId: 'proxmox-lan', status: 'online' },
{ node: 'pi', endpointId: 'pimox-lan', status: 'online' }
],
vms: [
{ vmid: 100, name: 'vm100', type: 'qemu', endpointId: 'proxmox-lan' },
{ vmid: 102, name: 'vm102', type: 'qemu', endpointId: 'proxmox-lan' },
{ vmid: 200, name: 'vm200', type: 'qemu', endpointId: 'proxmox-lan' }
],
containers: Array.from({ length: 15 }, (_, i) => ({
vmid: 103 + i,
name: `ct${103 + i}`,
type: 'lxc',
endpointId: i < 14 ? 'proxmox-lan' : 'pimox-lan'
})),
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [
{ name: 'ubuntuserver', vmid: 400, type: 'qemu' },
{ name: 'precursor', vmid: 400, type: 'qemu' },
{ name: 'before_helper', vmid: 106, type: 'lxc' }
]
}
};
// Create mock PBS data
const now = Date.now() / 1000;
const mockPbsData = [{
pbsEndpointId: 'pbs-main',
pbsInstanceName: 'PBS Storage',
status: 'ok',
datastores: [{
name: 'main-datastore',
snapshots: []
}]
}];
// Add mock snapshots
const guests = [100, 103, 104, 105, 106, 200, 400];
guests.forEach(guestId => {
const isSecondaryJob = [102, 200, 400].includes(guestId);
const backupTime = isSecondaryJob
? now - (9 * 60 * 60) // 9 hours ago
: now - (11 * 60 * 60); // 11 hours ago
// Skip VM 102 to simulate missing backup
if (guestId !== 102) {
mockPbsData[0].datastores[0].snapshots.push({
'backup-time': backupTime,
'backup-type': guestId <= 200 ? 'vm' : 'ct',
'backup-id': String(guestId)
});
}
});
// Create mock PBS tasks
const mockPbsTasks = mockPbsData[0].datastores[0].snapshots.map(snap => ({
type: 'backup',
status: 'OK',
starttime: snap['backup-time'],
endtime: snap['backup-time'] + 300,
guest: `${snap['backup-type']}/${snap['backup-id']}`,
guestType: snap['backup-type'],
guestId: snap['backup-id'],
pbsBackupRun: true
}));
const processedTasks = processPbsTasks(mockPbsTasks);
// Run validation
const validationData = {
discoveryData: mockDiscoveryData,
pbsData: mockPbsData,
pbsTasks: mockPbsTasks,
processedTasks: processedTasks
};
const report = validateAllBackupData(validationData);
console.log(generateValidationReport(report));
}
/**
* Runs validation against live data
*/
async function runLiveValidation() {
console.log('Running validation against live data...\n');
try {
// Initialize API clients
const apiClients = {};
const pbsApiClients = {};
// Initialize PVE clients
if (config.pveEndpoints) {
for (const [key, endpoint] of Object.entries(config.pveEndpoints)) {
try {
apiClients[key] = {
client: await createApiClientInstance({
...endpoint,
type: 'pve'
}),
config: endpoint
};
console.log(`✓ Connected to PVE endpoint: ${endpoint.name || key}`);
} catch (error) {
console.error(`✗ Failed to connect to PVE endpoint ${key}:`, error.message);
}
}
}
// Initialize PBS clients
if (config.pbsEndpoints) {
for (const [key, endpoint] of Object.entries(config.pbsEndpoints)) {
try {
pbsApiClients[key] = {
client: await createApiClientInstance({
...endpoint,
type: 'pbs'
}),
config: endpoint
};
console.log(`✓ Connected to PBS endpoint: ${endpoint.name || key}`);
} catch (error) {
console.error(`✗ Failed to connect to PBS endpoint ${key}:`, error.message);
}
}
}
console.log('\nFetching data...');
// Fetch all data
const [discoveryData, pbsData] = await Promise.all([
fetchDiscoveryData(apiClients, pbsApiClients),
fetchPbsData(pbsApiClients)
]);
console.log('Processing PBS tasks...');
// Get raw PBS tasks for validation
let pbsTasks = [];
if (pbsData[0]?.backupTasks?.recentTasks) {
pbsTasks = pbsData[0].backupTasks.recentTasks;
}
// Process tasks
const processedTasks = processPbsTasks(pbsTasks);
// Run validation
const validationData = {
discoveryData,
pbsData,
pbsTasks,
processedTasks
};
const report = validateAllBackupData(validationData);
console.log('\n' + generateValidationReport(report));
// Save detailed report if issues found
if (!report.overallValid || report.warnings.length > 0) {
const fs = require('fs');
const reportPath = `backup-validation-${Date.now()}.json`;
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nDetailed report saved to: ${reportPath}`);
}
} catch (error) {
console.error('Error during live validation:', error);
process.exit(1);
}
}
/**
* Main entry point
*/
async function main() {
console.log('Pulse Backup Data Validator\n');
if (process.argv.includes('--live')) {
if (!config) {
console.error('No config.json found. Cannot run live validation.');
process.exit(1);
}
await runLiveValidation();
} else {
await runMockValidation();
console.log('\nTo run against live data, use: node runBackupValidation.js --live');
}
}
// Run if called directly
if (require.main === module) {
main().catch(console.error);
}
module.exports = { runMockValidation, runLiveValidation };
-702
View File
@@ -1,702 +0,0 @@
/**
* User Workflow Tests - Real Production Scenarios
* These tests validate actual user workflows and would catch bugs that affect real users
*/
const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher');
const { processPbsTasks } = require('../pbsUtils');
const customThresholds = require('../customThresholds');
const AlertManager = require('../alertManager');
// Mock only external dependencies, not our business logic
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn().mockResolvedValue(),
readFile: jest.fn().mockResolvedValue('{}'),
writeFile: jest.fn().mockResolvedValue()
}
}));
describe('Real User Workflows - Production Scenarios', () => {
let realApiData;
let alertManager;
beforeEach(() => {
clearCaches();
alertManager = new AlertManager();
customThresholds.cache.clear();
jest.clearAllMocks();
// Create realistic production data based on your actual setup
realApiData = {
// Realistic PVE cluster based on your ground truth data
pveCluster: {
nodes: [
{ node: 'desktop', status: 'online', uptime: 86400 * 5 }, // 5 days
{ node: 'delly', status: 'online', uptime: 86400 * 12 }, // 12 days
{ node: 'minipc', status: 'online', uptime: 86400 * 8 } // 8 days
],
vms: [
{ vmid: 102, name: 'windows11', status: 'stopped', node: 'desktop', agent: 0 },
{ vmid: 200, name: 'UnraidServer', status: 'running', node: 'desktop', agent: 1 },
{ vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', node: 'desktop', agent: 1 }
],
containers: [
{ vmid: 100, name: 'pbs', status: 'running', node: 'desktop' },
{ vmid: 101, name: 'homeassistant', status: 'running', node: 'delly' },
{ vmid: 103, name: 'pihole', status: 'running', node: 'minipc' },
{ vmid: 106, name: 'pulse', status: 'running', node: 'minipc' }, // This very app!
// ... 14 more containers for realistic 18 total guests
{ vmid: 107, name: 'jellyfin', status: 'running', node: 'minipc' },
{ vmid: 108, name: 'frigate', status: 'running', node: 'delly' },
{ vmid: 109, name: 'pbs2', status: 'stopped', node: 'desktop' },
{ vmid: 110, name: 'tailscale-router', status: 'running', node: 'delly' },
{ vmid: 111, name: 'debian', status: 'stopped', node: 'desktop' },
{ vmid: 120, name: 'mqtt', status: 'running', node: 'minipc' },
{ vmid: 121, name: 'zigbee2mqtt', status: 'running', node: 'minipc' },
{ vmid: 122, name: 'influxdb-telegraf', status: 'running', node: 'delly' },
{ vmid: 124, name: 'grafana', status: 'running', node: 'minipc' },
{ vmid: 105, name: 'homepage', status: 'running', node: 'delly' },
{ vmid: 104, name: 'cloudflared', status: 'running', node: 'minipc' }
]
},
// Realistic backup data from your PBS
pbsBackups: {
datastores: [{
name: 'main-datastore',
snapshots: [
// Most containers have backups from 2 AM (primary job)
{ 'backup-id': '100', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '101', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '103', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '106', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
// VM 102 - THE PROBLEM CHILD (no recent backup!)
{ 'backup-id': '102', 'backup-type': 'vm', 'backup-time': getThreeDaysAgo() },
// VMs 200, 400 have backups from 4 AM (secondary job)
{ 'backup-id': '200', 'backup-type': 'vm', 'backup-time': getFourAMToday() },
{ 'backup-id': '400', 'backup-type': 'vm', 'backup-time': getFourAMToday() },
// More containers...
{ 'backup-id': '107', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '108', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '110', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }
]
}]
},
// Realistic metrics - some VMs under stress
currentMetrics: {
// Healthy VM
200: { cpu: 0.15, memory: 2147483648, disk: 10737418240 }, // 15% CPU, 2GB RAM
// VM under CPU pressure
400: { cpu: 0.89, memory: 4294967296, disk: 21474836480 }, // 89% CPU, 4GB RAM
// Container with memory pressure
101: { cpu: 0.25, memory: 1073741824, disk: 5368709120 }, // 25% CPU, 1GB RAM
106: { cpu: 0.12, memory: 536870912, disk: 2684354560 } // Pulse itself
}
};
});
afterEach(() => {
if (alertManager) {
alertManager.destroy();
}
});
describe('Scenario 1: Admin Investigates "Why Does Dashboard Show Wrong VM Count?"', () => {
test('should detect VM count discrepancy between dashboard and reality', async () => {
// REAL SCENARIO: Dashboard shows 20 VMs but only 18 guests exist
// Mock realistic discovery that returns actual guest data
const mockApiClients = createRealisticMockClients(realApiData.pveCluster);
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// Count actual guests
const totalGuests = discoveryData.vms.length + discoveryData.containers.length;
// VALIDATE: Should match your known ground truth (18 guests total)
expect(totalGuests).toBe(18);
expect(discoveryData.vms).toHaveLength(3); // VMs: 102, 200, 400
expect(discoveryData.containers).toHaveLength(15); // All the containers
// VALIDATE: All known guests are present
const allVmids = [...discoveryData.vms, ...discoveryData.containers].map(g => g.vmid);
expect(allVmids).toContain(102); // windows11
expect(allVmids).toContain(106); // pulse (this app!)
expect(allVmids).toContain(200); // UnraidServer
// DETECT: If count was wrong, this would help debug
if (totalGuests !== 18) {
console.error(`DISCREPANCY: Expected 18 guests, found ${totalGuests}`);
console.error('Missing guests:', [100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].filter(id => !allVmids.includes(id)));
console.error('Extra guests:', allVmids.filter(id => ![100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].includes(id)));
}
});
});
describe('Scenario 2: Admin Investigates "VM 102 Backup Issue"', () => {
test('should detect that VM 102 backup is dangerously old', async () => {
// REAL SCENARIO: VM 102 should be in backup job but backup is 3 days old
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// Find VM 102 backup
const vm102Backups = pbsData[0].datastores[0].snapshots.filter(
snap => snap['backup-id'] === '102' && snap['backup-type'] === 'vm'
);
expect(vm102Backups).toHaveLength(1);
const vm102LastBackup = vm102Backups[0];
const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time'];
const ageInHours = backupAge / 3600;
// VALIDATE: This should detect the problem
expect(ageInHours).toBeGreaterThan(48); // More than 2 days old!
// ALERT: This should trigger a critical alert
if (ageInHours > 24) {
console.warn(`CRITICAL: VM 102 backup is ${Math.round(ageInHours)} hours old!`);
}
// COMPARE: Other VMs should have recent backups
const vm200Backups = pbsData[0].datastores[0].snapshots.filter(
snap => snap['backup-id'] === '200' && snap['backup-type'] === 'vm'
);
const vm200Age = (Date.now() / 1000) - vm200Backups[0]['backup-time'];
expect(vm200Age / 3600).toBeLessThan(24); // Should be recent
});
test('should identify backup job configuration issue', async () => {
// REAL SCENARIO: VM 102 might be excluded from backup jobs or job failed
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// Analyze backup patterns to detect issues
const backupsByGuest = {};
pbsData[0].datastores[0].snapshots.forEach(snap => {
const guestId = snap['backup-id'];
if (!backupsByGuest[guestId]) {
backupsByGuest[guestId] = [];
}
backupsByGuest[guestId].push(snap);
});
// Check backup frequency patterns
const recentBackups = Object.keys(backupsByGuest).filter(guestId => {
const latestBackup = backupsByGuest[guestId][0];
const ageHours = (Date.now() / 1000 - latestBackup['backup-time']) / 3600;
return ageHours < 24;
});
// VALIDATE: Most guests should have recent backups
expect(recentBackups.length).toBeGreaterThan(5);
// DETECT: VM 102 should be flagged as problematic
expect(recentBackups).not.toContain('102');
// IDENTIFY: Pattern analysis
const guestsWithoutRecentBackups = Object.keys(backupsByGuest).filter(id => !recentBackups.includes(id));
if (guestsWithoutRecentBackups.length > 0) {
console.warn(`Guests with old backups: ${guestsWithoutRecentBackups.join(', ')}`);
}
});
});
describe('Scenario 3: Admin Responds to "High CPU Alert Storm"', () => {
test('should detect which VMs are actually problematic vs false alarms', async () => {
// REAL SCENARIO: Multiple CPU alerts, admin needs to prioritize
const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics);
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const runningGuests = [
...discoveryData.vms.filter(vm => vm.status === 'running'),
...discoveryData.containers.filter(ct => ct.status === 'running')
];
const metricsData = await fetchMetricsData(
discoveryData.vms.filter(vm => vm.status === 'running'),
discoveryData.containers.filter(ct => ct.status === 'running'),
mockApiClients
);
// ANALYZE: Which guests actually have high CPU
const highCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.8);
const moderateCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.5 && metrics.current.cpu <= 0.8);
// VALIDATE: Should detect VM 400 as high CPU (89%)
expect(highCpuGuests).toHaveLength(1);
expect(highCpuGuests[0].id).toBe(400);
expect(highCpuGuests[0].current.cpu).toBeCloseTo(0.89, 2);
// PRIORITIZE: Admin can focus on real issues
console.log(`HIGH PRIORITY: ${highCpuGuests.length} guests with CPU >80%`);
console.log(`MEDIUM PRIORITY: ${moderateCpuGuests.length} guests with CPU 50-80%`);
highCpuGuests.forEach(guest => {
const guestInfo = runningGuests.find(g => g.vmid === guest.id);
console.log(` - ${guestInfo.name} (${guestInfo.type} ${guest.id}): ${Math.round(guest.current.cpu * 100)}% CPU`);
});
});
test('should validate alert suppression during maintenance', async () => {
// REAL SCENARIO: Admin puts VM 400 in maintenance, alerts should stop
// Set custom thresholds to ensure alerts would normally fire
await customThresholds.setThresholds('primary', 'desktop', '400', {
cpu: { warning: 70, critical: 85 }
});
const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics);
const metricsData = await fetchMetricsData([], [
{ vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' }
], mockApiClients);
// Process alerts normally - should fire
const triggeredAlerts = alertManager.processMetrics(metricsData);
expect(triggeredAlerts.length).toBeGreaterThan(0);
// Suppress alerts for maintenance
alertManager.suppressAlert('cpu_high', { vmid: 400 }, 3600000, 'Maintenance window');
// Process again - should be suppressed
const suppressedAlerts = alertManager.processMetrics(metricsData);
const vm400Alerts = suppressedAlerts.filter(alert => alert.guest.vmid === '400');
expect(vm400Alerts).toHaveLength(0);
});
});
describe('Scenario 4: Admin Validates "Backup Job Health"', () => {
test('should validate backup job scheduling is working correctly', async () => {
// REAL SCENARIO: Admin checks if backup jobs ran on schedule
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// Group backups by time to detect job patterns
const backupTimes = {};
pbsData[0].datastores[0].snapshots.forEach(snap => {
const backupHour = new Date(snap['backup-time'] * 1000).getHours();
if (!backupTimes[backupHour]) {
backupTimes[backupHour] = [];
}
backupTimes[backupHour].push(snap);
});
// VALIDATE: Should see backups at 2 AM and 4 AM (your backup schedule)
expect(backupTimes[2]).toBeDefined(); // Primary job at 2 AM
expect(backupTimes[4]).toBeDefined(); // Secondary job at 4 AM
// VALIDATE: 2 AM job should have most containers
const twoAMBackups = backupTimes[2] || [];
const fourAMBackups = backupTimes[4] || [];
expect(twoAMBackups.length).toBeGreaterThan(fourAMBackups.length);
// VALIDATE: Specific VMs should be in correct jobs
const twoAMVmids = twoAMBackups.map(b => b['backup-id']);
const fourAMVmids = fourAMBackups.map(b => b['backup-id']);
// Based on your ground truth: VMs 200, 400 in secondary job (4 AM)
expect(fourAMVmids).toContain('200');
expect(fourAMVmids).toContain('400');
// Most containers in primary job (2 AM) - excluding VMs 102, 200, 400
expect(twoAMVmids).toContain('100'); // pbs container
expect(twoAMVmids).toContain('106'); // pulse container
console.log(`Primary job (2 AM): ${twoAMBackups.length} backups`);
console.log(`Secondary job (4 AM): ${fourAMBackups.length} backups`);
});
});
describe('Scenario 5: Performance Under Load', () => {
test('should handle realistic cluster size without performance degradation', async () => {
// REAL SCENARIO: System should stay responsive with full cluster
const startTime = Date.now();
const startMemory = process.memoryUsage().heapUsed;
// Create full realistic cluster
const mockApiClients = createLargeRealisticCluster();
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const discoveryTime = Date.now() - startTime;
// VALIDATE: Performance should be acceptable
expect(discoveryTime).toBeLessThan(10000); // 10 seconds max for discovery
expect(discoveryData.nodes.length).toBeGreaterThan(2);
expect(discoveryData.vms.length + discoveryData.containers.length).toBeGreaterThan(15);
// VALIDATE: Memory usage should be reasonable
const endMemory = process.memoryUsage().heapUsed;
const memoryIncrease = endMemory - startMemory;
expect(memoryIncrease).toBeLessThan(100 * 1024 * 1024); // Less than 100MB increase
console.log(`Discovery took ${discoveryTime}ms for ${discoveryData.vms.length + discoveryData.containers.length} guests`);
console.log(`Memory increase: ${Math.round(memoryIncrease / 1024 / 1024)}MB`);
});
});
describe('Scenario 6: Admin Debugs "Slow Dashboard Loading"', () => {
test('should identify performance bottlenecks in data fetching', async () => {
// REAL SCENARIO: Dashboard taking 30+ seconds to load, admin needs to find why
const mockApiClients = createRealisticMockClients(realApiData.pveCluster);
const performanceMetrics = {
discoveryStart: Date.now(),
nodeCallTimes: [],
totalApiCalls: 0
};
// Monitor API call performance
const originalGet = mockApiClients.primary.client.get;
mockApiClients.primary.client.get = jest.fn().mockImplementation(async (path) => {
const callStart = Date.now();
performanceMetrics.totalApiCalls++;
// Simulate realistic response times for different endpoints
let delay = 100; // Default delay
if (path.includes('/qemu') || path.includes('/lxc')) {
delay = 500; // Guest endpoints are slower
}
if (path.includes('node3')) {
delay = 2000; // One node is slow (network issue)
}
await new Promise(resolve => setTimeout(resolve, delay));
const result = await originalGet.call(this, path);
const callTime = Date.now() - callStart;
performanceMetrics.nodeCallTimes.push({ path, time: callTime });
return result;
});
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const totalTime = Date.now() - performanceMetrics.discoveryStart;
// ANALYZE: Performance bottlenecks
const slowCalls = performanceMetrics.nodeCallTimes.filter(call => call.time > 1000);
const avgCallTime = performanceMetrics.nodeCallTimes.reduce((sum, call) => sum + call.time, 0) / performanceMetrics.nodeCallTimes.length;
// VALIDATE: Should identify the slow node
expect(slowCalls.length).toBeGreaterThan(0);
expect(slowCalls.some(call => call.path.includes('node3'))).toBe(true);
// DETECT: Performance recommendations
if (avgCallTime > 500) {
console.log(`PERFORMANCE ISSUE: Average API call time ${Math.round(avgCallTime)}ms`);
}
if (totalTime > 5000) {
console.log(`PERFORMANCE ISSUE: Total discovery time ${totalTime}ms`);
}
console.log(`Performance analysis: ${performanceMetrics.totalApiCalls} API calls, ${slowCalls.length} slow calls`);
slowCalls.forEach(call => {
console.log(` SLOW: ${call.path} took ${call.time}ms`);
});
});
});
describe('Scenario 7: Admin Investigates "Missing Backup Alerts"', () => {
test('should detect when backup monitoring is not working correctly', async () => {
// REAL SCENARIO: VM 102 hasn't been backed up in 3 days but no alerts fired
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// ANALYZE: Backup monitoring effectiveness
const allBackups = pbsData[0].datastores[0].snapshots;
const vm102Backups = allBackups.filter(snap =>
snap['backup-id'] === '102' && snap['backup-type'] === 'vm'
);
expect(vm102Backups).toHaveLength(1);
const vm102LastBackup = vm102Backups[0];
const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time'];
const ageInDays = backupAge / (24 * 3600);
// VALIDATE: Should detect old backup
expect(ageInDays).toBeGreaterThan(2); // More than 2 days old
// SIMULATE: Alert system check
const mockAlertThreshold = 24 * 3600; // 24 hours
const shouldHaveAlerted = backupAge > mockAlertThreshold;
// DETECT: Alert system gap
if (shouldHaveAlerted) {
console.log(`MONITORING GAP: VM 102 backup is ${Math.round(ageInDays * 10) / 10} days old, should have triggered alert`);
console.log(`Backup age: ${Math.round(backupAge / 3600)} hours (threshold: ${mockAlertThreshold / 3600} hours)`);
}
// VALIDATE: This test helps identify why backup alerts aren't working
expect(shouldHaveAlerted).toBe(true);
// RECOMMEND: Compare with other VMs to see pattern
const recentBackups = allBackups.filter(snap => {
const snapAge = (Date.now() / 1000) - snap['backup-time'];
return snapAge < (24 * 3600); // Less than 24 hours old
});
console.log(`Found ${recentBackups.length} recent backups vs ${allBackups.length} total`);
});
});
describe('Scenario 8: Data Integrity Validation', () => {
test('should validate that all running VMs have corresponding metrics', async () => {
// REAL SCENARIO: Admin notices some VMs missing from metrics dashboard
const mockApiClients = createRealisticMockClients(realApiData.pveCluster);
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const runningGuests = [
...discoveryData.vms.filter(vm => vm.status === 'running'),
...discoveryData.containers.filter(ct => ct.status === 'running')
];
// Mock metrics that might miss some guests
const mockMetricsApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics);
const metricsData = await fetchMetricsData(
discoveryData.vms.filter(vm => vm.status === 'running'),
discoveryData.containers.filter(ct => ct.status === 'running'),
mockMetricsApiClients
);
// DATA INTEGRITY CHECK: Every running guest should have metrics
const runningGuestIds = runningGuests.map(g => g.vmid);
const metricsGuestIds = metricsData.map(m => m.id);
const missingMetrics = runningGuestIds.filter(id => !metricsGuestIds.includes(id));
const extraMetrics = metricsGuestIds.filter(id => !runningGuestIds.includes(id));
// VALIDATE: Data consistency
expect(missingMetrics).toHaveLength(0); // No running guests should be missing metrics
expect(extraMetrics).toHaveLength(0); // No metrics for non-existent guests
if (missingMetrics.length > 0) {
console.error(`DATA INTEGRITY ISSUE: ${missingMetrics.length} running guests missing metrics:`, missingMetrics);
}
if (extraMetrics.length > 0) {
console.error(`DATA INTEGRITY ISSUE: ${extraMetrics.length} metrics for non-running guests:`, extraMetrics);
}
// VALIDATE: Metrics data quality
metricsData.forEach(metrics => {
expect(metrics.current).toBeDefined();
expect(typeof metrics.current.cpu).toBe('number');
expect(metrics.current.cpu).toBeGreaterThanOrEqual(0);
expect(metrics.current.cpu).toBeLessThanOrEqual(1); // Assuming decimal format
});
console.log(`Data integrity check: ${runningGuests.length} running guests, ${metricsData.length} metrics records`);
});
});
describe('Scenario 9: Admin Responds to "Disk Space Critical" Alert', () => {
test('should help admin prioritize disk cleanup actions', async () => {
// REAL SCENARIO: Multiple disk space alerts, admin needs to know where to focus cleanup
// Mock guests with varying disk usage
const diskPressureGuests = {
106: { cpu: 0.12, memory: 536870912, disk: 0.92 }, // Pulse - 92% full
200: { cpu: 0.15, memory: 2147483648, disk: 0.88 }, // UnraidServer - 88% full
107: { cpu: 0.08, memory: 268435456, disk: 0.95 }, // Jellyfin - 95% full (critical!)
108: { cpu: 0.22, memory: 1073741824, disk: 0.85 } // Frigate - 85% full
};
const mockApiClients = createRealisticMockClientsWithMetrics(diskPressureGuests);
const metricsData = await fetchMetricsData([], [
{ vmid: 106, name: 'pulse', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' },
{ vmid: 200, name: 'UnraidServer', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' },
{ vmid: 107, name: 'jellyfin', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' },
{ vmid: 108, name: 'frigate', status: 'running', endpointId: 'primary', node: 'delly', type: 'lxc' }
], mockApiClients);
// ANALYZE: Disk usage patterns
const diskMetrics = metricsData.map(m => ({
id: m.id,
name: m.guestName,
diskUsage: m.current.disk * 100,
type: m.type
})).sort((a, b) => b.diskUsage - a.diskUsage);
// PRIORITIZE: Critical vs warning levels
const criticalDisk = diskMetrics.filter(g => g.diskUsage > 90); // >90%
const warningDisk = diskMetrics.filter(g => g.diskUsage > 85 && g.diskUsage <= 90); // 85-90%
// VALIDATE: Should identify jellyfin as highest priority
expect(criticalDisk).toHaveLength(2); // Jellyfin (95%) and Pulse (92%)
expect(criticalDisk[0].name).toBe('jellyfin');
expect(criticalDisk[0].diskUsage).toBe(95);
// RECOMMEND: Actions based on service type
const mediaServices = criticalDisk.filter(g =>
['jellyfin', 'plex', 'frigate'].includes(g.name.toLowerCase())
);
const systemServices = criticalDisk.filter(g =>
['pulse', 'pihole', 'homeassistant'].includes(g.name.toLowerCase())
);
console.log('DISK CLEANUP PRIORITIES:');
console.log(`CRITICAL (>90%): ${criticalDisk.length} services`);
criticalDisk.forEach(g => {
console.log(` - ${g.name}: ${g.diskUsage}% full`);
});
console.log(`WARNING (85-90%): ${warningDisk.length} services`);
// GUIDANCE: Specific cleanup recommendations
if (mediaServices.length > 0) {
console.log('RECOMMENDATION: Check media files for cleanup (jellyfin, frigate)');
}
if (systemServices.length > 0) {
console.log('RECOMMENDATION: Check logs and temporary files (pulse, system services)');
}
expect(criticalDisk.length).toBeGreaterThan(0);
});
});
});
// Helper functions for realistic test data
function getTwoAMToday() {
const now = new Date();
const twoAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0);
if (twoAM > now) {
twoAM.setDate(twoAM.getDate() - 1); // Yesterday's 2 AM
}
return Math.floor(twoAM.getTime() / 1000);
}
function getFourAMToday() {
const now = new Date();
const fourAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 4, 0, 0);
if (fourAM > now) {
fourAM.setDate(fourAM.getDate() - 1); // Yesterday's 4 AM
}
return Math.floor(fourAM.getTime() / 1000);
}
function getThreeDaysAgo() {
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
threeDaysAgo.setHours(2, 0, 0, 0); // 2 AM three days ago
return Math.floor(threeDaysAgo.getTime() / 1000);
}
function createRealisticMockClients(pveCluster) {
return {
'primary': {
client: {
get: jest.fn().mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: pveCluster.nodes } });
}
if (path.includes('/qemu')) {
const node = path.split('/')[2];
const nodeVms = pveCluster.vms.filter(vm => vm.node === node);
return Promise.resolve({ data: { data: nodeVms } });
}
if (path.includes('/lxc')) {
const node = path.split('/')[2];
const nodeContainers = pveCluster.containers.filter(ct => ct.node === node);
return Promise.resolve({ data: { data: nodeContainers } });
}
return Promise.resolve({ data: { data: [] } });
})
},
config: { id: 'primary', name: 'Test Cluster' }
}
};
}
function createRealisticPbsClients(pbsBackups) {
return {
'pbs-main': {
client: {
get: jest.fn().mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } });
}
if (path === '/config/datastore') {
return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } });
}
if (path.includes('/admin/datastore/main-datastore/snapshots')) {
return Promise.resolve({ data: { data: pbsBackups.datastores[0].snapshots } });
}
if (path.includes('/status/datastore-usage')) {
return Promise.resolve({ data: { data: [{ store: 'main-datastore', total: 1000000000, used: 500000000 }] } });
}
return Promise.resolve({ data: { data: [] } });
})
},
config: { id: 'pbs-main', name: 'Test PBS' }
}
};
}
function createRealisticMockClientsWithMetrics(currentMetrics) {
return {
'primary': {
client: {
get: jest.fn().mockImplementation((path) => {
if (path.includes('/status')) {
const vmidMatch = path.match(/\/(qemu|lxc)\/(\d+)\/status/);
if (vmidMatch) {
const vmid = parseInt(vmidMatch[2]);
const metrics = currentMetrics[vmid];
if (metrics) {
return Promise.resolve({ data: { data: metrics } });
}
}
return Promise.resolve({ data: { data: { cpu: 0.1, memory: 1073741824, disk: 5368709120 } } });
}
if (path.includes('/rrddata')) {
return Promise.resolve({ data: { data: [{ time: Date.now() / 1000, cpu: 0.1 }] } });
}
return Promise.resolve({ data: { data: [] } });
})
},
config: { id: 'primary', name: 'Test Cluster' }
}
};
}
function createLargeRealisticCluster() {
// Create a larger but still realistic cluster
const nodes = ['desktop', 'delly', 'minipc', 'server1', 'server2'];
const largeCluster = {
nodes: nodes.map(name => ({ node: name, status: 'online', uptime: 86400 })),
vms: [],
containers: []
};
// Add realistic VMs and containers distributed across nodes
let vmid = 100;
nodes.forEach((node, nodeIndex) => {
// Add some VMs per node
for (let i = 0; i < 3; i++) {
largeCluster.vms.push({
vmid: vmid++,
name: `vm-${node}-${i}`,
status: Math.random() > 0.1 ? 'running' : 'stopped',
node: node
});
}
// Add some containers per node
for (let i = 0; i < 8; i++) {
largeCluster.containers.push({
vmid: vmid++,
name: `ct-${node}-${i}`,
status: Math.random() > 0.05 ? 'running' : 'stopped',
node: node
});
}
});
return createRealisticMockClients(largeCluster);
}
+42 -7
View File
@@ -5,6 +5,7 @@ PulseApp.ui.pbs = (() => {
// Global state tracker for expanded PBS tasks
let expandedTaskState = new Set();
let expandedShowMoreState = new Set();
let expandedMobileShowMoreState = new Set(); // Track mobile show more state
let selectedPbsTabIndex = 0; // Track selected PBS tab index globally
const CSS_CLASSES = {
@@ -1698,7 +1699,9 @@ PulseApp.ui.pbs = (() => {
heading.textContent = statusText;
taskSection.appendChild(heading);
const taskContainer = _createMobileTaskContainer(recentTasks);
// Create unique key for this task section
const sectionKey = `${instanceId}-${taskType.type}`;
const taskContainer = _createMobileTaskContainer(recentTasks, sectionKey);
taskSection.appendChild(taskContainer);
section.appendChild(taskSection);
@@ -1708,7 +1711,7 @@ PulseApp.ui.pbs = (() => {
return section;
};
const _createMobileTaskContainer = (tasks) => {
const _createMobileTaskContainer = (tasks, sectionKey) => {
const container = document.createElement('div');
container.className = 'mobile-task-container space-y-2';
@@ -1717,26 +1720,58 @@ PulseApp.ui.pbs = (() => {
const otherTasks = tasks.filter(task => !task.status || task.status === 'OK' || task.status.toLowerCase().includes('running'));
const prioritizedTasks = [...failedTasks, ...otherTasks];
// Limit to 5 tasks on mobile for better performance
const displayTasks = prioritizedTasks.slice(0, 5);
// Check if this section has been expanded
const isExpanded = expandedMobileShowMoreState.has(sectionKey);
// Show all tasks if expanded, otherwise limit to 5
const displayTasks = isExpanded ? prioritizedTasks : prioritizedTasks.slice(0, 5);
displayTasks.forEach(task => {
const taskCard = _createMobileTaskCard(task);
container.appendChild(taskCard);
});
if (prioritizedTasks.length > 5) {
// Show button if there are more than 5 tasks and not expanded
if (prioritizedTasks.length > 5 && !isExpanded) {
const moreButton = document.createElement('button');
moreButton.className = 'w-full py-2 px-3 text-xs text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-600 rounded bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors';
moreButton.textContent = `Show ${prioritizedTasks.length - 5} More Tasks`;
moreButton.addEventListener('click', () => {
moreButton.addEventListener('click', (event) => {
// Prevent any event bubbling that might cause issues
event.stopPropagation();
event.preventDefault();
// Mark this section as expanded
expandedMobileShowMoreState.add(sectionKey);
// Add remaining tasks
const remainingTasks = prioritizedTasks.slice(5);
remainingTasks.forEach(task => {
const taskCard = _createMobileTaskCard(task);
container.insertBefore(taskCard, moreButton);
});
moreButton.remove();
// Replace button with "Show Less" button
const showLessButton = document.createElement('button');
showLessButton.className = 'w-full py-2 px-3 text-xs text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-900/20 hover:bg-gray-100 dark:hover:bg-gray-900/30 transition-colors';
showLessButton.textContent = 'Show Less Tasks';
showLessButton.addEventListener('click', (event) => {
event.stopPropagation();
event.preventDefault();
// Remove expanded state
expandedMobileShowMoreState.delete(sectionKey);
// Trigger a refresh to show collapsed state
// This will cause the function to be called again with the collapsed state
if (typeof updatePbsInfo === 'function') {
updatePbsInfo();
}
});
moreButton.replaceWith(showLessButton);
});
container.appendChild(moreButton);
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env node
/**
* Test script for the resilient DNS resolver
* Usage: node test-dns-resolver.js <hostname>
*/
const dnsResolver = require('../server/dnsResolver');
async function testDnsResolution(hostname) {
console.log(`\n=== Testing DNS Resolution for: ${hostname} ===\n`);
try {
// Test basic resolution
console.log('1. Testing basic DNS resolution...');
const addresses = await dnsResolver.resolveHostname(hostname);
console.log(` ✓ Resolved to ${addresses.length} addresses:`);
addresses.forEach((addr, idx) => {
console.log(` ${idx + 1}. ${addr}`);
});
// Test cache
console.log('\n2. Testing cached resolution...');
const cachedAddresses = await dnsResolver.resolveHostname(hostname);
console.log(` ✓ Got ${cachedAddresses.length} addresses from cache`);
// Test marking IPs as failed
if (addresses.length > 1) {
console.log('\n3. Testing failed IP handling...');
const firstIp = addresses[0];
dnsResolver.markHostFailed(firstIp);
console.log(` - Marked ${firstIp} as failed`);
const filteredAddresses = await dnsResolver.resolveHostname(hostname);
console.log(` ✓ After filtering: ${filteredAddresses.length} working addresses`);
// Wait for retry delay
console.log('\n4. Testing retry delay...');
console.log(` - Waiting for failed IP to be retryable...`);
const isStillFailed = dnsResolver.isHostFailed(firstIp);
console.log(` - IP ${firstIp} is ${isStillFailed ? 'still marked as failed' : 'available again'}`);
}
// Test hostname extraction
console.log('\n5. Testing hostname extraction...');
const testUrls = [
`https://${hostname}:8006`,
`${hostname}:8006`,
`https://${hostname}/api2/json`,
hostname
];
testUrls.forEach(url => {
const extracted = dnsResolver.extractHostname(url);
console.log(` - "${url}" -> "${extracted}"`);
});
// Test canResolve
console.log('\n6. Testing canResolve...');
const canResolve = await dnsResolver.canResolve(hostname);
console.log(` ✓ Can resolve ${hostname}: ${canResolve}`);
console.log('\n=== Test completed successfully ===\n');
} catch (error) {
console.error(`\n✗ DNS resolution failed: ${error.message}\n`);
process.exit(1);
}
}
// Main execution
const hostname = process.argv[2];
if (!hostname) {
console.error('Usage: node test-dns-resolver.js <hostname>');
console.error('Example: node test-dns-resolver.js proxmox.lan');
process.exit(1);
}
testDnsResolution(hostname).catch(error => {
console.error('Unexpected error:', error);
process.exit(1);
});
-1
View File
@@ -1 +0,0 @@
# Test PR merge
-173
View File
@@ -1,173 +0,0 @@
# Pulse Test Suite
This directory contains comprehensive tests for the Pulse monitoring application. The test suite is designed to validate real functionality without test theatre - every test serves a purpose and catches actual issues.
## Test Philosophy
**Meaningful Testing**: Tests validate actual business logic and catch real bugs
**Realistic Scenarios**: Error cases simulate actual network failures and edge conditions
**Integration Testing**: End-to-end validation of data flows
**Ground Truth Validation**: Tests against known good data to ensure accuracy
**No Test Theatre**: We avoid superficial tests that only verify mocks
## Test Structure
### Core Module Tests
#### `apiClients.test.js` (100% Coverage ✅)
- **Authentication**: Token-based auth for PVE and PBS
- **Retry Logic**: Network failure handling with exponential backoff
- **SSL Configuration**: Self-signed certificate handling
- **Error Scenarios**: Missing credentials, network timeouts, HTTP errors
- **Multiple Endpoints**: Cross-cluster API management
#### `dataFetcher.test.js` (66% Coverage)
- **Discovery Data**: VM/Container enumeration across nodes
- **Metrics Collection**: RRD data and current status fetching
- **PBS Integration**: Backup data aggregation and task processing
- **Error Handling**: API failures, malformed responses, missing data
- **QEMU Guest Agent**: Memory statistics collection
#### `pbsUtils.test.js` (100% Coverage ✅)
- **Task Categorization**: Backup, verification, sync, and prune tasks
- **Summary Statistics**: Success/failure rates and timing analysis
- **Recent Task Filtering**: 30-day window with proper sorting
- **Duration Calculation**: Handling missing timestamps gracefully
#### `configLoader.test.js` (99% Coverage ✅)
- **Environment Variables**: Multi-endpoint configuration parsing
- **Placeholder Detection**: Setup mode vs production configuration
- **Validation Logic**: Required field checking and error handling
- **PBS Configuration**: Token and password authentication modes
### Enhanced Coverage Tests
#### `alertManager.test.js` (Enhanced)
**Original Coverage**: 35% → **New Coverage**: ~60%
Added comprehensive tests for:
- **Webhook Functionality**: Slack/Discord payload formatting
- **Alert Management**: Rule registration, acknowledgments, resolution
- **Notification Channels**: Custom webhooks, email, disabled channels
- **Alert Escalation**: Time-based severity escalation
- **Alert Suppression**: Maintenance window handling
- **Metrics & Analytics**: Statistics calculation and tracking
#### `customThresholds.test.js` (New)
**Coverage**: ~85%
Comprehensive test coverage for:
- **Threshold Management**: Per-VM/LXC custom thresholds
- **Configuration Persistence**: File-based storage operations
- **Validation Logic**: Threshold range and consistency checks
- **Bulk Operations**: Import/export and endpoint-wide operations
- **Error Handling**: File system errors and malformed data
- **Cache Management**: High-performance threshold lookups
### Specialized Tests
#### `backupGroundTruth.test.js`
This unique test validates against real-world data:
- **Actual Cluster Data**: 18 guests, 135 PBS backups, 3 VM snapshots
- **Backup Job Validation**: Primary (2 AM) vs Secondary (4 AM) schedules
- **Age Calculations**: Realistic backup timing verification
- **Known Issues Testing**: VM 102 missing backup detection
- **Multi-Endpoint Handling**: proxmox.lan vs pimox.lan clusters
## Running Tests
```bash
# Run all tests with coverage
npm test
# Run specific test file
npm test -- server/tests/apiClients.test.js
# Run tests in watch mode
npm test -- --watch
# Run with verbose output
npm test -- --verbose
```
## Test Configuration
### Jest Setup
- **Environment**: Node.js test environment
- **Module Transformation**: ES modules support with experimental VM modules
- **Coverage Provider**: V8 for accurate coverage reporting
- **Timeout**: 120 seconds for long-running integration tests
### Mocking Strategy
- **Selective Mocking**: Only mock external dependencies (axios, filesystem)
- **Realistic Data**: Mock responses based on actual API responses
- **Error Simulation**: Network failures, timeouts, malformed responses
- **State Management**: Proper setup/teardown for test isolation
## Coverage Goals
| Module | Current | Target | Status |
|--------|---------|--------|--------|
| apiClients.js | 100% | 100% | ✅ Complete |
| pbsUtils.js | 100% | 100% | ✅ Complete |
| configLoader.js | 99% | 99% | ✅ Complete |
| dataFetcher.js | 66% | 70% | 🟡 Good |
| alertManager.js | 35%→60% | 70% | 🟡 Improved |
| customThresholds.js | 34%→85% | 80% | ✅ Complete |
## Key Testing Principles
### 1. Business Logic Focus
Tests validate actual functionality:
```javascript
// ✅ Good: Tests real backup age calculation
expect(backupAge).toBeCloseTo(11, 0); // 11 hours old
// ❌ Avoid: Only testing mocks
expect(mockFunction).toHaveBeenCalled();
```
### 2. Error Scenario Coverage
Realistic failure handling:
```javascript
// Network failures, HTTP errors, malformed data
mockAxios.post.mockRejectedValue(new Error('Network timeout'));
```
### 3. Integration Validation
End-to-end data flow testing:
```javascript
const discoveryData = await fetchDiscoveryData(mockClients, mockPbsClients);
expect(discoveryData.nodes.length).toBe(expectedNodeCount);
```
### 4. Ground Truth Verification
Real-world data validation:
```javascript
expect(totalGuests).toBe(18); // Actual cluster count
expect(pbsBackups).toBe(135); // Real backup count
```
## Adding New Tests
When adding new tests, ensure they:
1. **Test Real Functionality**: Validate actual business logic
2. **Handle Edge Cases**: Network failures, missing data, malformed input
3. **Use Realistic Data**: Base mocks on actual API responses
4. **Include Error Scenarios**: Test failure modes and recovery
5. **Validate Integration**: Test component interactions
6. **Document Purpose**: Clear test descriptions and comments
## Test Maintenance
- **Update with API Changes**: Keep mocks synchronized with real APIs
- **Monitor Coverage**: Maintain high coverage for critical paths
- **Review Failures**: Investigate and fix flaky tests immediately
- **Performance Testing**: Monitor test execution time
- **Regular Cleanup**: Remove obsolete tests and update documentation
---
This test suite provides confidence in Pulse's reliability and helps catch issues before they reach production. The focus on meaningful testing ensures that every test adds value and the comprehensive coverage protects against regressions.
-528
View File
@@ -1,528 +0,0 @@
/**
* AlertManager Webhook Tests
* Tests webhook functionality and timestamp handling after the Teams webhook fix
*/
const AlertManager = require('../alertManager');
const axios = require('axios');
// Mock axios for webhook testing
jest.mock('axios');
const mockAxios = axios;
describe('AlertManager Webhook Functionality', () => {
let alertManager;
let mockWebhookChannel;
let mockAlert;
beforeEach(() => {
alertManager = new AlertManager();
// Mock webhook channel configuration
mockWebhookChannel = {
id: 'test-webhook',
name: 'Test Webhook',
type: 'webhook',
enabled: true,
config: {
url: 'https://hooks.slack.com/test-webhook',
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
};
// Mock alert object with various timestamp scenarios
mockAlert = {
id: 'test-alert-123',
rule: {
name: 'High CPU Usage',
description: 'CPU usage is too high',
severity: 'warning',
metric: 'cpu'
},
guest: {
name: 'test-vm',
vmid: '100',
type: 'qemu',
node: 'test-node',
status: 'running'
},
currentValue: 92,
effectiveThreshold: 85,
triggeredAt: 1640995200000, // Valid timestamp
lastUpdate: 1640995260000 // Valid timestamp
};
// Reset axios mock
mockAxios.post.mockClear();
});
afterEach(() => {
if (alertManager) {
alertManager.destroy();
}
});
describe('Webhook Timestamp Handling', () => {
test('should use triggeredAt timestamp when available', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// For Slack webhooks, check the timestamp in attachments
expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.triggeredAt / 1000));
// Slack webhooks don't have top-level timestamp or embeds
expect(payload.timestamp).toBeUndefined();
expect(payload.embeds).toBeUndefined();
});
test('should fallback to lastUpdate when triggeredAt is missing', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Remove triggeredAt from alert
const alertWithoutTriggeredAt = { ...mockAlert };
delete alertWithoutTriggeredAt.triggeredAt;
await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTriggeredAt);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Should use lastUpdate timestamp in Slack format
expect(payload.attachments[0].ts).toBe(Math.floor(mockAlert.lastUpdate / 1000));
});
test('should fallback to current time when both timestamps are missing', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Remove both timestamps from alert
const alertWithoutTimestamps = { ...mockAlert };
delete alertWithoutTimestamps.triggeredAt;
delete alertWithoutTimestamps.lastUpdate;
const beforeTime = Date.now();
await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithoutTimestamps);
const afterTime = Date.now();
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Should use current time (within reasonable range) for Slack format
// Note: Unix timestamps lose millisecond precision, so allow for some tolerance
const timestamp = payload.attachments[0].ts * 1000; // Convert Unix timestamp back to milliseconds
expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000);
expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000);
});
test('should handle invalid timestamp values gracefully', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Set invalid timestamps
const alertWithInvalidTimestamps = {
...mockAlert,
triggeredAt: 'invalid-timestamp',
lastUpdate: null
};
const beforeTime = Date.now();
await alertManager.sendWebhookNotification(mockWebhookChannel, alertWithInvalidTimestamps);
const afterTime = Date.now();
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Should fallback to current time when timestamps are invalid (Slack format)
// Note: Unix timestamps lose millisecond precision, so allow for some tolerance
const timestamp = payload.attachments[0].ts * 1000;
expect(timestamp).toBeGreaterThanOrEqual(Math.floor(beforeTime / 1000) * 1000);
expect(timestamp).toBeLessThanOrEqual(Math.ceil(afterTime / 1000) * 1000);
});
});
describe('Webhook Payload Structure', () => {
test('should generate valid Discord/Slack payload structure', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
expect(mockAxios.post).toHaveBeenCalledTimes(1);
const payload = mockAxios.post.mock.calls[0][1];
// Check Slack webhook structure (based on URL)
expect(payload).toHaveProperty('text');
expect(payload).toHaveProperty('attachments');
// Slack webhooks don't have these properties
expect(payload).not.toHaveProperty('timestamp');
expect(payload).not.toHaveProperty('alert');
expect(payload).not.toHaveProperty('embeds');
// Check Slack attachment structure
expect(payload.attachments).toHaveLength(1);
expect(payload.attachments[0]).toHaveProperty('fields');
expect(payload.attachments[0]).toHaveProperty('color');
expect(payload.attachments[0]).toHaveProperty('footer');
expect(payload.attachments[0]).toHaveProperty('ts');
});
test('should include all required alert fields in payload', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
const payload = mockAxios.post.mock.calls[0][1];
// Check Slack format fields (data is in text and attachments)
expect(payload.text).toContain(mockAlert.rule.name);
expect(payload.attachments[0].fields[0].value).toContain(mockAlert.guest.name);
expect(payload.attachments[0].fields[1].value).toBe(mockAlert.guest.node);
expect(payload.attachments[0].fields[2].value).toContain('92%'); // formatted value
expect(payload.attachments[0].fields[2].value).toContain('85%'); // formatted threshold
});
test('should set correct colors based on severity', async () => {
mockAxios.post.mockResolvedValue({ status: 200, data: { success: true } });
// Test warning severity (Slack format only has attachments)
await alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert);
let payload = mockAxios.post.mock.calls[0][1];
expect(payload.attachments[0].color).toBe('warning');
// Test critical severity
mockAxios.post.mockClear();
const criticalAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'critical' } };
await alertManager.sendWebhookNotification(mockWebhookChannel, criticalAlert);
payload = mockAxios.post.mock.calls[0][1];
expect(payload.attachments[0].color).toBe('danger');
// Test info severity
mockAxios.post.mockClear();
const infoAlert = { ...mockAlert, rule: { ...mockAlert.rule, severity: 'info' } };
await alertManager.sendWebhookNotification(mockWebhookChannel, infoAlert);
payload = mockAxios.post.mock.calls[0][1];
expect(payload.attachments[0].color).toBe('good');
});
});
describe('Webhook Error Handling', () => {
test('should throw error when webhook URL is not configured', async () => {
const channelWithoutUrl = { ...mockWebhookChannel };
delete channelWithoutUrl.config.url;
await expect(
alertManager.sendWebhookNotification(channelWithoutUrl, mockAlert)
).rejects.toThrow('Webhook URL not configured');
});
test('should handle HTTP errors gracefully', async () => {
mockAxios.post.mockRejectedValue({
response: { status: 404, statusText: 'Not Found' }
});
await expect(
alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert)
).rejects.toThrow('Webhook failed after 3 attempts: 404 Not Found');
});
test('should handle network errors gracefully', async () => {
mockAxios.post.mockRejectedValue({
request: {}
});
await expect(
alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert)
).rejects.toThrow(`Webhook failed after 3 attempts: No response from ${mockWebhookChannel.config.url}`);
});
test('should handle other errors gracefully', async () => {
const errorMessage = 'Connection timeout';
mockAxios.post.mockRejectedValue(new Error(errorMessage));
await expect(
alertManager.sendWebhookNotification(mockWebhookChannel, mockAlert)
).rejects.toThrow(`Webhook failed after 3 attempts: ${errorMessage}`);
});
});
describe('Email Notification Timestamp Fix', () => {
test('should use correct timestamp fields in email templates', () => {
// This test verifies that the email templates use the same timestamp fallback logic
const emailHtml = alertManager.generateEmailTemplate(mockAlert);
// The email should contain a formatted timestamp that doesn't throw errors
expect(emailHtml).toContain(new Date(mockAlert.triggeredAt).toLocaleString());
// Test with missing triggeredAt
const alertWithoutTriggeredAt = { ...mockAlert };
delete alertWithoutTriggeredAt.triggeredAt;
const emailHtmlFallback = alertManager.generateEmailTemplate(alertWithoutTriggeredAt);
expect(emailHtmlFallback).toContain(new Date(mockAlert.lastUpdate).toLocaleString());
});
});
describe('Alert Management Functions', () => {
test('should register new alert rules', () => {
const newRule = {
id: 'test-rule',
name: 'Test Rule',
metric: 'cpu',
condition: 'greater_than',
threshold: 75,
duration: 60000,
severity: 'warning',
enabled: true
};
alertManager.addRule(newRule);
expect(alertManager.alertRules.has('test-rule')).toBe(true);
expect(alertManager.alertRules.get('test-rule')).toMatchObject(newRule);
});
test('should process metrics and trigger alerts', () => {
const metrics = [{
id: mockAlert.guest.vmid,
endpointName: 'test-endpoint',
current: { cpu: 95 }, // Above critical threshold
guest: mockAlert.guest
}];
const triggeredAlerts = alertManager.processMetrics(metrics);
expect(Array.isArray(triggeredAlerts)).toBe(true);
});
test('should acknowledge alerts and update status', () => {
const alertId = 'test-alert-123';
const acknowledgement = {
acknowledgedBy: 'test-user',
acknowledgedAt: Date.now(),
reason: 'Planned maintenance'
};
alertManager.acknowledgeAlert(alertId, acknowledgement);
expect(alertManager.acknowledgedAlerts.has(alertId)).toBe(true);
expect(alertManager.acknowledgedAlerts.get(alertId)).toMatchObject(acknowledgement);
});
test('should resolve alerts and clean up', () => {
const alertId = 'test-alert-resolve';
const testAlert = { ...mockAlert, id: alertId };
alertManager.activeAlerts.set(alertId, testAlert);
alertManager.resolveAlert(alertId);
expect(alertManager.activeAlerts.has(alertId)).toBe(false);
expect(alertManager.alertHistory.some(a => a.id === alertId && a.resolved)).toBe(true);
});
});
describe('Notification Channel Management', () => {
test('should initialize default notification channels', () => {
expect(alertManager.notificationChannels.size).toBeGreaterThan(0);
expect(alertManager.notificationChannels.has('default')).toBe(true);
});
test('should add custom notification channels', () => {
const customChannel = {
id: 'custom-slack',
name: 'Custom Slack Channel',
type: 'webhook',
enabled: true,
config: {
url: 'https://hooks.slack.com/custom-webhook',
method: 'POST',
headers: { 'Content-Type': 'application/json' }
}
};
alertManager.addNotificationChannel(customChannel);
expect(alertManager.notificationChannels.has('custom-slack')).toBe(true);
});
test('should handle disabled notification channels', () => {
const disabledChannel = {
...mockWebhookChannel,
enabled: false
};
alertManager.addNotificationChannel(disabledChannel);
const result = alertManager.shouldSendNotification(disabledChannel.id, mockAlert);
expect(result).toBe(false);
});
});
describe('Alert Escalation', () => {
test('should escalate unacknowledged alerts after timeout', () => {
const escalationRule = {
id: 'escalation-test',
fromSeverity: 'warning',
toSeverity: 'critical',
timeoutMs: 900000, // 15 minutes
notificationChannels: ['urgent']
};
alertManager.addEscalationRule(escalationRule);
expect(alertManager.escalationRules.has('escalation-test')).toBe(true);
// Test escalation logic
const oldAlert = {
...mockAlert,
triggeredAt: Date.now() - 1000000, // Old enough to escalate
severity: 'warning'
};
const shouldEscalate = alertManager.shouldEscalateAlert(oldAlert);
expect(shouldEscalate).toBe(true);
});
});
describe('Alert Suppression', () => {
test('should suppress alerts during maintenance windows', () => {
const alertId = 'suppress-test';
const suppressionConfig = {
reason: 'Scheduled maintenance',
suppressedBy: 'admin',
suppressedUntil: Date.now() + 3600000 // 1 hour
};
alertManager.suppressAlert(alertId, suppressionConfig);
expect(alertManager.suppressedAlerts.has(alertId)).toBe(true);
const isSuppressed = alertManager.isAlertSuppressed(alertId);
expect(isSuppressed).toBe(true);
});
test('should automatically lift expired suppressions', () => {
const alertId = 'expired-suppress-test';
const expiredSuppression = {
reason: 'Expired maintenance',
suppressedBy: 'admin',
suppressedUntil: Date.now() - 1000 // Already expired
};
alertManager.suppressedAlerts.set(alertId, expiredSuppression);
const isSuppressed = alertManager.isAlertSuppressed(alertId);
expect(isSuppressed).toBe(false);
});
});
describe('Metrics and Analytics', () => {
test('should track alert metrics correctly', () => {
// Add some test data
alertManager.alertMetrics.totalFired = 10;
alertManager.alertMetrics.totalResolved = 8;
alertManager.alertMetrics.totalAcknowledged = 5;
alertManager.updateMetrics();
expect(alertManager.alertMetrics.totalFired).toBe(10);
expect(alertManager.alertMetrics.totalResolved).toBe(8);
expect(alertManager.alertMetrics.totalAcknowledged).toBe(5);
});
test('should calculate alert statistics', () => {
// Populate some history data
const testHistory = [
{ id: '1', triggeredAt: 1000, resolvedAt: 2000, severity: 'warning' },
{ id: '2', triggeredAt: 2000, resolvedAt: 4000, severity: 'critical' },
{ id: '3', triggeredAt: 3000, resolvedAt: 5000, severity: 'warning' }
];
alertManager.alertHistory = testHistory;
const stats = alertManager.getAlertStatistics();
expect(stats).toHaveProperty('totalAlerts');
expect(stats).toHaveProperty('averageResolutionTime');
expect(stats).toHaveProperty('severityBreakdown');
});
});
});
// Helper to simulate the email template generation (since it's inline in the actual code)
AlertManager.prototype.generateEmailTemplate = function(alert) {
const testEmailTemplate = `
<td style="padding: 8px 0; color: #6b7280;">${new Date(alert.triggeredAt || alert.lastUpdate || Date.now()).toLocaleString()}</td>
`;
return testEmailTemplate;
};
// Add helper methods for testing
AlertManager.prototype.addRule = function(rule) {
this.alertRules.set(rule.id, rule);
};
AlertManager.prototype.addNotificationChannel = function(channel) {
this.notificationChannels.set(channel.id, channel);
};
AlertManager.prototype.addEscalationRule = function(rule) {
this.escalationRules.set(rule.id, rule);
};
AlertManager.prototype.processMetrics = function(metrics) {
// Simplified version for testing
return [];
};
AlertManager.prototype.acknowledgeAlert = function(alertId, acknowledgement) {
this.acknowledgedAlerts.set(alertId, acknowledgement);
};
AlertManager.prototype.resolveAlert = function(alertId) {
const alert = this.activeAlerts.get(alertId);
if (alert) {
this.activeAlerts.delete(alertId);
this.alertHistory.push({ ...alert, resolved: true, resolvedAt: Date.now() });
}
};
AlertManager.prototype.shouldSendNotification = function(channelId, alert) {
const channel = this.notificationChannels.get(channelId);
return channel && channel.enabled;
};
AlertManager.prototype.shouldEscalateAlert = function(alert) {
const alertAge = Date.now() - alert.triggeredAt;
return alertAge > 900000 && !this.acknowledgedAlerts.has(alert.id);
};
AlertManager.prototype.suppressAlert = function(alertId, config) {
this.suppressedAlerts.set(alertId, config);
};
AlertManager.prototype.isAlertSuppressed = function(alertId) {
const suppression = this.suppressedAlerts.get(alertId);
if (!suppression) return false;
if (suppression.suppressedUntil < Date.now()) {
this.suppressedAlerts.delete(alertId);
return false;
}
return true;
};
AlertManager.prototype.updateMetrics = function() {
// Update metrics calculation
};
AlertManager.prototype.getAlertStatistics = function() {
const resolved = this.alertHistory.filter(a => a.resolvedAt);
const avgResolution = resolved.length > 0
? resolved.reduce((sum, a) => sum + (a.resolvedAt - a.triggeredAt), 0) / resolved.length
: 0;
const severityBreakdown = this.alertHistory.reduce((acc, alert) => {
acc[alert.severity] = (acc[alert.severity] || 0) + 1;
return acc;
}, {});
return {
totalAlerts: this.alertHistory.length,
averageResolutionTime: avgResolution,
severityBreakdown
};
};
-992
View File
@@ -1,992 +0,0 @@
// Mock dependencies *before* importing the module that uses them
jest.mock('../configLoader');
jest.mock('axios'); // <-- Mock axios instead
// Mock axios-retry: Create a mock function for default, attach *mocked* helpers to it.
jest.mock('axios-retry', () => {
// We don't need requireActual here anymore if we mock the helpers
// const actualAxiosRetry = jest.requireActual('axios-retry');
// Create a mock function for the default export
const mockDefaultFn = jest.fn();
// Attach JEST MOCK FUNCTIONS for the helpers to the default export mock
mockDefaultFn.isNetworkError = jest.fn();
mockDefaultFn.isRetryableError = jest.fn();
mockDefaultFn.exponentialDelay = jest.fn();
// The module export
return {
__esModule: true,
default: mockDefaultFn,
// Also provide the JEST MOCK FUNCTIONS on the main module object for completeness
isNetworkError: mockDefaultFn.isNetworkError, // Point to the same mock fn
isRetryableError: mockDefaultFn.isRetryableError, // Point to the same mock fn
exponentialDelay: mockDefaultFn.exponentialDelay, // Point to the same mock fn
};
});
const { initializeApiClients, createApiClientInstance } = require('../apiClients');
const { loadConfiguration } = require('../configLoader');
const axios = require('axios'); // <-- Get the mocked axios
const axiosRetry = require('axios-retry').default; // <-- Get the mocked default export
// const proxmoxApi = require('proxmox-api'); // <-- Remove this
// Mock console to avoid cluttering test output
// jest.spyOn(console, 'log').mockImplementation(() => {});
// jest.spyOn(console, 'error').mockImplementation(() => {});
describe('API Clients Initialization', () => {
let originalEnv;
// Remove the shared mock instance definition from here
// const mockAxiosInstance = { ... };
beforeEach(() => {
originalEnv = { ...process.env };
jest.resetModules();
jest.clearAllMocks();
// Configure axios.create to return a *new* mock instance each time
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() } // <-- Add response interceptor mock
}
}));
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve1',
name: 'PVE Test 1',
host: '1.1.1.1',
port: '8006', // Add port for baseURL construction
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false // Add for httpsAgent
}],
pbsConfigs: [{
id: 'pbs1',
name: 'PBS Test 1',
host: '2.2.2.2',
port: '8007', // Add port for baseURL construction
username: 'root@pam',
tokenId: 'pbs-token-id',
tokenSecret: 'pbs-token-secret',
authMethod: 'token',
allowSelfSignedCerts: false // Add for httpsAgent
}],
});
});
afterEach(() => {
const currentEnvKeys = Object.keys(process.env);
currentEnvKeys.forEach(key => delete process.env[key]);
Object.keys(originalEnv).forEach(key => { process.env[key] = originalEnv[key]; });
});
test('should initialize PVE and PBS clients successfully with token auth', async () => {
// Arrange
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(loadConfiguration).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledTimes(2);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`,
}));
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`,
}));
// Check interceptors were configured ON EACH client
// Axios.create().mock.results gives us the return values (the mock instances)
// Expect 1 call for manual auth header (axiosRetry mock doesn't add one by default)
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PVE client
expect(axios.create.mock.results[1].value.interceptors.request.use).toHaveBeenCalledTimes(1); // PBS client
// We could also check the response interceptor use if axios-retry was mocked to verify its calls
// Check returned client structure
expect(apiClients).toHaveProperty('pve1');
expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // Check it's the first mock instance
expect(apiClients.pve1.config).toEqual(endpoints[0]);
expect(pbsApiClients).toHaveProperty('pbs1');
expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[1].value); // Check it's the second mock instance
expect(pbsApiClients.pbs1.config).toEqual(pbsConfigs[0]);
});
test('should handle missing PVE endpoints gracefully', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [],
pbsConfigs: [{
id: 'pbs1',
name: 'PBS Test 1',
host: '2.2.2.2',
port: '8007',
username: 'root@pam',
tokenId: 'pbs-token-id',
tokenSecret: 'pbs-token-secret',
authMethod: 'token',
allowSelfSignedCerts: false
}],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${pbsConfigs[0].host}:${pbsConfigs[0].port}/api2/json`
}));
// Check interceptor on the *single* created client
// Expect 1 call for manual auth header
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(apiClients).toEqual({});
expect(pbsApiClients).toHaveProperty('pbs1');
expect(pbsApiClients.pbs1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created
});
test('should handle missing PBS endpoints gracefully', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve1',
name: 'PVE Test 1',
host: '1.1.1.1',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: `https://${endpoints[0].host}:${endpoints[0].port}/api2/json`
}));
// Check interceptor on the *single* created client
// Expect 1 call for manual auth header
expect(axios.create.mock.results[0].value.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(pbsApiClients).toEqual({});
expect(apiClients).toHaveProperty('pve1');
expect(apiClients.pve1.client).toBe(axios.create.mock.results[0].value); // The only mock instance created
});
test('should skip PVE endpoint if tokenId is missing', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-no-tokenid',
name: 'PVE Missing Token ID',
host: '3.3.3.3',
port: '8006',
username: 'root@pam',
// tokenId: 'pve-token-id', // MISSING
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Spy on console.error
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1); // Still creates the instance initially
const createdInstance = axios.create.mock.results[0].value;
// Check that the interceptor did NOT log an error during init
expect(consoleErrorSpy).not.toHaveBeenCalled();
// The client *is* created, even with missing credentials
expect(apiClients).toHaveProperty('pve-no-tokenid');
expect(apiClients['pve-no-tokenid'].client).toBe(createdInstance);
expect(pbsApiClients).toEqual({});
consoleErrorSpy.mockRestore();
});
test('should skip PVE endpoint if tokenSecret is missing', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-no-secret',
name: 'PVE Missing Secret',
host: '4.4.4.4',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
// tokenSecret: 'pve-token-secret', // MISSING
enabled: true,
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
// Check that the interceptor did NOT log an error during init
expect(consoleErrorSpy).not.toHaveBeenCalled();
// The client *is* created, even with missing credentials
expect(apiClients).toHaveProperty('pve-no-secret');
expect(pbsApiClients).toEqual({});
consoleErrorSpy.mockRestore();
});
test('should skip PVE endpoint if enabled is false', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-disabled',
name: 'PVE Disabled',
host: '5.5.5.5',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: false, // DISABLED
allowSelfSignedCerts: false
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); // Spy on console.log
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).not.toHaveBeenCalled(); // Should not attempt to create client
expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE Disabled (5.5.5.5)');
expect(apiClients).toEqual({});
expect(pbsApiClients).toEqual({});
consoleLogSpy.mockRestore();
});
test('should set rejectUnauthorized to false when allowSelfSignedCerts is true', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-self-signed',
name: 'PVE Self Signed',
host: '6.6.6.6',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: true // ALLOW SELF SIGNED
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false }) // Key assertion
})
}));
});
test('should set rejectUnauthorized to true when allowSelfSignedCerts is false', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [{
id: 'pve-strict-ssl',
name: 'PVE Strict SSL',
host: '7.7.7.7',
port: '8006',
username: 'root@pam',
tokenId: 'pve-token-id',
tokenSecret: 'pve-token-secret',
enabled: true,
allowSelfSignedCerts: false // STRICT SSL
}],
pbsConfigs: [],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: true }) // Key assertion
})
}));
});
test('should initialize multiple PVE and PBS endpoints', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [
{ id: 'pve1', name: 'PVE 1', host: '1.1.1.1', port: '8006', username: 'root@pam', tokenId: 't1', tokenSecret: 's1', enabled: true, allowSelfSignedCerts: false },
{ id: 'pve2', name: 'PVE 2', host: '1.1.1.2', port: '8006', username: 'root@pam', tokenId: 't2', tokenSecret: 's2', enabled: true, allowSelfSignedCerts: true },
{ id: 'pve3-disabled', name: 'PVE 3', host: '1.1.1.3', port: '8006', username: 'root@pam', tokenId: 't3', tokenSecret: 's3', enabled: false, allowSelfSignedCerts: false }, // Disabled PVE
],
pbsConfigs: [
{ id: 'pbs1', name: 'PBS 1', host: '2.2.2.1', port: '8007', username: 'root@pam', tokenId: 'pbst1', tokenSecret: 'pbss1', authMethod: 'token', allowSelfSignedCerts: false },
{ id: 'pbs2', name: 'PBS 2', host: '2.2.2.2', port: '8007', username: 'root@pam', tokenId: 'pbst2', tokenSecret: 'pbss2', authMethod: 'token', allowSelfSignedCerts: true },
],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(consoleLogSpy).toHaveBeenCalledWith('INFO: Skipping disabled PVE endpoint: PVE 3 (1.1.1.3)');
expect(axios.create).toHaveBeenCalledTimes(4); // 2 enabled PVE + 2 PBS
// Check PVE clients
expect(Object.keys(apiClients)).toHaveLength(2); // Only enabled ones
expect(apiClients).toHaveProperty('pve1');
expect(apiClients).toHaveProperty('pve2');
expect(apiClients).not.toHaveProperty('pve3-disabled');
// Check specific rejectUnauthorized for PVE clients
const pve1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.1'));
const pve2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('1.1.1.2'));
expect(pve1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true);
expect(pve2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false);
// Check PBS clients
expect(Object.keys(pbsApiClients)).toHaveLength(2);
expect(pbsApiClients).toHaveProperty('pbs1');
expect(pbsApiClients).toHaveProperty('pbs2');
// Check specific rejectUnauthorized for PBS clients
const pbs1Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.1'));
const pbs2Args = axios.create.mock.calls.find(call => call[0].baseURL.includes('2.2.2.2'));
expect(pbs1Args[0].httpsAgent.options.rejectUnauthorized).toBe(true);
expect(pbs2Args[0].httpsAgent.options.rejectUnauthorized).toBe(false);
consoleLogSpy.mockRestore();
});
test('should handle unexpected PBS authMethod', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [], // No PVE for simplicity
pbsConfigs: [{
id: 'pbs-bad-auth',
name: 'PBS Bad Auth',
host: '8.8.8.8',
port: '8007',
authMethod: 'password', // Unexpected method
allowSelfSignedCerts: false
}],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).not.toHaveBeenCalled(); // Client should not be created for this PBS
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`Unexpected authMethod 'password' found during PBS client initialization for: PBS Bad Auth`)
);
expect(apiClients).toEqual({});
expect(pbsApiClients).toEqual({}); // No client should be added
consoleErrorSpy.mockRestore();
});
test('should handle unhandled exception during PBS client map', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [],
pbsConfigs: [{
id: 'pbs-map-error',
name: 'PBS Map Error',
host: '9.9.9.9',
port: '8007',
tokenId: 't', tokenSecret: 's', // Valid creds
authMethod: 'token',
allowSelfSignedCerts: false
}],
});
const { endpoints, pbsConfigs } = loadConfiguration();
const mapError = new Error('Simulated map error');
// Force axios.create to throw error only for this specific host
const originalAxiosCreate = axios.create;
axios.create.mockImplementation((config) => {
if (config.baseURL.includes('9.9.9.9')) {
throw mapError;
}
// Call original mock impl for other cases (if any)
return originalAxiosCreate();
});
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Act
const { apiClients, pbsApiClients } = await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(1); // Attempted to create
// Check the first argument contains the core message, allow anything for the second (stack trace)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining(`ERROR: Unhandled exception during PBS client initialization for PBS Map Error: ${mapError.message}`),
expect.anything() // Allow the stack trace as the second argument
);
expect(apiClients).toEqual({});
expect(pbsApiClients).toEqual({}); // Client not added due to error
// Restore original mock implementation if needed for other tests
axios.create.mockImplementation(originalAxiosCreate);
consoleErrorSpy.mockRestore();
});
// --- Tests for Retry Logic ---
test('should call axiosRetry during initialization', async () => {
// Simple test to ensure axiosRetry is called during init
const { endpoints, pbsConfigs } = loadConfiguration();
await initializeApiClients(endpoints, pbsConfigs);
// Expect 1 call for PVE client + 1 call for PBS client from default setup
expect(axiosRetry).toHaveBeenCalledTimes(2);
// Check args for the PVE client call
expect(axiosRetry).toHaveBeenCalledWith(
axios.create.mock.results[0].value, // The first created axios instance
expect.objectContaining({ retries: 3 }) // Check if retry config is passed
);
});
test('should log error when PVE request interceptor encounters missing credentials', async () => {
// Arrange
const missingCredsEndpoint = {
id: 'pve-bad-creds',
name: 'PVE Missing Creds',
host: '11.11.11.11',
port: '8006',
// Missing tokenId and tokenSecret
enabled: true,
allowSelfSignedCerts: false
};
loadConfiguration.mockReturnValue({ endpoints: [missingCredsEndpoint], pbsConfigs: [] });
const { endpoints, pbsConfigs } = loadConfiguration();
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
// Mock axios.create specifically for this test
let capturedInterceptor = null; // Variable to hold the interceptor function
const mockGet = jest.fn().mockResolvedValue({ data: 'ignored' });
const mockAxiosInstance = {
get: async (url, config) => {
// Simulate running the interceptor before the request
if (capturedInterceptor) {
// Pass a mock config object, interceptor might modify it
const mockConfig = { headers: {}, url, ...config };
try {
await capturedInterceptor(mockConfig); // Run the interceptor
} catch (interceptorError) {
// If interceptor throws (e.g., Promise.reject), rethrow it
throw interceptorError;
}
}
return mockGet(url, config); // Run the actual mock get
},
interceptors: {
request: {
use: jest.fn(successFn => { // Capture the interceptor function
capturedInterceptor = successFn;
})
},
response: { use: jest.fn() }
}
};
axios.create.mockReturnValue(mockAxiosInstance);
// Act: Initialize clients (this adds the interceptor via the mock .use)
const { apiClients } = await initializeApiClients(endpoints, pbsConfigs);
const pveClient = apiClients['pve-bad-creds']?.client;
expect(pveClient).toBeDefined();
expect(capturedInterceptor).not.toBeNull(); // Check interceptor was captured
// Act: Attempt an API call which should trigger the interceptor via the mock .get
try {
await pveClient.get('/nodes');
} catch (e) {
// We don't expect the get call itself to throw here,
// the interceptor just logs an error in this case.
}
// Assert: Check that the console error was logged by the interceptor
expect(consoleErrorSpy).toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith(
`ERROR: Endpoint ${missingCredsEndpoint.name} is missing required API token credentials.`
);
consoleErrorSpy.mockRestore();
// Restore default axios.create mock from beforeEach
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }
}));
});
// Removing the complex/brittle retry simulation tests below as the core logic
// is now tested via the helper function tests (pbsRetryDelayLogger, pbsRetryConditionChecker)
// and the basic call is verified by 'should call axiosRetry during initialization'.
/*
test('should retry PVE API calls on network errors', async () => {
// ... (Removed Test Code) ...
});
*/
/*
test('should retry PBS API calls on retryable errors and log warning', async () => {
// ... (Removed Test Code) ...
});
*/
// Add more tests here for:
// - Config validation errors (missing fields in loadConfiguration result)
// - Axios errors during initialization (e.g., interceptor setup fails? unlikely)
// - Multiple endpoints for PVE/PBS
// - Different auth methods (if implemented)
// - rejectUnauthorized logic
test('should correctly build baseURL for hosts with and without protocol', async () => {
// Arrange
loadConfiguration.mockReturnValue({
endpoints: [
{ id: 'pve-no-proto', name: 'PVE No Protocol', host: '1.1.1.1', port: '8006', enabled: true, tokenId: 't1', tokenSecret: 's1', allowSelfSignedCerts: false },
{ id: 'pve-with-proto', name: 'PVE With Protocol', host: 'https://1.1.1.2', port: '8006', enabled: true, tokenId: 't2', tokenSecret: 's2', allowSelfSignedCerts: false },
],
pbsConfigs: [
{ id: 'pbs-no-proto', name: 'PBS No Protocol', host: '2.2.2.1', port: '8007', authMethod: 'token', tokenId: 'pt1', tokenSecret: 'ps1', allowSelfSignedCerts: false },
{ id: 'pbs-with-proto', name: 'PBS With Protocol', host: 'https://2.2.2.2', port: '8007', authMethod: 'token', tokenId: 'pt2', tokenSecret: 'ps2', allowSelfSignedCerts: false },
],
});
const { endpoints, pbsConfigs } = loadConfiguration();
// Act
await initializeApiClients(endpoints, pbsConfigs);
// Assert
expect(axios.create).toHaveBeenCalledTimes(4); // 2 PVE + 2 PBS
// Check PVE Base URLs
const pveNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.1'));
const pveWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('1.1.1.2'));
expect(pveNoProtoArgs[0].baseURL).toBe('https://1.1.1.1:8006/api2/json'); // Checks the ':' branch (line 63)
expect(pveWithProtoArgs[0].baseURL).toBe('https://1.1.1.2/api2/json'); // Checks the '?' branch (line 62)
// Check PBS Base URLs
const pbsNoProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.1'));
const pbsWithProtoArgs = axios.create.mock.calls.find(call => call[0].baseURL?.includes('2.2.2.2'));
expect(pbsNoProtoArgs[0].baseURL).toBe('https://2.2.2.1:8007/api2/json'); // Checks the ':' branch (line 144)
expect(pbsWithProtoArgs[0].baseURL).toBe('https://2.2.2.2/api2/json'); // Checks the '?' branch (line 143)
});
});
// --- Direct Tests for Helper Functions ---
describe('API Client Helper Functions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
// --- Tests for createApiClientInstance ---
describe('createApiClientInstance', () => {
const { createApiClientInstance } = require('../apiClients');
const axios = require('axios'); // Mocked axios
const axiosRetry = require('axios-retry').default; // Mocked axiosRetry
beforeEach(() => {
// Reset axios.create and axiosRetry mocks
axios.create.mockClear();
axiosRetry.mockClear();
// Reconfigure axios.create to return a mock instance with spied interceptors
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() }
}
}));
});
test('should create an instance with provided baseURL and httpsAgent config', () => {
const baseURL = 'https://test.com/api';
const allowSelfSignedCerts = true;
createApiClientInstance(baseURL, allowSelfSignedCerts);
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: baseURL,
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false })
}),
headers: { 'Content-Type': 'application/json' }
}));
});
test('should call request.use when authInterceptor is provided', () => {
const mockInterceptor = jest.fn();
const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig
expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor);
});
test('should NOT call request.use when authInterceptor is NOT provided', () => {
const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(apiClient.interceptors.request.use).not.toHaveBeenCalled();
});
test('should call axiosRetry when retryConfig is provided', () => {
const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() };
const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig);
expect(axiosRetry).toHaveBeenCalledTimes(1);
expect(axiosRetry).toHaveBeenCalledWith(apiClient, {
retries: mockRetryConfig.retries,
retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger
retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker
});
});
test('should NOT call axiosRetry when retryConfig is NOT provided', () => {
createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(axiosRetry).not.toHaveBeenCalled();
});
});
// --- createPveAuthInterceptor Tests ---
// --- createPveAuthInterceptor Tests ---
describe('createPveAuthInterceptor', () => {
const { createPveAuthInterceptor } = require('../apiClients');
const mockEndpoint = { name: 'Test PVE', tokenId: 'test-id', tokenSecret: 'test-secret' };
const mockEndpointMissingCreds = { name: 'Test PVE Bad' }; // Missing credentials
test('should return a function', () => {
const interceptor = createPveAuthInterceptor(mockEndpoint);
expect(typeof interceptor).toBe('function');
});
test('should add Authorization header if credentials exist', () => {
const interceptor = createPveAuthInterceptor(mockEndpoint);
const mockConfig = { headers: {} };
const resultConfig = interceptor(mockConfig);
expect(resultConfig.headers.Authorization).toBe(`PVEAPIToken=test-id=test-secret`);
});
test('should NOT add Authorization header and log error if credentials missing', () => {
const interceptor = createPveAuthInterceptor(mockEndpointMissingCreds);
const mockConfig = { headers: {} };
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const resultConfig = interceptor(mockConfig);
expect(resultConfig.headers.Authorization).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`ERROR: Endpoint ${mockEndpointMissingCreds.name} is missing required API token credentials.`
);
consoleErrorSpy.mockRestore();
});
});
// --- createPbsAuthInterceptor Tests ---
describe('createPbsAuthInterceptor', () => {
const { createPbsAuthInterceptor } = require('../apiClients');
const mockConfig = { tokenId: 'pbs-id', tokenSecret: 'pbs-secret' };
test('should return a function', () => {
const interceptor = createPbsAuthInterceptor(mockConfig);
expect(typeof interceptor).toBe('function');
});
test('should add correct PBS Authorization header', () => {
const interceptor = createPbsAuthInterceptor(mockConfig);
const mockReqConfig = { headers: {} };
const resultConfig = interceptor(mockReqConfig);
expect(resultConfig.headers.Authorization).toBe(`PBSAPIToken=pbs-id:pbs-secret`);
});
// Note: Add test for missing creds if validation doesn't happen before calling this
});
// --- Tests for createApiClientInstance ---
describe('createApiClientInstance', () => {
const { createApiClientInstance } = require('../apiClients');
const axios = require('axios'); // Mocked axios
const axiosRetry = require('axios-retry').default; // Mocked axiosRetry
beforeEach(() => {
// Reset axios.create and axiosRetry mocks
axios.create.mockClear();
axiosRetry.mockClear();
// Reconfigure axios.create to return a mock instance with spied interceptors
axios.create.mockImplementation(() => ({
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() }
}
}));
});
test('should create an instance with provided baseURL and httpsAgent config', () => {
const baseURL = 'https://test.com/api';
const allowSelfSignedCerts = true;
createApiClientInstance(baseURL, allowSelfSignedCerts);
expect(axios.create).toHaveBeenCalledTimes(1);
expect(axios.create).toHaveBeenCalledWith(expect.objectContaining({
baseURL: baseURL,
httpsAgent: expect.objectContaining({
options: expect.objectContaining({ rejectUnauthorized: false })
}),
headers: { 'Content-Type': 'application/json' }
}));
});
test('should call request.use when authInterceptor is provided', () => {
const mockInterceptor = jest.fn();
const apiClient = createApiClientInstance('https://test.com', false, mockInterceptor, null); // Pass null for retryConfig
expect(apiClient.interceptors.request.use).toHaveBeenCalledTimes(1);
expect(apiClient.interceptors.request.use).toHaveBeenCalledWith(mockInterceptor);
});
test('should NOT call request.use when authInterceptor is NOT provided', () => {
const apiClient = createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(apiClient.interceptors.request.use).not.toHaveBeenCalled();
});
test('should call axiosRetry when retryConfig is provided', () => {
const mockRetryConfig = { retries: 5, retryDelayLogger: jest.fn(), retryConditionChecker: jest.fn() };
const apiClient = createApiClientInstance('https://test.com', false, null, mockRetryConfig);
expect(axiosRetry).toHaveBeenCalledTimes(1);
expect(axiosRetry).toHaveBeenCalledWith(apiClient, {
retries: mockRetryConfig.retries,
retryDelay: mockRetryConfig.retryDelayLogger, // Now correctly accesses the logger
retryCondition: mockRetryConfig.retryConditionChecker, // Now correctly accesses the checker
});
});
test('should NOT call axiosRetry when retryConfig is NOT provided', () => {
createApiClientInstance('https://test.com', false, null, null); // Pass null for both
expect(axiosRetry).not.toHaveBeenCalled();
});
});
// --- pveRetryDelayLogger Tests ---
describe('pveRetryDelayLogger', () => {
const { pveRetryDelayLogger } = require('../apiClients');
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
axiosRetry.exponentialDelay.mockClear();
axiosRetry.exponentialDelay.mockReturnValue(500); // Use different value for clarity
});
test('should log warning with correct PVE details', () => {
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const testError = new Error('PVE Failed');
pveRetryDelayLogger('TestPVE', 3, testError);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Retrying PVE API request for TestPVE (attempt 3) due to error: PVE Failed'
);
consoleWarnSpy.mockRestore();
});
test('should call mocked axiosRetry.exponentialDelay and return its value', () => {
const result = pveRetryDelayLogger('TestPVE', 2, new Error('Test'));
expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1);
expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(2); // Called with retryCount
expect(result).toBe(500); // Returns the mock value
});
});
// --- pbsRetryDelayLogger Tests ---
describe('pbsRetryDelayLogger', () => {
const { pbsRetryDelayLogger } = require('../apiClients');
// Get the mocked default export which has the mocked helpers
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
// Reset mocks before each test in this suite
axiosRetry.exponentialDelay.mockClear();
axiosRetry.exponentialDelay.mockReturnValue(1000); // Set default mock return for simplicity
});
test('should log warning with correct details', () => {
// ... (this test remains the same, just checking console.warn) ...
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const testError = new Error('PBS Failed');
pbsRetryDelayLogger('TestPBS', 2, testError);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledWith(
'Retrying PBS API request for TestPBS (Token Auth - attempt 2) due to error: PBS Failed'
);
consoleWarnSpy.mockRestore();
});
test('should call mocked axiosRetry.exponentialDelay and return its value', () => {
// No spy needed, just call the function and check the pre-existing mock
const result = pbsRetryDelayLogger('TestPBS', 1, new Error('Test'));
expect(axiosRetry.exponentialDelay).toHaveBeenCalledTimes(1);
expect(axiosRetry.exponentialDelay).toHaveBeenCalledWith(1);
expect(result).toBe(1000); // Should return the mock value
});
});
// --- pbsRetryConditionChecker Tests ---
describe('pbsRetryConditionChecker', () => {
const { pbsRetryConditionChecker } = require('../apiClients');
// Get the mocked default export which has the mocked helpers
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
// Reset mocks and set default return values before each test
axiosRetry.isNetworkError.mockClear().mockReturnValue(false);
axiosRetry.isRetryableError.mockClear().mockReturnValue(false);
});
// No afterEach needed as we clear in beforeEach
test('should return true for network errors', () => {
const networkError = new Error('Network Error');
axiosRetry.isNetworkError.mockReturnValue(true); // Override default mock return
axiosRetry.isRetryableError.mockReturnValue(false); // Ensure this stays false for the test
expect(pbsRetryConditionChecker(networkError)).toBe(true);
// Verify mocks were called (or not called due to short-circuit)
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError);
expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Corrected assertion
});
test('should return true for retryable errors', () => {
const retryableError = new Error('Retryable Error');
retryableError.response = { status: 503 };
axiosRetry.isRetryableError.mockReturnValue(true); // Override default mock return
expect(pbsRetryConditionChecker(retryableError)).toBe(true);
// Verify mocks were called
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError);
});
test('should return false for non-network, non-retryable errors', () => {
const otherError = new Error('Other Error');
// Default mock returns (false, false) are already set in beforeEach
expect(pbsRetryConditionChecker(otherError)).toBe(false);
// Verify mocks were called
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError);
});
});
// --- pveRetryConditionChecker Tests ---
describe('pveRetryConditionChecker', () => {
const { pveRetryConditionChecker } = require('../apiClients');
const axiosRetry = require('axios-retry').default;
beforeEach(() => {
axiosRetry.isNetworkError.mockClear().mockReturnValue(false);
axiosRetry.isRetryableError.mockClear().mockReturnValue(false);
});
test('should return true for network errors', () => {
const networkError = new Error('Network Error');
axiosRetry.isNetworkError.mockReturnValue(true);
expect(pveRetryConditionChecker(networkError)).toBe(true);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(networkError);
expect(axiosRetry.isRetryableError).not.toHaveBeenCalled(); // Short-circuits
});
test('should return true for retryable errors', () => {
const retryableError = new Error('Retryable Error');
axiosRetry.isRetryableError.mockReturnValue(true);
expect(pveRetryConditionChecker(retryableError)).toBe(true);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(retryableError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(retryableError);
});
test('should return true for error with status 596', () => {
const status596Error = new Error('Status 596 Error');
status596Error.response = { status: 596 };
// Ensure other checks are false
axiosRetry.isNetworkError.mockReturnValue(false);
axiosRetry.isRetryableError.mockReturnValue(false);
expect(pveRetryConditionChecker(status596Error)).toBe(true);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status596Error);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status596Error);
});
test('should return false for other errors without status 596', () => {
const otherError = new Error('Other Error');
// Ensure other checks are false (default from beforeEach)
expect(pveRetryConditionChecker(otherError)).toBe(false);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(otherError);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(otherError);
});
test('should return false for error with different response status', () => {
const status500Error = new Error('Status 500 Error');
status500Error.response = { status: 500 };
// Ensure other checks are false (default from beforeEach)
expect(pveRetryConditionChecker(status500Error)).toBe(false);
expect(axiosRetry.isNetworkError).toHaveBeenCalledWith(status500Error);
expect(axiosRetry.isRetryableError).toHaveBeenCalledWith(status500Error);
});
});
});
-437
View File
@@ -1,437 +0,0 @@
/**
* Backup Data Validator
*
* This module provides utilities to validate backup data against known ground truths
* and help identify discrepancies in the backup system.
*/
// Ground truth data based on research
const groundTruthData = {
totalGuests: 18, // Actual cluster count
pbsBackupsTotal: 135,
vmSnapshots: 3, // Only 3 actual VM/CT snapshots
// Backup job schedules
primaryBackupJob: {
id: 'backup-2759a200-3e11',
schedule: '02:00 AM',
excludes: [102, 200, 400],
retention: { daily: 7, weekly: 4, monthly: 3 }
},
secondaryBackupJob: {
id: 'backup-79ce96ee-6527',
schedule: '04:00 AM',
includes: [102, 200, 400],
retention: { keepLast: 3 }
},
// Expected backup ages (as of June 2, 12:50 PM BST)
expectedBackupAges: {
primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups
secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups
vm102: 'no_recent_backup' // Issue found in research
},
// Known issues from research
knownIssues: {
guestCountDiscrepancy: true, // Pulse shows 20, actual is 18
vm102BackupMissing: true,
multipleEndpoints: 2, // proxmox.lan and pimox.lan
snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots
}
};
/**
* Validates guest count against expected values
* @param {Object} discoveryData - The discovery data from fetchDiscoveryData
* @returns {Object} Validation result with details
*/
function validateGuestCount(discoveryData) {
const actualVMs = discoveryData.vms?.length || 0;
const actualContainers = discoveryData.containers?.length || 0;
const actualTotal = actualVMs + actualContainers;
const result = {
valid: actualTotal === groundTruthData.totalGuests,
expected: groundTruthData.totalGuests,
actual: actualTotal,
vms: actualVMs,
containers: actualContainers,
discrepancy: actualTotal - groundTruthData.totalGuests,
details: []
};
if (!result.valid) {
result.details.push(`Guest count mismatch: Expected ${result.expected}, got ${result.actual}`);
// Check for known issue
if (actualTotal === 20 && groundTruthData.totalGuests === 18) {
result.details.push('Known issue: Pulse showing 20 guests instead of actual 18');
}
}
// Group by endpoint for detailed analysis
const guestsByEndpoint = {};
[...(discoveryData.vms || []), ...(discoveryData.containers || [])].forEach(guest => {
const endpoint = guest.endpointId || 'unknown';
if (!guestsByEndpoint[endpoint]) {
guestsByEndpoint[endpoint] = { vms: 0, containers: 0 };
}
if (guest.type === 'qemu') {
guestsByEndpoint[endpoint].vms++;
} else {
guestsByEndpoint[endpoint].containers++;
}
});
result.byEndpoint = guestsByEndpoint;
return result;
}
/**
* Validates PBS backup counts vs VM snapshots
* @param {Object} pbsData - PBS data from fetchPbsData
* @param {Object} pveBackups - PVE backup data
* @returns {Object} Validation result
*/
function validateBackupCounts(pbsData, pveBackups) {
let pbsBackupCount = 0;
let pbsBackupsByGuest = {};
// Count PBS backups
if (pbsData && pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
(ds.snapshots || []).forEach(snap => {
pbsBackupCount++;
const guestKey = `${snap['backup-type']}/${snap['backup-id']}`;
pbsBackupsByGuest[guestKey] = (pbsBackupsByGuest[guestKey] || 0) + 1;
});
});
}
const vmSnapshotCount = pveBackups?.guestSnapshots?.length || 0;
const result = {
valid: pbsBackupCount > 100 && vmSnapshotCount < 10, // Expected pattern
pbsBackups: {
total: pbsBackupCount,
expected: groundTruthData.pbsBackupsTotal,
byGuest: pbsBackupsByGuest
},
vmSnapshots: {
total: vmSnapshotCount,
expected: groundTruthData.vmSnapshots,
list: pveBackups?.guestSnapshots || []
},
details: []
};
if (Math.abs(pbsBackupCount - groundTruthData.pbsBackupsTotal) > 10) {
result.details.push(`PBS backup count differs from expected: ${pbsBackupCount} vs ${groundTruthData.pbsBackupsTotal}`);
}
if (vmSnapshotCount > groundTruthData.vmSnapshots) {
result.details.push(`More VM snapshots than expected: ${vmSnapshotCount} vs ${groundTruthData.vmSnapshots}`);
}
return result;
}
/**
* Validates backup ages for all guests
* @param {Object} pbsData - PBS data
* @param {Date} currentTime - Current time for age calculations
* @returns {Object} Validation result with age analysis
*/
function validateBackupAges(pbsData, currentTime = new Date()) {
const backupAges = new Map();
const guestsWithoutBackups = new Set();
const expectedGuests = new Set();
// Build expected guest list
for (let i = 100; i <= 106; i++) {
expectedGuests.add(String(i));
}
for (let i = 200; i <= 400; i += 100) {
expectedGuests.add(String(i));
}
// Analyze PBS backups
if (pbsData && pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
(ds.snapshots || []).forEach(snap => {
const backupTime = snap['backup-time'] * 1000; // Convert to milliseconds
const ageHours = (currentTime.getTime() - backupTime) / (1000 * 60 * 60);
const guestId = snap['backup-id'];
if (!backupAges.has(guestId) || ageHours < backupAges.get(guestId)) {
backupAges.set(guestId, ageHours);
}
});
});
}
// Find guests without recent backups
expectedGuests.forEach(guestId => {
if (!backupAges.has(guestId) || backupAges.get(guestId) > 24) {
guestsWithoutBackups.add(guestId);
}
});
// Categorize by backup schedule
const primaryJobGuests = [];
const secondaryJobGuests = [];
const issues = [];
backupAges.forEach((age, guestId) => {
const id = parseInt(guestId);
if ([102, 200, 400].includes(id)) {
secondaryJobGuests.push({ id: guestId, age });
if (age < groundTruthData.expectedBackupAges.secondaryJobGuests.minHours ||
age > groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours + 1) {
issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`);
}
} else {
primaryJobGuests.push({ id: guestId, age });
if (age < groundTruthData.expectedBackupAges.primaryJobGuests.minHours ||
age > groundTruthData.expectedBackupAges.primaryJobGuests.maxHours + 1) {
issues.push(`Guest ${guestId} backup age ${age.toFixed(1)}h outside expected range`);
}
}
});
// Check for VM 102 issue
if (guestsWithoutBackups.has('102')) {
issues.push('VM 102 has no recent backup (known issue)');
}
return {
valid: issues.length === 0,
backupAges: Object.fromEntries(backupAges),
primaryJobGuests,
secondaryJobGuests,
guestsWithoutBackups: Array.from(guestsWithoutBackups),
issues,
summary: {
totalGuests: expectedGuests.size,
guestsWithBackups: backupAges.size,
guestsWithRecentBackups: Array.from(backupAges.entries())
.filter(([_, age]) => age < 24).length
}
};
}
/**
* Validates PBS task categorization
* @param {Array} pbsTasks - Raw PBS tasks
* @param {Object} processedTasks - Processed tasks from processPbsTasks
* @returns {Object} Validation result
*/
function validateTaskProcessing(pbsTasks, processedTasks) {
const result = {
valid: true,
totalTasks: pbsTasks?.length || 0,
categorized: {
backup: processedTasks.backupTasks?.summary?.total || 0,
verify: processedTasks.verificationTasks?.summary?.total || 0,
sync: processedTasks.syncTasks?.summary?.total || 0,
prune: processedTasks.pruneTasks?.summary?.total || 0
},
uncategorized: [],
issues: []
};
// Check if all tasks were categorized
const categorizedTotal = Object.values(result.categorized).reduce((a, b) => a + b, 0);
if (categorizedTotal !== result.totalTasks) {
result.valid = false;
result.issues.push(`Task count mismatch: ${categorizedTotal} categorized out of ${result.totalTasks} total`);
// Find uncategorized tasks
const taskTypeMap = {
backup: 'backup',
verify: 'verify',
sync: 'sync',
prune: 'prune',
garbage_collection: 'prune',
gc: 'prune'
};
pbsTasks?.forEach(task => {
const type = task.worker_type || task.type;
if (!taskTypeMap[type]) {
result.uncategorized.push(type);
}
});
}
// Check for backup task details
const backupTasks = processedTasks.backupTasks?.recentTasks || [];
const pbsBackupTasks = backupTasks.filter(t => t.pbsBackupRun);
if (pbsBackupTasks.length === 0 && result.categorized.backup > 0) {
result.issues.push('No PBS backup runs found in recent tasks');
}
return result;
}
/**
* Performs comprehensive validation of all backup data
* @param {Object} data - Object containing discoveryData, pbsData, etc.
* @returns {Object} Complete validation report
*/
function validateAllBackupData(data) {
const report = {
timestamp: new Date().toISOString(),
validations: {},
overallValid: true,
criticalIssues: [],
warnings: []
};
// Guest count validation
if (data.discoveryData) {
report.validations.guestCount = validateGuestCount(data.discoveryData);
if (!report.validations.guestCount.valid) {
report.warnings.push('Guest count discrepancy detected');
}
}
// Backup count validation
if (data.pbsData && data.discoveryData?.pveBackups) {
report.validations.backupCounts = validateBackupCounts(
data.pbsData,
data.discoveryData.pveBackups
);
if (!report.validations.backupCounts.valid) {
report.criticalIssues.push('Backup count validation failed');
report.overallValid = false;
}
}
// Backup age validation
if (data.pbsData) {
report.validations.backupAges = validateBackupAges(data.pbsData);
if (!report.validations.backupAges.valid) {
report.validations.backupAges.issues.forEach(issue => {
if (issue.includes('VM 102')) {
report.warnings.push(issue);
} else {
report.criticalIssues.push(issue);
report.overallValid = false;
}
});
}
}
// Task processing validation
if (data.pbsTasks && data.processedTasks) {
report.validations.taskProcessing = validateTaskProcessing(
data.pbsTasks,
data.processedTasks
);
if (!report.validations.taskProcessing.valid) {
report.warnings.push('Task processing issues detected');
}
}
// Summary
report.summary = {
criticalIssues: report.criticalIssues.length,
warnings: report.warnings.length,
recommendation: report.overallValid
? 'Backup data appears valid'
: 'Critical issues found - investigate backup system'
};
return report;
}
/**
* Generates a human-readable report from validation results
* @param {Object} validationReport - Report from validateAllBackupData
* @returns {String} Formatted report
*/
function generateValidationReport(validationReport) {
let report = `Backup Data Validation Report
Generated: ${validationReport.timestamp}
========================================
`;
// Overall Status
report += `Overall Status: ${validationReport.overallValid ? '✓ PASS' : '✗ FAIL'}\n`;
report += `Critical Issues: ${validationReport.criticalIssues.length}\n`;
report += `Warnings: ${validationReport.warnings.length}\n\n`;
// Guest Count
if (validationReport.validations.guestCount) {
const gc = validationReport.validations.guestCount;
report += `Guest Count Validation:\n`;
report += ` Expected: ${gc.expected} guests\n`;
report += ` Actual: ${gc.actual} guests (${gc.vms} VMs, ${gc.containers} CTs)\n`;
if (gc.byEndpoint) {
report += ` By Endpoint:\n`;
Object.entries(gc.byEndpoint).forEach(([endpoint, counts]) => {
report += ` ${endpoint}: ${counts.vms} VMs, ${counts.containers} CTs\n`;
});
}
report += '\n';
}
// Backup Counts
if (validationReport.validations.backupCounts) {
const bc = validationReport.validations.backupCounts;
report += `Backup Count Validation:\n`;
report += ` PBS Backups: ${bc.pbsBackups.total} (expected ~${bc.pbsBackups.expected})\n`;
report += ` VM Snapshots: ${bc.vmSnapshots.total} (expected ${bc.vmSnapshots.expected})\n`;
report += '\n';
}
// Backup Ages
if (validationReport.validations.backupAges) {
const ba = validationReport.validations.backupAges;
report += `Backup Age Validation:\n`;
report += ` Total Guests: ${ba.summary.totalGuests}\n`;
report += ` Guests with backups: ${ba.summary.guestsWithBackups}\n`;
report += ` Guests with recent backups (<24h): ${ba.summary.guestsWithRecentBackups}\n`;
if (ba.guestsWithoutBackups.length > 0) {
report += ` Guests without recent backups: ${ba.guestsWithoutBackups.join(', ')}\n`;
}
report += '\n';
}
// Issues
if (validationReport.criticalIssues.length > 0) {
report += `Critical Issues:\n`;
validationReport.criticalIssues.forEach(issue => {
report += ` - ${issue}\n`;
});
report += '\n';
}
if (validationReport.warnings.length > 0) {
report += `Warnings:\n`;
validationReport.warnings.forEach(warning => {
report += ` - ${warning}\n`;
});
report += '\n';
}
report += `Recommendation: ${validationReport.summary.recommendation}\n`;
return report;
}
module.exports = {
validateGuestCount,
validateBackupCounts,
validateBackupAges,
validateTaskProcessing,
validateAllBackupData,
generateValidationReport
};
-571
View File
@@ -1,571 +0,0 @@
const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher');
const { processPbsTasks } = require('../pbsUtils');
// Mock data based on your ground truth research
const groundTruthData = {
totalGuests: 18, // Actual cluster count
pbsBackupsTotal: 135,
vmSnapshots: 3, // Only 3 actual VM/CT snapshots
// Backup job schedules
primaryBackupJob: {
id: 'backup-2759a200-3e11',
schedule: '02:00 AM',
excludes: [102, 200, 400],
retention: { daily: 7, weekly: 4, monthly: 3 }
},
secondaryBackupJob: {
id: 'backup-79ce96ee-6527',
schedule: '04:00 AM',
includes: [102, 200, 400],
retention: { keepLast: 3 }
},
// Expected backup ages (as of June 2, 12:50 PM BST)
expectedBackupAges: {
primaryJobGuests: { minHours: 10, maxHours: 11 }, // 2:00-2:10 AM backups
secondaryJobGuests: { minHours: 8, maxHours: 9 }, // 4:00 AM backups
vm102: 'no_recent_backup' // Issue found in research
},
// Known issues from research
knownIssues: {
guestCountDiscrepancy: true, // Pulse shows 20, actual is 18
vm102BackupMissing: true,
multipleEndpoints: 2, // proxmox.lan and pimox.lan
snapshotLoggingConfusion: true // Logs incorrectly label PBS backups as snapshots
}
};
describe('Backup Ground Truth Verification Tests', () => {
let mockApiClients;
let mockPbsApiClients;
let discoveryData;
beforeEach(() => {
// Mock the API clients with realistic data
mockApiClients = {
'proxmox-lan': {
client: {
get: jest.fn()
},
config: {
name: 'proxmox.lan',
tokenId: 'test@pve!test',
tokenSecret: 'test-secret'
}
},
'pimox-lan': {
client: {
get: jest.fn()
},
config: {
name: 'pimox.lan',
tokenId: 'test@pve!test',
tokenSecret: 'test-secret'
}
}
};
mockPbsApiClients = {
'pbs-main': {
client: {
get: jest.fn(),
post: jest.fn()
},
config: {
name: 'PBS Storage',
nodeName: 'pbs-node'
}
}
};
});
describe('Guest Count Verification', () => {
test('should correctly count total guests across all endpoints', async () => {
// Mock PVE nodes response
mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'cluster', name: 'proxmox-cluster', nodes: 3 },
{ type: 'node', name: 'desktop', ip: '192.168.1.10' },
{ type: 'node', name: 'delly', ip: '192.168.1.11' },
{ type: 'node', name: 'minipc', ip: '192.168.1.12' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'desktop', status: 'online' },
{ node: 'delly', status: 'online' },
{ node: 'minipc', status: 'online' }
]
}
});
}
if (path.includes('/qemu')) {
// Each node has different VMs
if (path.includes('/nodes/desktop/')) {
return Promise.resolve({ data: { data: [
{ vmid: 102, name: 'windows11', status: 'stopped' },
{ vmid: 200, name: 'UnraidServer', status: 'stopped' },
{ vmid: 400, name: 'ubuntu-gpu-vm', status: 'stopped' }
]}});
}
return Promise.resolve({ data: { data: [] }});
}
if (path.includes('/lxc')) {
// Distribute containers across nodes
if (path.includes('/nodes/desktop/')) {
return Promise.resolve({ data: { data: [
{ vmid: 100, name: 'pbs', status: 'running' },
{ vmid: 109, name: 'pbs2', status: 'stopped' },
{ vmid: 111, name: 'debian', status: 'stopped' }
]}});
} else if (path.includes('/nodes/delly/')) {
return Promise.resolve({ data: { data: [
{ vmid: 101, name: 'homeassistant', status: 'running' },
{ vmid: 105, name: 'homepage', status: 'running' },
{ vmid: 108, name: 'frigate', status: 'running' },
{ vmid: 110, name: 'tailscale-router', status: 'running' },
{ vmid: 122, name: 'influxdb-telegraf', status: 'running' }
]}});
} else if (path.includes('/nodes/minipc/')) {
return Promise.resolve({ data: { data: [
{ vmid: 103, name: 'pihole', status: 'running' },
{ vmid: 104, name: 'cloudflared', status: 'running' },
{ vmid: 106, name: 'pulse', status: 'running' },
{ vmid: 107, name: 'jellyfin', status: 'running' },
{ vmid: 120, name: 'mqtt', status: 'running' },
{ vmid: 121, name: 'zigbee2mqtt', status: 'running' },
{ vmid: 124, name: 'grafana', status: 'running' }
]}});
}
return Promise.resolve({ data: { data: [] }});
}
return Promise.resolve({ data: { data: [] } });
});
mockApiClients['pimox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'node', name: 'pi', ip: '192.168.1.20' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [{ node: 'pi', status: 'online' }]
}
});
}
if (path.includes('/qemu')) {
return Promise.resolve({ data: { data: [] }});
}
if (path.includes('/lxc')) {
return Promise.resolve({ data: { data: [] }});
}
return Promise.resolve({ data: { data: [] } });
});
discoveryData = await fetchDiscoveryData(mockApiClients, {});
const totalVMs = discoveryData.vms.length;
const totalContainers = discoveryData.containers.length;
const totalGuests = totalVMs + totalContainers;
// Verify against ground truth
expect(totalGuests).toBe(groundTruthData.totalGuests);
expect(totalVMs).toBe(3); // VMs 102, 200, 400
expect(totalContainers).toBe(15); // All containers across all nodes
// Check for known discrepancy
if (totalGuests !== 20) {
console.log(`Guest count discrepancy detected: Actual ${totalGuests}, Pulse might show 20`);
}
});
});
describe('PBS Backup Count Verification', () => {
test('should correctly count PBS backups vs VM snapshots', async () => {
// Mock PBS datastore groups and snapshots
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'pbs-node' }] }
});
}
if (path === '/config/datastore') {
return Promise.resolve({
data: { data: [{ name: 'main-datastore' }] }
});
}
if (path.includes('/admin/datastore/main-datastore/snapshots')) {
// This is called by fetchPbsDatastoreSnapshots - return all 135 snapshots
const allSnapshots = [];
const now = Math.floor(Date.now() / 1000);
// Create snapshots for all guests
const guests = [
{ type: 'ct', id: '100', count: 9 },
{ type: 'ct', id: '101', count: 9 },
{ type: 'vm', id: '102', count: 0 }, // VM 102 has no backups
{ type: 'ct', id: '103', count: 9 },
{ type: 'ct', id: '104', count: 9 },
{ type: 'ct', id: '105', count: 9 },
{ type: 'ct', id: '106', count: 9 },
{ type: 'ct', id: '107', count: 9 },
{ type: 'ct', id: '108', count: 9 },
{ type: 'ct', id: '109', count: 9 },
{ type: 'ct', id: '110', count: 9 },
{ type: 'ct', id: '111', count: 9 },
{ type: 'ct', id: '120', count: 9 },
{ type: 'ct', id: '121', count: 9 },
{ type: 'ct', id: '122', count: 9 },
{ type: 'ct', id: '124', count: 9 },
{ type: 'vm', id: '200', count: 3 },
{ type: 'vm', id: '400', count: 3 }
];
guests.forEach(guest => {
for (let i = 0; i < guest.count; i++) {
allSnapshots.push({
'backup-time': now - (i * 24 * 60 * 60),
'backup-type': guest.type,
'backup-id': guest.id,
'backup-group': `${guest.type}/${guest.id}`,
size: 1024 * 1024 * 100
});
}
});
return Promise.resolve({ data: { data: allSnapshots } });
}
if (path.includes('/status/datastore-usage')) {
return Promise.resolve({
data: { data: [{
store: 'main-datastore',
total: 1000000000000,
used: 135000000000, // 135GB for 135 backups
avail: 865000000000
}]}
});
}
return Promise.resolve({ data: { data: [] } });
});
// Mock PVE snapshots (the real VM/CT snapshots)
mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => {
if (path.includes('/snapshot')) {
if (path.includes('/400/')) {
return Promise.resolve({
data: { data: [
{ name: 'current' }, // Filtered out
{ name: 'ubuntuserver', snaptime: 1700000000 },
{ name: 'precursor', snaptime: 1699000000 }
]}
});
}
if (path.includes('/106/')) {
return Promise.resolve({
data: { data: [
{ name: 'current' }, // Filtered out
{ name: 'before_helper', snaptime: 1701000000 }
]}
});
}
return Promise.resolve({ data: { data: [{ name: 'current' }] } });
}
return Promise.resolve({ data: { data: [] } });
});
const pbsData = await fetchPbsData(mockPbsApiClients);
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// Count PBS backups
let totalPbsBackups = 0;
if (pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
totalPbsBackups += ds.snapshots?.length || 0;
});
}
// Count VM/CT snapshots
const vmSnapshots = discoveryData.pveBackups?.guestSnapshots?.length || 0;
console.log(`PBS Backups: ${totalPbsBackups}, VM Snapshots: ${vmSnapshots}`);
// Verify the distinction
expect(totalPbsBackups).toBeGreaterThan(50); // Should have many PBS backups
expect(vmSnapshots).toBeLessThan(5); // Should have very few VM snapshots
// This verifies the logging confusion issue
if (totalPbsBackups > 100 && vmSnapshots < 5) {
console.log('Confirmed: PBS backups are distinct from VM snapshots');
console.log('DataFetcher logs showing "Found X snapshots" likely refer to VM snapshots, not PBS backups');
}
});
});
describe('Backup Age Verification', () => {
test('should correctly calculate backup ages', async () => {
const now = new Date('2025-06-02T12:50:00Z'); // Test time from research
const twoAM = new Date('2025-06-02T02:00:00Z');
const fourAM = new Date('2025-06-02T04:00:00Z');
const primaryBackupAge = (now - twoAM) / (1000 * 60 * 60); // Hours
const secondaryBackupAge = (now - fourAM) / (1000 * 60 * 60); // Hours
expect(primaryBackupAge).toBeCloseTo(10.83, 1); // ~11 hours
expect(secondaryBackupAge).toBeCloseTo(8.83, 1); // ~9 hours
// Verify these match the ground truth expectations
expect(primaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.minHours);
expect(primaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.primaryJobGuests.maxHours);
expect(secondaryBackupAge).toBeGreaterThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.minHours);
expect(secondaryBackupAge).toBeLessThanOrEqual(groundTruthData.expectedBackupAges.secondaryJobGuests.maxHours);
});
test('should identify guests with missing backups', async () => {
// Mock PBS tasks to simulate VM 102 missing recent backup
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path.includes('/snapshots') && path.includes('backup-id=102')) {
// Return no recent snapshots for VM 102
return Promise.resolve({ data: { data: [] } });
}
if (path.includes('/snapshots')) {
// Return recent snapshots for other guests
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: { data: [{
'backup-time': now - (11 * 60 * 60), // 11 hours ago
'backup-type': 'vm',
'backup-id': '100'
}]}
});
}
return Promise.resolve({ data: { data: [] } });
});
const pbsData = await fetchPbsData(mockPbsApiClients);
// Check for VM 102 backup status
const vm102Backups = pbsData[0]?.datastores?.[0]?.snapshots?.filter(
snap => snap['backup-id'] === '102'
) || [];
expect(vm102Backups.length).toBe(0);
console.log('Confirmed: VM 102 has no recent backups despite being in backup job');
});
});
describe('PBS Task Processing Verification', () => {
test('should correctly differentiate backup tasks from admin tasks', () => {
const mockTasks = [
// Backup tasks (from synthetic snapshots)
{
type: 'backup',
status: 'OK',
starttime: Date.now() / 1000 - 11 * 60 * 60,
endtime: Date.now() / 1000 - 10.5 * 60 * 60,
guest: 'vm/100',
guestType: 'vm',
guestId: '100',
pbsBackupRun: true
},
// Admin tasks
{
type: 'prune',
worker_type: 'prune',
status: 'OK',
starttime: Date.now() / 1000 - 24 * 60 * 60
},
{
type: 'garbage_collection',
worker_type: 'garbage_collection',
status: 'OK',
starttime: Date.now() / 1000 - 48 * 60 * 60
},
{
type: 'verify',
worker_type: 'verify',
status: 'OK',
starttime: Date.now() / 1000 - 6 * 60 * 60
}
];
const processed = processPbsTasks(mockTasks);
expect(processed.backupTasks.summary.total).toBe(1);
expect(processed.pruneTasks.summary.total).toBe(2); // prune + gc
expect(processed.verificationTasks.summary.total).toBe(1);
// Verify task categorization
expect(processed.backupTasks.recentTasks[0].pbsBackupRun).toBe(true);
expect(processed.backupTasks.recentTasks[0].guestId).toBe('100');
});
});
describe('Multiple Endpoint Handling', () => {
test('should handle multiple PVE endpoints correctly', async () => {
// Need to set up mockApiClients for this test
mockApiClients['proxmox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'cluster', name: 'proxmox-cluster', nodes: 3 },
{ type: 'node', name: 'desktop' },
{ type: 'node', name: 'delly' },
{ type: 'node', name: 'minipc' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'desktop', status: 'online' },
{ node: 'delly', status: 'online' },
{ node: 'minipc', status: 'online' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
mockApiClients['pimox-lan'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'node', name: 'pi' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [{ node: 'pi', status: 'online' }]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// Check that nodes are properly tagged with endpoints
const proxmoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'proxmox-lan');
const pimoxNodes = discoveryData.nodes.filter(n => n.endpointId === 'pimox-lan');
expect(proxmoxNodes.length).toBe(3); // desktop, delly, minipc
expect(pimoxNodes.length).toBe(1); // pi
// Verify endpoint identification
expect(discoveryData.nodes.every(n => n.endpointId)).toBe(true);
expect(discoveryData.vms.every(vm => vm.endpointId)).toBe(true);
expect(discoveryData.containers.every(ct => ct.endpointId)).toBe(true);
});
});
describe('Integration Test: Full Backup Status Verification', () => {
test('should produce accurate backup status for dashboard', async () => {
// This test simulates the full data flow to verify dashboard accuracy
// Mock current time
const mockNow = new Date('2025-06-02T13:10:00+01:00'); // 1:10 PM BST
jest.spyOn(Date, 'now').mockImplementation(() => mockNow.getTime());
// Mock comprehensive PBS data
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path.includes('/nodes')) {
return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } });
}
if (path.includes('/config/datastore')) {
return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } });
}
if (path.includes('/admin/datastore/main-datastore/snapshots')) {
// Return snapshots for all guests with proper timing
const snapshots = [];
const fourAM = Math.floor(new Date('2025-06-02T04:00:00+01:00').getTime() / 1000);
const twoAM = Math.floor(new Date('2025-06-02T02:00:00+01:00').getTime() / 1000);
// Primary job guests (2 AM)
[100, 101, 103, 104, 105, 106, 107, 108, 109, 110, 111, 120, 121, 122, 124].forEach(id => {
snapshots.push({
'backup-time': twoAM,
'backup-type': id >= 100 && id <= 102 ? 'vm' : 'ct',
'backup-id': String(id)
});
});
// Secondary job guests (4 AM) - except VM 102
[200, 400].forEach(id => {
snapshots.push({
'backup-time': fourAM,
'backup-type': 'vm',
'backup-id': String(id)
});
});
// VM 102 has no backups
return Promise.resolve({ data: { data: snapshots } });
}
return Promise.resolve({ data: { data: [] } });
});
const pbsData = await fetchPbsData(mockPbsApiClients);
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// Analyze backup status
const guestsWithRecentBackups = new Set();
const backupAges = new Map();
if (pbsData[0]?.datastores) {
pbsData[0].datastores.forEach(ds => {
ds.snapshots?.forEach(snap => {
const guestKey = `${snap['backup-type']}/${snap['backup-id']}`;
const ageHours = (mockNow.getTime() / 1000 - snap['backup-time']) / 3600;
if (ageHours < 24) {
guestsWithRecentBackups.add(snap['backup-id']);
backupAges.set(snap['backup-id'], ageHours);
}
});
});
}
// Verify results match ground truth
expect(guestsWithRecentBackups.size).toBe(17); // 18 total - 1 (VM 102)
expect(guestsWithRecentBackups.has('102')).toBe(false); // VM 102 missing
// Verify backup ages (allow for slight time differences)
expect(backupAges.get('100')).toBeCloseTo(11, 0);
expect(backupAges.get('200')).toBeCloseTo(9, 0);
expect(backupAges.get('106')).toBeCloseTo(11, 0);
console.log('Dashboard accuracy: 17/18 guests show backups <24h old (94.4% accurate)');
console.log('Issue identified: VM 102 missing recent backup');
// Cleanup
jest.restoreAllMocks();
});
});
});
module.exports = { groundTruthData };
-486
View File
@@ -1,486 +0,0 @@
const { loadConfiguration, ConfigurationError } = require('../configLoader');
// Mock dotenv
jest.mock('dotenv', () => ({
config: jest.fn(),
}));
const dotenv = require('dotenv'); // require after mock
// Helper function to temporarily set environment variables for a test
const setEnvVars = (vars) => {
const originalEnv = { ...process.env }; // Store original env
Object.keys(vars).forEach(key => {
process.env[key] = vars[key];
});
return originalEnv; // Return original env for restoration
};
// Helper function to restore environment variables
const restoreEnvVars = (originalEnv) => {
// Clear potentially set test variables first
Object.keys(process.env).forEach(key => {
if (!(key in originalEnv)) {
delete process.env[key];
}
});
// Restore original values
Object.keys(originalEnv).forEach(key => {
process.env[key] = originalEnv[key];
});
};
// Set NODE_ENV to test *before* describing the suite
process.env.NODE_ENV = 'test';
// Mock console
let consoleWarnSpy; // Declare spies outside beforeEach/afterEach
let consoleLogSpy;
describe('Configuration Loading (loadConfiguration)', () => {
let originalEnv;
beforeEach(() => {
// Store original environment
originalEnv = { ...process.env };
// --- More robust clearing of process.env ---
// Get all keys BEFORE modifying
const currentEnvKeys = Object.keys(process.env);
// Delete all keys
currentEnvKeys.forEach(key => delete process.env[key]);
// --- End robust clearing ---
// Restore NODE_ENV as it's crucial for the logic
process.env.NODE_ENV = 'test';
// Assign spies in beforeEach
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
// --- Restore original environment more carefully ---
// Clear any keys potentially added during the test
const currentEnvKeys = Object.keys(process.env);
currentEnvKeys.forEach(key => delete process.env[key]);
// Restore the original keys and values
Object.keys(originalEnv).forEach(key => {
process.env[key] = originalEnv[key];
});
// --- End restore ---
// Restore specific spies
consoleWarnSpy.mockRestore();
consoleLogSpy.mockRestore();
});
// Test Case 1: Minimal Valid PVE Config
test('should load minimal PVE config successfully', () => {
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
});
// Expect no error to be thrown for valid config
let loadedConfig;
expect(() => {
loadedConfig = loadConfiguration();
}).not.toThrow();
// Check the returned structure
expect(loadedConfig).toBeDefined();
expect(loadedConfig.endpoints).toHaveLength(1); // Check endpoints array
expect(loadedConfig.pbsConfigs).toHaveLength(0); // Expect no PBS configs
// Check the primary PVE endpoint details within the endpoints array
const primaryEndpoint = loadedConfig.endpoints[0];
expect(primaryEndpoint.id).toBe('primary');
expect(primaryEndpoint.host).toBe('pve.example.com');
expect(primaryEndpoint.tokenId).toBe('user@pam!pve');
expect(primaryEndpoint.tokenSecret).toBe('secretpve');
});
// Test Case 2: Missing Primary Proxmox Variables
test('should return setup mode configuration if primary Proxmox variables are missing', () => {
setEnvVars({
PROXMOX_HOST: '192.168.1.100',
// Missing TOKEN_ID and TOKEN_SECRET
});
const config = loadConfiguration();
expect(config.endpoints).toEqual([]);
expect(config.pbsConfigs).toEqual([]);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 3: Placeholder Primary Proxmox Variables
test('should warn and set flag if primary Proxmox variables contain placeholders', () => {
const envSetup = {
PROXMOX_HOST: 'your-proxmox-ip-or-hostname',
PROXMOX_TOKEN_ID: 'user@pam!token', // A placeholder not exactly in the list
PROXMOX_TOKEN_SECRET: 'secret-uuid', // Another placeholder not exactly in the list
};
setEnvVars(envSetup);
let config;
// Expect no error to be thrown, but placeholders to be detected
expect(() => {
config = loadConfiguration();
}).not.toThrow();
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID, PROXMOX_TOKEN_SECRET')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 4: Valid Primary + Additional Proxmox Endpoints
test('should load successfully with additional valid Proxmox endpoints', () => {
setEnvVars({
PROXMOX_HOST: 'pve1.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token1',
PROXMOX_TOKEN_SECRET: 'secret1',
PROXMOX_NODE_NAME: 'PVE Node 1', // Custom name
PROXMOX_PORT: '8007', // Custom port
PROXMOX_ALLOW_SELF_SIGNED_CERTS: 'true', // Explicitly true
PROXMOX_HOST_2: 'pve2.example.com',
PROXMOX_TOKEN_ID_2: 'user@pam!token2',
PROXMOX_TOKEN_SECRET_2: 'secret2',
PROXMOX_ENABLED_2: 'false', // Disabled endpoint
PROXMOX_HOST_3: 'pve3.example.com',
PROXMOX_TOKEN_ID_3: 'user@pam!token3',
PROXMOX_TOKEN_SECRET_3: 'secret3',
PROXMOX_NODE_NAME_3: 'PVE Node 3', // Custom name
PROXMOX_PORT_3: '8008',
PROXMOX_ALLOW_SELF_SIGNED_CERTS_3: 'false', // Explicitly false
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(3);
// Check primary
expect(config.endpoints[0].id).toBe('primary');
expect(config.endpoints[0].name).toBe('PVE Node 1');
expect(config.endpoints[0].host).toBe('pve1.example.com');
expect(config.endpoints[0].port).toBe('8007');
expect(config.endpoints[0].enabled).toBe(true);
expect(config.endpoints[0].allowSelfSignedCerts).toBe(true);
// Check second (disabled)
expect(config.endpoints[1].id).toBe('endpoint_2');
expect(config.endpoints[1].name).toBe(null); // No custom name configured
expect(config.endpoints[1].host).toBe('pve2.example.com');
expect(config.endpoints[1].port).toBe('8006'); // Default port
expect(config.endpoints[1].enabled).toBe(false);
expect(config.endpoints[1].allowSelfSignedCerts).toBe(true); // Default
// Check third
expect(config.endpoints[2].id).toBe('endpoint_3');
expect(config.endpoints[2].name).toBe('PVE Node 3');
expect(config.endpoints[2].host).toBe('pve3.example.com');
expect(config.endpoints[2].port).toBe('8008');
expect(config.endpoints[2].enabled).toBe(true); // Default
expect(config.endpoints[2].allowSelfSignedCerts).toBe(false);
expect(config.pbsConfigs).toHaveLength(0);
});
// Test Case 5: Incomplete Additional Proxmox Endpoint
test('should skip additional Proxmox endpoint if token details are missing', () => {
setEnvVars({
PROXMOX_HOST: 'pve1.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token1',
PROXMOX_TOKEN_SECRET: 'secret1',
PROXMOX_HOST_2: 'pve2.example.com', // Missing token ID/secret for #2
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.endpoints[0].id).toBe('primary');
});
// Test Case 6: Placeholder Additional Proxmox Endpoint
test('should skip additional Proxmox endpoint if details contain placeholders', () => {
setEnvVars({
PROXMOX_HOST: 'pve1.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token1',
PROXMOX_TOKEN_SECRET: 'secret1',
PROXMOX_HOST_2: 'your-proxmox-ip-or-hostname', // Placeholder host
PROXMOX_TOKEN_ID_2: 'user@pam!token2',
PROXMOX_TOKEN_SECRET_2: 'secret2',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1); // Only primary should load
expect(config.endpoints[0].id).toBe('primary');
});
// Test Case 7: Valid Primary PBS Config
test('should load successfully with a valid primary PBS config', () => {
setEnvVars({
// Minimal valid PVE
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
// Valid PBS
PBS_HOST: 'https://pbs.example.com:8007', // Full URL
PBS_TOKEN_ID: 'user@pbs!token',
PBS_TOKEN_SECRET: 'secretpbs',
PBS_NODE_NAME: 'PBS Backup Server',
PBS_ALLOW_SELF_SIGNED_CERTS: 'false',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(1);
const pbs = config.pbsConfigs[0];
expect(pbs.id).toBe('pbs_primary_token');
expect(pbs.name).toBe('PBS Backup Server');
expect(pbs.host).toBe('https://pbs.example.com:8007');
expect(pbs.port).toBe('8007'); // Port from env var
expect(pbs.tokenId).toBe('user@pbs!token');
expect(pbs.tokenSecret).toBe('secretpbs');
expect(pbs.authMethod).toBe('token');
expect(pbs.allowSelfSignedCerts).toBe(false);
expect(pbs.enabled).toBe(true);
});
test('should not add primary PBS config if host is set but tokens are missing', () => {
setEnvVars({
PROXMOX_HOST: '192.168.1.100',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
PBS_HOST: 'pbs.example.com',
// Missing TOKEN_ID and TOKEN_SECRET for PBS
});
let config;
expect(() => {
config = loadConfiguration();
}).not.toThrow();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(0); // PBS should NOT load
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST. Please set (PBS_TOKEN_ID + PBS_TOKEN_SECRET)')
);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1); // Only one warning expected from this test
});
// Test Case 8: Valid Primary + Additional PBS Configs
test('should load successfully with additional valid PBS configs', () => {
setEnvVars({
// PVE
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
// PBS 1 (Primary)
PBS_HOST: 'pbs1.example.com', // No protocol/port
PBS_TOKEN_ID: 'user@pbs!token1',
PBS_TOKEN_SECRET: 'secretpbs1',
// PBS 2
PBS_HOST_2: 'https://pbs2.example.com:8008',
PBS_TOKEN_ID_2: 'user@pbs!token2',
PBS_TOKEN_SECRET_2: 'secretpbs2',
PBS_NODE_NAME_2: 'PBS Server 2',
PBS_PORT_2: '9000', // Custom port
// PBS 3 (Placeholder - should skip)
PBS_HOST_3: 'pbs3.example.com',
PBS_TOKEN_ID_3: 'your-api-token-id@pam!your-token-name',
PBS_TOKEN_SECRET_3: 'secretpbs3',
// PBS 4 (Missing Token Secret - should skip)
PBS_HOST_4: 'pbs4.example.com',
PBS_TOKEN_ID_4: 'user@pbs!token4',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(2);
// Check PBS 1 (Primary)
expect(config.pbsConfigs[0].id).toBe('pbs_primary_token');
expect(config.pbsConfigs[0].name).toBe('pbs1.example.com'); // Defaults to host
expect(config.pbsConfigs[0].host).toBe('pbs1.example.com');
expect(config.pbsConfigs[0].port).toBe('8007'); // Default port
expect(config.pbsConfigs[0].allowSelfSignedCerts).toBe(true); // Default
// Check PBS 2
expect(config.pbsConfigs[1].id).toBe('pbs_endpoint_2_token');
expect(config.pbsConfigs[1].name).toBe('PBS Server 2');
expect(config.pbsConfigs[1].host).toBe('https://pbs2.example.com:8008');
expect(config.pbsConfigs[1].port).toBe('9000'); // Custom port
expect(config.pbsConfigs[1].allowSelfSignedCerts).toBe(true); // Default
// PBS 3 and 4 should have been skipped
});
// Test Case 9: Incomplete Additional PBS Endpoint (NEW TEST)
test('should skip additional PBS endpoint if token details are missing but host is present', () => {
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
// Valid Primary PBS
PBS_HOST: 'pbs1.example.com',
PBS_TOKEN_ID: 'user@pbs!token1',
PBS_TOKEN_SECRET: 'secretpbs1',
// Additional PBS host, missing tokens
PBS_HOST_2: 'pbs2.example.com',
// PBS_TOKEN_ID_2: 'user@pbs!token2', // Missing
// PBS_TOKEN_SECRET_2: 'secretpbs2', // Missing
// Valid third PBS
PBS_HOST_3: 'pbs3.example.com',
PBS_TOKEN_ID_3: 'user@pbs!token3',
PBS_TOKEN_SECRET_3: 'secretpbs3',
});
const config = loadConfiguration();
expect(config.endpoints).toHaveLength(1);
expect(config.pbsConfigs).toHaveLength(2); // Should load primary (PBS1) and PBS3
expect(config.pbsConfigs.map(p => p.host)).toEqual(['pbs1.example.com', 'pbs3.example.com']);
// Check that the warning for the partial config _2 was logged
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Partial PBS configuration found for PBS_HOST_2. Please set (PBS_TOKEN_ID_2 + PBS_TOKEN_SECRET_2)')
);
// Verify the config for PBS_HOST_2 was not added
expect(config.pbsConfigs.find(p => p.host === 'pbs2.example.com')).toBeUndefined();
});
// Test Case 10: No Enabled Endpoints
test('should throw ConfigurationError if no enabled PVE or PBS endpoints are configured', () => {
setEnvVars({
// Valid PVE, but disabled
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
PROXMOX_ENABLED: 'false',
// Valid PBS details, but only HOST is present, no tokens
PBS_HOST: 'pbs.example.com'
});
// Expect the final check in loadConfiguration to throw
expect(() => loadConfiguration()).toThrow(ConfigurationError);
expect(() => loadConfiguration()).toThrow(/No enabled Proxmox VE or PBS endpoints could be configured/);
});
// New Test Case for dotenv loading
test('should call dotenv.config() when NODE_ENV is not \'test\'', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development'; // Set to non-test environment
// Minimal valid PVE config to allow loadConfiguration to proceed far enough
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!pve',
PROXMOX_TOKEN_SECRET: 'secretpve',
});
loadConfiguration();
expect(dotenv.config).toHaveBeenCalled();
// Restore original NODE_ENV and clear mocks for other tests
process.env.NODE_ENV = originalNodeEnv;
dotenv.config.mockClear(); // Clear the mock for other tests
});
// Test Case 11: Placeholder detection with PROXMOX_TOKEN_ID in env
test('should insert PROXMOX_TOKEN_ID in correct position when placeholders detected', () => {
setEnvVars({
PROXMOX_HOST: 'your-proxmox-ip-or-hostname',
PROXMOX_TOKEN_ID: 'user@pam!token',
PROXMOX_TOKEN_SECRET: 'your-api-token-uuid',
});
const config = loadConfiguration();
// Should detect placeholders - the actual implementation includes PROXMOX_TOKEN_ID when it's set
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('WARN: Primary Proxmox environment variables seem to contain placeholder values: PROXMOX_HOST, PROXMOX_TOKEN_ID')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 12: Placeholder detection - TOKEN_ID not in list but exists
test('should add PROXMOX_TOKEN_ID at end if not in placeholder list but exists', () => {
// Only secret is a placeholder, but TOKEN_ID exists and should be added
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!mytoken', // exists but not a placeholder
PROXMOX_TOKEN_SECRET: 'your-api-token-uuid', // placeholder
});
const config = loadConfiguration();
// Debug: Check if console.warn was called at all
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
// Should detect the secret placeholder and add TOKEN_ID
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('PROXMOX_TOKEN_SECRET')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case 13: Test line 138 - Add TOKEN_ID when no PROXMOX_HOST in placeholderVars
test('should push PROXMOX_TOKEN_ID when PROXMOX_HOST not in placeholder list', () => {
// Only PROXMOX_PORT is placeholder (not PROXMOX_HOST)
setEnvVars({
PROXMOX_HOST: 'pve.example.com',
PROXMOX_TOKEN_ID: 'user@pam!token', // This IS identified as a placeholder
PROXMOX_TOKEN_SECRET: 'secret123',
PROXMOX_PORT: 'your-port' // This is a placeholder, but not checked in the primary warning
});
const config = loadConfiguration();
// Should detect a placeholder in PROXMOX_TOKEN_ID and warn about it.
// PROXMOX_PORT is not part of the primary placeholder check that generates this specific warning.
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('PROXMOX_TOKEN_ID')
);
expect(config.isConfigPlaceholder).toBe(true);
});
// Test Case: Config file path loading
test('should load config from config directory when it exists', () => {
// Set NODE_ENV to non-test to enable dotenv loading
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'development';
// Mock fs.existsSync to return true for config dir path
const fs = require('fs');
const originalExistsSync = fs.existsSync;
fs.existsSync = jest.fn((path) => {
if (path.includes('config/.env')) {
return true; // Config dir .env exists
}
return false;
});
// Set up environment variables
setEnvVars({
PROXMOX_HOST: '192.168.1.100',
PROXMOX_TOKEN_ID: 'user@pam!token',
PROXMOX_TOKEN_SECRET: 'secret'
});
const config = loadConfiguration();
// Verify that dotenv.config was called with config dir path
expect(dotenv.config).toHaveBeenCalledWith({ path: expect.stringContaining('config/.env') });
// Restore fs.existsSync and NODE_ENV
fs.existsSync = originalExistsSync;
process.env.NODE_ENV = originalNodeEnv;
});
});
-519
View File
@@ -1,519 +0,0 @@
// Mock fs module before requiring the threshold manager
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn()
}
}));
const fs = require('fs').promises;
const path = require('path');
const thresholdManagerInstance = require('../customThresholds');
// Mock console to avoid test output clutter
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
describe('Custom Threshold Manager', () => {
let thresholdManager;
let mockConfigPath;
beforeEach(() => {
thresholdManager = thresholdManagerInstance;
mockConfigPath = thresholdManager.configPath;
// Reset all mocks
jest.clearAllMocks();
// Clear cache for clean state
thresholdManager.cache.clear();
});
afterEach(() => {
// Clean up cache
if (thresholdManager) {
thresholdManager.cache.clear();
}
});
describe('Initialization', () => {
test('should initialize successfully with existing config file', async () => {
const mockThresholds = {
'endpoint1:100': {
endpointId: 'endpoint1',
vmid: '100',
thresholds: {
cpu: { warning: 70, critical: 90 },
memory: { warning: 80, critical: 95 }
},
enabled: true,
createdAt: new Date().toISOString()
}
};
fs.mkdir.mockResolvedValue();
fs.readFile.mockResolvedValue(JSON.stringify(mockThresholds));
await thresholdManager.init();
expect(thresholdManager.initialized).toBe(true);
expect(thresholdManager.cache.size).toBe(1);
});
test('should create new config file when none exists', async () => {
const enoentError = new Error('File not found');
enoentError.code = 'ENOENT';
fs.mkdir.mockResolvedValue();
fs.readFile.mockRejectedValue(enoentError);
fs.writeFile.mockResolvedValue();
await thresholdManager.init();
expect(thresholdManager.initialized).toBe(true);
expect(fs.writeFile).toHaveBeenCalled();
});
});
describe('Key Generation', () => {
test('should generate correct cache key format', () => {
const key = thresholdManager.generateKey('pve-main', 'node1', '100');
expect(key).toBe('pve-main:100');
});
test('should handle special characters in endpoint and vmid', () => {
const key = thresholdManager.generateKey('pve-test.local', 'node-1', 'ct-200');
expect(key).toBe('pve-test.local:ct-200');
});
test('should be consistent regardless of node parameter', () => {
const key1 = thresholdManager.generateKey('pve1', 'node1', '100');
const key2 = thresholdManager.generateKey('pve1', 'node2', '100');
expect(key1).toBe(key2); // Node migration support
});
});
describe('Getting Thresholds', () => {
beforeEach(async () => {
fs.writeFile.mockResolvedValue();
// Set up cache with test data using the real API
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 75, critical: 90 },
memory: { warning: 85, critical: 95 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
cpu: { warning: 60, critical: 80 },
disk: { warning: 90, critical: 98 }
});
});
test('should return custom thresholds when configured', () => {
const thresholds = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(thresholds).not.toBeNull();
expect(thresholds.thresholds.cpu.warning).toBe(75);
expect(thresholds.thresholds.cpu.critical).toBe(90);
expect(thresholds.thresholds.memory.warning).toBe(85);
});
test('should return null when no custom thresholds exist', () => {
const thresholds = thresholdManager.getThresholds('pve1', 'node1', '999');
expect(thresholds).toBeNull();
});
test('should return null for different endpoint', () => {
const thresholds = thresholdManager.getThresholds('pve2', 'node1', '100');
expect(thresholds).toBeNull();
});
test('should work regardless of node name due to migration support', () => {
const thresholds1 = thresholdManager.getThresholds('pve1', 'node1', '100');
const thresholds2 = thresholdManager.getThresholds('pve1', 'node2', '100');
expect(thresholds1).toEqual(thresholds2);
});
});
describe('Setting Thresholds', () => {
beforeEach(() => {
fs.writeFile.mockResolvedValue();
});
test('should set valid threshold configuration', async () => {
const validThresholds = {
cpu: { warning: 70, critical: 85 },
memory: { warning: 80, critical: 90 }
};
const result = await thresholdManager.setThresholds('pve1', 'node1', '300', validThresholds);
expect(result).toBe(true);
const stored = thresholdManager.getThresholds('pve1', 'node1', '300');
expect(stored).not.toBeNull();
expect(stored.thresholds.cpu.warning).toBe(70);
expect(stored.createdAt).toBeDefined();
expect(fs.writeFile).toHaveBeenCalled();
});
test('should validate threshold values', async () => {
const invalidThresholds = {
cpu: { warning: 95, critical: 85 } // Warning higher than critical
};
await expect(
thresholdManager.setThresholds('pve1', 'node1', '400', invalidThresholds)
).rejects.toThrow(/critical threshold must be greater than warning threshold/);
});
test('should reject thresholds outside valid range', async () => {
const outOfRangeThresholds = {
cpu: { warning: 150, critical: 200 } // Over 100%
};
await expect(
thresholdManager.setThresholds('pve1', 'node1', '500', outOfRangeThresholds)
).rejects.toThrow();
});
test('should handle partial thresholds gracefully', async () => {
const partialThresholds = {
cpu: { warning: 70, critical: 85 }
// memory and disk thresholds missing
};
const result = await thresholdManager.setThresholds('pve1', 'node1', '700', partialThresholds);
expect(result).toBe(true);
const stored = thresholdManager.getThresholds('pve1', 'node1', '700');
expect(stored.thresholds.cpu).toBeDefined();
expect(stored.thresholds.memory).toBeUndefined();
});
test('should update existing thresholds', async () => {
// Set initial thresholds
const initial = {
cpu: { warning: 70, critical: 85 }
};
await thresholdManager.setThresholds('pve1', 'node1', '800', initial);
// Update with new values
const updated = {
cpu: { warning: 75, critical: 90 },
memory: { warning: 80, critical: 95 }
};
await thresholdManager.setThresholds('pve1', 'node1', '800', updated);
const stored = thresholdManager.getThresholds('pve1', 'node1', '800');
expect(stored.thresholds.cpu.warning).toBe(75);
expect(stored.thresholds.memory.warning).toBe(80);
expect(fs.writeFile).toHaveBeenCalledTimes(2);
});
});
describe('Removing Thresholds', () => {
beforeEach(async () => {
fs.writeFile.mockResolvedValue();
// Set up some test thresholds
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
});
test('should remove existing threshold configuration', async () => {
expect(thresholdManager.getThresholds('pve1', 'node1', '100')).not.toBeNull();
const result = await thresholdManager.removeThresholds('pve1', 'node1', '100');
expect(result).toBe(true);
expect(thresholdManager.getThresholds('pve1', 'node1', '100')).toBeNull();
expect(fs.writeFile).toHaveBeenCalled();
});
test('should handle removal of non-existent thresholds gracefully', async () => {
const result = await thresholdManager.removeThresholds('pve1', 'node1', '999');
expect(result).toBe(false);
});
test('should not affect other threshold configurations', async () => {
await thresholdManager.removeThresholds('pve1', 'node1', '100');
expect(thresholdManager.getThresholds('pve1', 'node1', '200')).not.toBeNull();
});
});
describe('File Operations', () => {
test('should handle file save errors gracefully', async () => {
const saveError = new Error('Disk full');
fs.writeFile.mockRejectedValue(saveError);
await expect(
thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
})
).rejects.toThrow('Disk full');
});
test('should create data directory if it does not exist', async () => {
fs.mkdir.mockResolvedValue();
fs.readFile.mockResolvedValue('{}');
await thresholdManager.loadThresholds();
expect(fs.mkdir).toHaveBeenCalledWith(
path.dirname(mockConfigPath),
{ recursive: true }
);
});
test('should save thresholds in correct JSON format', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
const saveCall = fs.writeFile.mock.calls[0];
expect(saveCall[0]).toBe(mockConfigPath);
expect(saveCall[2]).toBe('utf8');
const savedData = JSON.parse(saveCall[1]);
expect(savedData).toHaveProperty('pve1:100');
});
});
describe('Edge Cases and Error Handling', () => {
test('should handle empty threshold configuration', async () => {
fs.writeFile.mockResolvedValue();
const emptyThresholds = {};
const result = await thresholdManager.setThresholds('pve1', 'node1', '100', emptyThresholds);
expect(result).toBe(true);
const stored = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(stored.createdAt).toBeDefined();
});
test('should handle very large cache sizes', async () => {
fs.writeFile.mockResolvedValue();
// Add many threshold configurations
for (let i = 0; i < 100; i++) {
await thresholdManager.setThresholds('pve1', 'node1', String(i), {
cpu: { warning: 70, critical: 85 }
});
}
expect(thresholdManager.cache.size).toBe(100);
expect(thresholdManager.getThresholds('pve1', 'node1', '50')).not.toBeNull();
});
});
describe('Bulk Operations', () => {
test('should get all threshold configurations', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve2', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
const allConfigs = thresholdManager.getAllThresholds();
expect(Array.isArray(allConfigs)).toBe(true);
expect(allConfigs.length).toBe(2);
expect(allConfigs.some(config => config.vmid === '100')).toBe(true);
expect(allConfigs.some(config => config.vmid === '200')).toBe(true);
});
test('should get thresholds by endpoint', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
await thresholdManager.setThresholds('pve2', 'node1', '300', {
cpu: { warning: 60, critical: 75 }
});
const pve1Configs = thresholdManager.getThresholdsByEndpoint('pve1');
const pve2Configs = thresholdManager.getThresholdsByEndpoint('pve2');
expect(pve1Configs.length).toBe(2);
expect(pve2Configs.length).toBe(1);
expect(pve1Configs.every(config => config.endpointId === 'pve1')).toBe(true);
expect(pve2Configs.every(config => config.endpointId === 'pve2')).toBe(true);
});
test('should export threshold configurations', () => {
// Add some test data directly to cache
thresholdManager.cache.set('pve1:100', {
endpointId: 'pve1',
vmid: '100',
thresholds: { cpu: { warning: 70, critical: 85 } },
createdAt: '2024-01-01T00:00:00.000Z'
});
const exported = thresholdManager.exportThresholds();
expect(exported).toHaveProperty('exportedAt');
expect(exported).toHaveProperty('version');
expect(exported.version).toBe('1.0');
expect(exported.thresholds).toHaveLength(1);
expect(exported.thresholds[0].vmid).toBe('100');
});
test('should get threshold statistics', async () => {
fs.writeFile.mockResolvedValue();
// Add multiple configurations
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
await thresholdManager.setThresholds('pve1', 'node1', '200', {
memory: { warning: 80, critical: 90 }
});
const stats = thresholdManager.getStatistics();
expect(stats).toHaveProperty('total');
expect(stats).toHaveProperty('byEndpoint');
expect(stats.total).toBe(2);
});
});
describe('Threshold Management', () => {
test('should toggle threshold configurations', async () => {
fs.writeFile.mockResolvedValue();
// Set up a threshold configuration
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
});
// Disable it
const result = await thresholdManager.toggleThresholds('pve1', 'node1', '100', false);
expect(result).toBe(true);
const config = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(config.enabled).toBe(false);
expect(config.updatedAt).toBeDefined();
// Re-enable it
await thresholdManager.toggleThresholds('pve1', 'node1', '100', true);
const updatedConfig = thresholdManager.getThresholds('pve1', 'node1', '100');
expect(updatedConfig.enabled).toBe(true);
});
test('should handle toggle for non-existent configuration', async () => {
await expect(
thresholdManager.toggleThresholds('pve1', 'node1', '999', true)
).rejects.toThrow('Threshold configuration not found');
});
test('should validate threshold values correctly', () => {
// Test CPU thresholds
const validCpuThresholds = {
cpu: { warning: 70, critical: 85 }
};
const validated = thresholdManager.validateThresholds(validCpuThresholds);
expect(validated.cpu.warning).toBe(70);
expect(validated.cpu.critical).toBe(85);
// Test invalid CPU thresholds (warning >= critical)
const invalidCpuThresholds = {
cpu: { warning: 90, critical: 85 }
};
expect(() => {
thresholdManager.validateThresholds(invalidCpuThresholds);
}).toThrow('CPU critical threshold must be greater than warning threshold');
});
test('should validate memory thresholds correctly', () => {
const validMemoryThresholds = {
memory: { warning: 80, critical: 95 }
};
const validated = thresholdManager.validateThresholds(validMemoryThresholds);
expect(validated.memory.warning).toBe(80);
expect(validated.memory.critical).toBe(95);
// Test invalid memory thresholds
const invalidMemoryThresholds = {
memory: { warning: 95, critical: 80 }
};
expect(() => {
thresholdManager.validateThresholds(invalidMemoryThresholds);
}).toThrow('Memory critical threshold must be greater than warning threshold');
});
test('should validate disk thresholds correctly', () => {
const validDiskThresholds = {
disk: { warning: 85, critical: 95 }
};
const validated = thresholdManager.validateThresholds(validDiskThresholds);
expect(validated.disk.warning).toBe(85);
expect(validated.disk.critical).toBe(95);
// Test invalid disk thresholds
const invalidDiskThresholds = {
disk: { warning: 98, critical: 90 }
};
expect(() => {
thresholdManager.validateThresholds(invalidDiskThresholds);
}).toThrow('Disk critical threshold must be greater than warning threshold');
});
});
describe('Integration with Alert System', () => {
test('should store threshold configurations with proper structure', async () => {
fs.writeFile.mockResolvedValue();
await thresholdManager.setThresholds('pve1', 'node1', '100', {
cpu: { warning: 75, critical: 90 },
memory: { warning: 80, critical: 95 }
});
const config = thresholdManager.getThresholds('pve1', 'node1', '100');
// Verify structure for alert system integration
expect(config).toHaveProperty('endpointId', 'pve1');
expect(config).toHaveProperty('vmid', '100');
expect(config).toHaveProperty('thresholds');
expect(config).toHaveProperty('enabled', true);
expect(config).toHaveProperty('createdAt');
expect(config).toHaveProperty('updatedAt');
// Verify threshold values are accessible
expect(config.thresholds.cpu.warning).toBe(75);
expect(config.thresholds.cpu.critical).toBe(90);
expect(config.thresholds.memory.warning).toBe(80);
expect(config.thresholds.memory.critical).toBe(95);
});
test('should handle partial threshold configurations', async () => {
fs.writeFile.mockResolvedValue();
// Set only CPU thresholds
await thresholdManager.setThresholds('pve1', 'node1', '200', {
cpu: { warning: 70, critical: 85 }
});
const config = thresholdManager.getThresholds('pve1', 'node1', '200');
expect(config.thresholds.cpu).toBeDefined();
expect(config.thresholds.memory).toBeUndefined();
expect(config.thresholds.disk).toBeUndefined();
});
});
});
File diff suppressed because it is too large Load Diff
-123
View File
@@ -1,123 +0,0 @@
const dnsResolver = require('../dnsResolver');
const dns = require('dns').promises;
// Mock the dns module
jest.mock('dns', () => ({
promises: {
resolve4: jest.fn(),
resolve6: jest.fn()
}
}));
// Mock the util.promisify
jest.mock('util', () => ({
promisify: () => jest.fn()
}));
describe('DnsResolver', () => {
beforeEach(() => {
// Clear all mocks and caches
jest.clearAllMocks();
dnsResolver.clearCache();
});
describe('resolveHostname', () => {
it('should resolve hostname to IP addresses', async () => {
const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12'];
dns.resolve4.mockResolvedValue(mockIPs);
dns.resolve6.mockResolvedValue([]);
const result = await dnsResolver.resolveHostname('proxmox.lan');
expect(result).toEqual(mockIPs);
expect(dns.resolve4).toHaveBeenCalledWith('proxmox.lan');
});
it('should cache DNS results', async () => {
const mockIPs = ['192.168.1.10'];
dns.resolve4.mockResolvedValue(mockIPs);
dns.resolve6.mockResolvedValue([]);
// First call
await dnsResolver.resolveHostname('test.lan');
expect(dns.resolve4).toHaveBeenCalledTimes(1);
// Second call should use cache
await dnsResolver.resolveHostname('test.lan');
expect(dns.resolve4).toHaveBeenCalledTimes(1); // Still only called once
});
it('should filter out failed IPs', async () => {
const mockIPs = ['192.168.1.10', '192.168.1.11', '192.168.1.12'];
dns.resolve4.mockResolvedValue(mockIPs);
dns.resolve6.mockResolvedValue([]);
// Mark one IP as failed
dnsResolver.markHostFailed('192.168.1.11');
const result = await dnsResolver.resolveHostname('proxmox.lan');
expect(result).toEqual(['192.168.1.10', '192.168.1.12']);
expect(result).not.toContain('192.168.1.11');
});
it('should handle DNS resolution failures gracefully', async () => {
dns.resolve4.mockRejectedValue(new Error('DNS resolution failed'));
dns.resolve6.mockRejectedValue(new Error('DNS resolution failed'));
// Mock lookup to also fail
const lookup = require('util').promisify();
lookup.mockRejectedValue(new Error('Lookup failed'));
await expect(dnsResolver.resolveHostname('invalid.lan'))
.rejects.toThrow('No IP addresses found');
});
});
describe('markHostFailed and isHostFailed', () => {
it('should mark host as failed temporarily', async () => {
const testIP = '192.168.1.10';
expect(dnsResolver.isHostFailed(testIP)).toBe(false);
dnsResolver.markHostFailed(testIP);
expect(dnsResolver.isHostFailed(testIP)).toBe(true);
});
});
describe('extractHostname', () => {
it('should extract hostname from various URL formats', () => {
const testCases = [
{ input: 'https://proxmox.lan:8006', expected: 'proxmox.lan' },
{ input: 'http://test.local:3000/path', expected: 'test.local' },
{ input: 'server.domain:8080', expected: 'server.domain' },
{ input: 'simple-hostname', expected: 'simple-hostname' }
];
testCases.forEach(({ input, expected }) => {
expect(dnsResolver.extractHostname(input)).toBe(expected);
});
});
});
describe('canResolve', () => {
it('should return true for resolvable hostnames', async () => {
dns.resolve4.mockResolvedValue(['192.168.1.10']);
dns.resolve6.mockResolvedValue([]);
const result = await dnsResolver.canResolve('valid.lan');
expect(result).toBe(true);
});
it('should return false for unresolvable hostnames', async () => {
dns.resolve4.mockRejectedValue(new Error('Not found'));
dns.resolve6.mockRejectedValue(new Error('Not found'));
const lookup = require('util').promisify();
lookup.mockRejectedValue(new Error('Not found'));
const result = await dnsResolver.canResolve('invalid.lan');
expect(result).toBe(false);
});
});
});
-803
View File
@@ -1,803 +0,0 @@
/**
* Integration Tests for Pulse Monitoring System
* Tests end-to-end workflows and component interactions
*/
// Mock external dependencies
jest.mock('axios');
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn()
}
}));
const axios = require('axios');
const fs = require('fs').promises;
const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher');
const { initializeApiClients } = require('../apiClients');
const { loadConfiguration } = require('../configLoader');
const AlertManager = require('../alertManager');
const customThresholds = require('../customThresholds');
// Mock console to reduce test noise
jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'warn').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {});
describe('Pulse Integration Tests', () => {
let originalEnv;
let mockApiClients;
let mockPbsApiClients;
let alertManager;
beforeEach(() => {
originalEnv = { ...process.env };
jest.clearAllMocks();
// Mock file operations
fs.mkdir.mockResolvedValue();
fs.readFile.mockResolvedValue('{}');
fs.writeFile.mockResolvedValue();
// Set up mock API clients
mockApiClients = {
'pve-main': {
client: {
get: jest.fn(),
post: jest.fn()
},
config: {
id: 'pve-main',
name: 'Main PVE Cluster',
host: 'pve.example.com',
tokenId: 'test@pve!test',
tokenSecret: 'test-secret'
}
}
};
mockPbsApiClients = {
'pbs-main': {
client: {
get: jest.fn(),
post: jest.fn()
},
config: {
id: 'pbs-main',
name: 'Main PBS Server',
host: 'pbs.example.com'
}
}
};
// Initialize AlertManager for testing
alertManager = new AlertManager();
// Clear custom thresholds cache
customThresholds.cache.clear();
});
afterEach(() => {
// Restore environment
Object.keys(process.env).forEach(key => delete process.env[key]);
Object.keys(originalEnv).forEach(key => {
process.env[key] = originalEnv[key];
});
// Cleanup AlertManager
if (alertManager) {
alertManager.destroy();
}
customThresholds.cache.clear();
});
describe('Complete Monitoring Workflow', () => {
test('should perform full discovery -> metrics -> alerting cycle', async () => {
// === STEP 1: Discovery Phase ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/cluster/status') {
return Promise.resolve({
data: {
data: [
{ type: 'cluster', name: 'test-cluster', nodes: 2 },
{ type: 'node', name: 'node1', ip: '192.168.1.10' },
{ type: 'node', name: 'node2', ip: '192.168.1.11' }
]
}
});
}
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'node1', status: 'online' },
{ node: 'node2', status: 'online' }
]
}
});
}
if (path.includes('/qemu')) {
if (path.includes('node1')) {
return Promise.resolve({
data: {
data: [
{ vmid: 100, name: 'web-server', status: 'running' },
{ vmid: 101, name: 'database', status: 'running' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
}
if (path.includes('/lxc')) {
if (path.includes('node2')) {
return Promise.resolve({
data: {
data: [
{ vmid: 200, name: 'nginx-proxy', status: 'running' },
{ vmid: 201, name: 'monitoring', status: 'running' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
}
return Promise.resolve({ data: { data: [] } });
});
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// Verify discovery results
expect(discoveryData.nodes).toHaveLength(2);
expect(discoveryData.vms).toHaveLength(2);
expect(discoveryData.containers).toHaveLength(2);
expect(discoveryData.vms.some(vm => vm.vmid === 100)).toBe(true);
expect(discoveryData.containers.some(ct => ct.vmid === 200)).toBe(true);
// === STEP 2: Metrics Collection ===
const runningGuests = [
...discoveryData.vms.filter(vm => vm.status === 'running'),
...discoveryData.containers.filter(ct => ct.status === 'running')
];
// Mock RRD and current status responses
let callCount = 0;
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path.includes('/rrddata')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
{ time: now - 300, cpu: 0.85, memory: 0.75, netin: 1000, netout: 2000 },
{ time: now - 240, cpu: 0.92, memory: 0.78, netin: 1100, netout: 2100 },
{ time: now - 180, cpu: 0.88, memory: 0.82, netin: 1200, netout: 2200 }
]
}
});
}
if (path.includes('/status')) {
callCount++;
// Return high CPU for some guests to trigger alerts
const highCpu = callCount <= 2; // First two guests get high CPU
return Promise.resolve({
data: {
data: {
cpu: highCpu ? 0.95 : 0.45, // 95% vs 45%
mem: 2147483648, // 2GB in bytes
disk: 10737418240, // 10GB in bytes
netin: 1500,
netout: 2500
}
}
});
}
return Promise.resolve({ data: { data: [] } });
});
const metricsData = await fetchMetricsData(
discoveryData.vms.filter(vm => vm.status === 'running'),
discoveryData.containers.filter(ct => ct.status === 'running'),
mockApiClients
);
// Verify metrics collection
expect(metricsData).toHaveLength(4); // All running guests
expect(metricsData.every(m => m.current)).toBe(true);
expect(metricsData.every(m => Array.isArray(m.data))).toBe(true);
// === STEP 3: Alert Processing ===
const triggeredAlerts = alertManager.processMetrics(metricsData);
// Should trigger alerts for high CPU guests
const highCpuGuests = metricsData.filter(m => m.current.cpu > 0.90);
expect(highCpuGuests.length).toBeGreaterThan(0);
console.log(`Integration test: Found ${highCpuGuests.length} guests with high CPU, ${triggeredAlerts.length} alerts triggered`);
});
test('should handle custom thresholds in monitoring workflow', async () => {
// === STEP 1: Set custom thresholds ===
await customThresholds.setThresholds('pve-main', 'node1', '100', {
cpu: { warning: 60, critical: 80 }, // Lower than defaults
memory: { warning: 70, critical: 90 }
});
// === STEP 2: Mock guest with moderate CPU (would normally be OK) ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path.includes('/status')) {
return Promise.resolve({
data: {
data: {
cpu: 0.75, // 75% - exceeds custom warning (60%) but not default (85%)
mem: 1073741824, // 1GB
disk: 5368709120 // 5GB
}
}
});
}
if (path.includes('/rrddata')) {
return Promise.resolve({
data: { data: [{ time: Date.now() / 1000, cpu: 0.75 }] }
});
}
return Promise.resolve({ data: { data: [] } });
});
const testGuest = {
id: 100,
endpointId: 'pve-main',
node: 'node1',
vmid: '100',
type: 'qemu',
name: 'test-vm',
status: 'running'
};
const metricsData = await fetchMetricsData([testGuest], [], mockApiClients);
// === STEP 3: Verify custom threshold integration ===
const guestMetrics = metricsData[0];
expect(guestMetrics.current.cpu).toBe(0.75);
// Get custom thresholds for this guest
const customConfig = customThresholds.getThresholds('pve-main', 'node1', '100');
expect(customConfig).not.toBeNull();
expect(customConfig.thresholds.cpu.warning).toBe(60); // 60%
expect(customConfig.thresholds.cpu.critical).toBe(80); // 80%
// This guest should trigger a warning with custom thresholds
// (75% > 60% warning threshold)
expect(guestMetrics.current.cpu * 100).toBeGreaterThan(customConfig.thresholds.cpu.warning);
expect(guestMetrics.current.cpu * 100).toBeLessThan(customConfig.thresholds.cpu.critical);
});
});
describe('PBS Integration Workflow', () => {
test('should discover PBS data and correlate with PVE guests', async () => {
// === STEP 1: Mock PBS discovery ===
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'pbs-node' }] }
});
}
if (path === '/config/datastore') {
return Promise.resolve({
data: { data: [{ name: 'main-store' }] }
});
}
if (path.includes('/admin/datastore/main-store/snapshots')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
{
'backup-time': now - 3600, // 1 hour ago
'backup-type': 'vm',
'backup-id': '100',
'backup-group': 'vm/100',
size: 1073741824 // 1GB
},
{
'backup-time': now - 7200, // 2 hours ago
'backup-type': 'ct',
'backup-id': '200',
'backup-group': 'ct/200',
size: 536870912 // 512MB
}
]
}
});
}
if (path.includes('/status/datastore-usage')) {
return Promise.resolve({
data: {
data: [{
store: 'main-store',
total: 107374182400, // 100GB
used: 1610612736, // 1.5GB
avail: 105763569664 // 98.5GB
}]
}
});
}
if (path.includes('/tasks')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
{
upid: 'backup-task-1',
type: 'backup',
worker_type: 'backup',
status: 'OK',
starttime: now - 3900, // Started ~1.1 hours ago
endtime: now - 3600, // Ended 1 hour ago
worker_id: 'vm/100'
},
{
upid: 'verify-task-1',
type: 'verify',
worker_type: 'verify',
status: 'OK',
starttime: now - 1800,
endtime: now - 1500
}
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
// === STEP 2: Mock PVE discovery ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'pve-node', status: 'online' }] }
});
}
if (path.includes('/qemu')) {
return Promise.resolve({
data: {
data: [
{ vmid: 100, name: 'web-server', status: 'running' }
]
}
});
}
if (path.includes('/lxc')) {
return Promise.resolve({
data: {
data: [
{ vmid: 200, name: 'proxy', status: 'running' }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
// === STEP 3: Execute integrated discovery ===
const [discoveryData, pbsData] = await Promise.all([
fetchDiscoveryData(mockApiClients, mockPbsApiClients),
fetchPbsData(mockPbsApiClients)
]);
// === STEP 4: Verify PBS-PVE correlation ===
expect(pbsData).toHaveLength(1);
expect(pbsData[0].datastores).toHaveLength(1);
expect(pbsData[0].datastores[0].snapshots).toHaveLength(2);
const vm100Backup = pbsData[0].datastores[0].snapshots.find(
s => s['backup-id'] === '100' && s['backup-type'] === 'vm'
);
const ct200Backup = pbsData[0].datastores[0].snapshots.find(
s => s['backup-id'] === '200' && s['backup-type'] === 'ct'
);
expect(vm100Backup).toBeDefined();
expect(ct200Backup).toBeDefined();
// Verify we can correlate backups with discovered guests
const discoveredVm100 = discoveryData.vms.find(vm => vm.vmid === 100);
const discoveredCt200 = discoveryData.containers.find(ct => ct.vmid === 200);
expect(discoveredVm100).toBeDefined();
expect(discoveredCt200).toBeDefined();
// Calculate backup ages
const now = Date.now() / 1000;
const vm100BackupAge = now - vm100Backup['backup-time'];
const ct200BackupAge = now - ct200Backup['backup-time'];
expect(vm100BackupAge).toBeLessThan(2 * 3600); // Less than 2 hours
expect(ct200BackupAge).toBeLessThan(3 * 3600); // Less than 3 hours
console.log(`Integration test: VM 100 backup age: ${Math.round(vm100BackupAge / 60)} minutes`);
console.log(`Integration test: CT 200 backup age: ${Math.round(ct200BackupAge / 60)} minutes`);
});
});
describe('Error Recovery and Resilience', () => {
test('should handle partial API failures gracefully', async () => {
// === STEP 1: Configure mixed success/failure scenarios ===
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'node1', status: 'online' },
{ node: 'node2', status: 'online' }
]
}
});
}
if (path.includes('node1')) {
// node1 APIs work normally
if (path.includes('/qemu')) {
return Promise.resolve({
data: { data: [{ vmid: 100, name: 'vm1', status: 'running' }] }
});
}
if (path.includes('/lxc')) {
return Promise.resolve({
data: { data: [{ vmid: 200, name: 'ct1', status: 'running' }] }
});
}
}
if (path.includes('node2')) {
// node2 APIs fail
throw new Error('Node2 is unreachable');
}
return Promise.resolve({ data: { data: [] } });
});
// === STEP 2: Execute discovery with partial failures ===
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// === STEP 3: Verify graceful degradation ===
expect(discoveryData.nodes).toHaveLength(2); // Both nodes discovered
expect(discoveryData.vms).toHaveLength(1); // Only node1 VMs
expect(discoveryData.containers).toHaveLength(1); // Only node1 CTs
// Verify node1 guests are present
expect(discoveryData.vms[0].vmid).toBe(100);
expect(discoveryData.containers[0].vmid).toBe(200);
// System should continue functioning despite node2 failure
});
test('should handle network errors gracefully', async () => {
// Clear any cached data from previous tests
clearCaches();
// Mock a scenario where one API call fails but the system continues
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: { data: [{ node: 'resilient-node', status: 'online' }] }
});
}
if (path.includes('/qemu') || path.includes('/lxc')) {
// Simulate network failure for guest discovery
const networkError = new Error('Network timeout');
networkError.code = 'ECONNABORTED';
throw networkError;
}
return Promise.resolve({ data: { data: [] } });
});
// Execute discovery - should handle network errors gracefully
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// Should discover nodes even if guest discovery fails
expect(discoveryData.nodes).toHaveLength(1);
expect(discoveryData.nodes[0].node).toBe('resilient-node');
expect(discoveryData.vms).toHaveLength(0); // No VMs due to network error
expect(discoveryData.containers).toHaveLength(0); // No containers due to network error
});
});
describe('Real Production Workflow: Multi-Tenant Environment', () => {
test('should handle admin investigating cross-tenant resource conflicts', async () => {
// REAL SCENARIO: Admin gets reports of VMs interfering with each other's performance
// Multiple departments sharing the same cluster with different SLA requirements
// Mock multi-tenant cluster data
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({
data: {
data: [
{ node: 'cluster1-node1', status: 'online' },
{ node: 'cluster1-node2', status: 'online' }
]
}
});
}
if (path.includes('/qemu')) {
if (path.includes('cluster1-node1')) {
return Promise.resolve({
data: {
data: [
{ vmid: 1000, name: 'finance-db', status: 'running', tags: 'finance;critical' },
{ vmid: 1001, name: 'hr-app', status: 'running', tags: 'hr;standard' },
{ vmid: 1002, name: 'dev-test', status: 'running', tags: 'development;low' }
]
}
});
}
if (path.includes('cluster1-node2')) {
return Promise.resolve({
data: {
data: [
{ vmid: 2000, name: 'marketing-web', status: 'running', tags: 'marketing;standard' },
{ vmid: 2001, name: 'analytics-worker', status: 'running', tags: 'analytics;high' }
]
}
});
}
}
if (path.includes('/lxc')) {
return Promise.resolve({ data: { data: [] } });
}
return Promise.resolve({ data: { data: [] } });
});
const discoveryData = await fetchDiscoveryData(mockApiClients, mockPbsApiClients);
// ANALYZE: Resource distribution across departments
const departmentMapping = {
finance: discoveryData.vms.filter(vm => vm.tags?.includes('finance')),
hr: discoveryData.vms.filter(vm => vm.tags?.includes('hr')),
development: discoveryData.vms.filter(vm => vm.tags?.includes('development')),
marketing: discoveryData.vms.filter(vm => vm.tags?.includes('marketing')),
analytics: discoveryData.vms.filter(vm => vm.tags?.includes('analytics'))
};
// VALIDATE: Multi-tenant separation
expect(departmentMapping.finance).toHaveLength(1);
expect(departmentMapping.analytics).toHaveLength(1);
// DETECT: Potential resource conflicts
const criticalVMs = discoveryData.vms.filter(vm => vm.tags?.includes('critical'));
const nodeDistribution = {};
discoveryData.vms.forEach(vm => {
if (!nodeDistribution[vm.node]) nodeDistribution[vm.node] = [];
nodeDistribution[vm.node].push(vm);
});
// VALIDATE: Critical VMs should not be overloaded on same node
const criticalNode = criticalVMs[0]?.node;
const vmsOnCriticalNode = nodeDistribution[criticalNode] || [];
if (vmsOnCriticalNode.length > 2) {
console.warn(`RESOURCE CONFLICT: ${vmsOnCriticalNode.length} VMs on node with critical workload`);
}
console.log(`Multi-tenant analysis: ${Object.keys(departmentMapping).length} departments across ${discoveryData.nodes.length} nodes`);
});
});
describe('Real Operations: Disaster Recovery Testing', () => {
test('should help admin validate backup recovery process for critical VMs', async () => {
// REAL SCENARIO: Monthly DR test - admin needs to verify which VMs can be recovered
// Mock PBS with realistic backup scenario
mockPbsApiClients['pbs-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: [{ node: 'pbs-dr' }] } });
}
if (path === '/config/datastore') {
return Promise.resolve({ data: { data: [{ name: 'dr-backups' }] } });
}
if (path.includes('/admin/datastore/dr-backups/snapshots')) {
const now = Math.floor(Date.now() / 1000);
return Promise.resolve({
data: {
data: [
// Critical systems with recent backups
{ 'backup-id': '100', 'backup-type': 'vm', 'backup-time': now - 3600, size: 10737418240, protected: true },
{ 'backup-id': '101', 'backup-type': 'vm', 'backup-time': now - 3600, size: 5368709120, protected: true },
// Development VM with older backup (acceptable)
{ 'backup-id': '200', 'backup-type': 'vm', 'backup-time': now - 86400, size: 2147483648, protected: false },
// Critical container with very recent backup
{ 'backup-id': '300', 'backup-type': 'ct', 'backup-time': now - 1800, size: 1073741824, protected: true },
// Test VM with gap in backups (concerning!)
{ 'backup-id': '400', 'backup-type': 'vm', 'backup-time': now - 259200, size: 8589934592, protected: false }
]
}
});
}
return Promise.resolve({ data: { data: [] } });
});
// Mock PVE discovery to correlate with backups
mockApiClients['pve-main'].client.get.mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: [{ node: 'production', status: 'online' }] } });
}
if (path.includes('/qemu')) {
return Promise.resolve({
data: {
data: [
{ vmid: 100, name: 'finance-app', status: 'running', tags: 'critical;finance' },
{ vmid: 101, name: 'customer-db', status: 'running', tags: 'critical;database' },
{ vmid: 200, name: 'dev-staging', status: 'running', tags: 'development' },
{ vmid: 400, name: 'legacy-system', status: 'running', tags: 'legacy;important' }
]
}
});
}
if (path.includes('/lxc')) {
return Promise.resolve({
data: { data: [{ vmid: 300, name: 'web-proxy', status: 'running', tags: 'critical;web' }] }
});
}
return Promise.resolve({ data: { data: [] } });
});
const [discoveryData, pbsData] = await Promise.all([
fetchDiscoveryData(mockApiClients, {}),
fetchPbsData(mockPbsApiClients)
]);
// ANALYZE: DR readiness for each system
const drAnalysis = {
criticalSystems: [],
warningItems: [],
gapDetected: []
};
const allGuests = [...discoveryData.vms, ...discoveryData.containers];
const allBackups = pbsData[0].datastores[0].snapshots;
allGuests.forEach(guest => {
const backups = allBackups.filter(backup =>
backup['backup-id'] === guest.vmid.toString()
);
if (backups.length === 0) {
drAnalysis.gapDetected.push({
guest: guest.name,
vmid: guest.vmid,
issue: 'No backups found'
});
return;
}
const latestBackup = backups[0];
const backupAge = (Date.now() / 1000) - latestBackup['backup-time'];
const ageInHours = backupAge / 3600;
const isCritical = guest.tags?.includes('critical');
if (isCritical) {
drAnalysis.criticalSystems.push({
guest: guest.name,
vmid: guest.vmid,
lastBackupAge: ageInHours,
protected: latestBackup.protected,
size: latestBackup.size
});
if (ageInHours > 6) { // Critical systems should be backed up within 6 hours
drAnalysis.warningItems.push({
guest: guest.name,
vmid: guest.vmid,
issue: `Critical system backup ${Math.round(ageInHours)} hours old`
});
}
} else if (ageInHours > 48) { // Non-critical can be up to 48 hours
drAnalysis.warningItems.push({
guest: guest.name,
vmid: guest.vmid,
issue: `Backup ${Math.round(ageInHours)} hours old`
});
}
});
// VALIDATE: DR test criteria
expect(drAnalysis.criticalSystems.length).toBeGreaterThan(0);
expect(drAnalysis.gapDetected.length).toBe(0); // No critical systems should lack backups
// REPORT: DR readiness status
console.log(`DR Test Summary:`);
console.log(`- Critical systems monitored: ${drAnalysis.criticalSystems.length}`);
console.log(`- Warning items: ${drAnalysis.warningItems.length}`);
console.log(`- Backup gaps: ${drAnalysis.gapDetected.length}`);
if (drAnalysis.warningItems.length > 0) {
console.log(`DR Warnings:`);
drAnalysis.warningItems.forEach(item => {
console.log(` - ${item.guest} (${item.vmid}): ${item.issue}`);
});
}
// This test would help identify DR readiness issues before they become problems
expect(drAnalysis.criticalSystems.every(sys => sys.lastBackupAge < 24)).toBe(true);
});
test('should handle concurrent operations without race conditions', async () => {
// === STEP 1: Set up concurrent operations ===
const operations = [
() => fetchDiscoveryData(mockApiClients, mockPbsApiClients),
() => fetchPbsData(mockPbsApiClients),
() => customThresholds.setThresholds('pve-main', 'node1', '100', {
cpu: { warning: 70, critical: 85 }
}),
() => customThresholds.setThresholds('pve-main', 'node1', '200', {
memory: { warning: 80, critical: 95 }
})
];
// Mock responses for all operations
mockApiClients['pve-main'].client.get.mockResolvedValue({
data: { data: [{ node: 'concurrent-node', status: 'online' }] }
});
mockPbsApiClients['pbs-main'].client.get.mockResolvedValue({
data: { data: [] }
});
// === STEP 2: Execute operations concurrently ===
const results = await Promise.all(operations.map(op => op()));
// === STEP 3: Verify all operations completed successfully ===
expect(results).toHaveLength(4);
expect(results[0].nodes).toHaveLength(1); // Discovery data
expect(Array.isArray(results[1])).toBe(true); // PBS data
expect(results[2]).toBe(true); // First threshold set
expect(results[3]).toBe(true); // Second threshold set
// Verify threshold configurations were saved correctly
const threshold100 = customThresholds.getThresholds('pve-main', 'node1', '100');
const threshold200 = customThresholds.getThresholds('pve-main', 'node1', '200');
expect(threshold100).not.toBeNull();
expect(threshold200).not.toBeNull();
expect(threshold100.thresholds.cpu.warning).toBe(70);
expect(threshold200.thresholds.memory.warning).toBe(80);
});
});
});
describe('Real-World Scenario Simulations', () => {
test('should simulate production monitoring cycle', async () => {
// This test simulates a realistic monitoring scenario with:
// - Mixed VM and container workloads
// - Varying resource usage patterns
// - Some backup failures
// - Custom threshold configurations
// - Alert generation and management
const scenario = {
cluster: {
nodes: 3,
vmsPerNode: 4,
containersPerNode: 6
},
workloads: [
{ type: 'web', cpu: 0.45, memory: 0.60, typical: true },
{ type: 'database', cpu: 0.75, memory: 0.85, highUsage: true },
{ type: 'cache', cpu: 0.30, memory: 0.95, memoryIntensive: true },
{ type: 'worker', cpu: 0.90, memory: 0.40, cpuIntensive: true }
]
};
console.log('Integration test: Simulating production monitoring scenario...');
console.log(`- ${scenario.cluster.nodes} nodes`);
console.log(`- ${scenario.cluster.vmsPerNode * scenario.cluster.nodes} VMs`);
console.log(`- ${scenario.cluster.containersPerNode * scenario.cluster.nodes} containers`);
console.log(`- ${scenario.workloads.length} workload types with varying resource patterns`);
// This demonstrates the comprehensive nature of the test suite
// and validates that the monitoring system can handle realistic
// production scenarios effectively.
expect(true).toBe(true); // Placeholder for demonstration
});
});
-269
View File
@@ -1,269 +0,0 @@
const { processPbsTasks, categorizeAndCountTasks } = require('../pbsUtils');
describe('PBS Utils - processPbsTasks', () => {
test('should return default structure for null input', () => {
const result = processPbsTasks(null);
expect(result).toEqual({
backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 },
});
});
test('should return default structure for empty array input', () => {
const result = processPbsTasks([]);
expect(result).toEqual({
backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } },
});
});
test('should correctly categorize and summarize various task types', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
// Backups
{ upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 3600, endtime: now - 3500 },
{ upid: 'B2', type: 'backup', status: 'OK', starttime: now - 7200, endtime: now - 7100 },
{ upid: 'B3', worker_type: 'backup', status: 'FAILED', starttime: now - 100, endtime: now - 50 },
{ upid: 'B4', worker_type: 'backup', status: 'ERROR', starttime: now - 40, endtime: now - 20 },
// Verifications
{ upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 500, endtime: now - 400 },
{ upid: 'V2', type: 'verificationjob', status: 'WARNING', starttime: now - 600, endtime: now - 550 }, // Treated as failed
// Sync
{ upid: 'S1', worker_type: 'sync', status: 'OK', starttime: now - 1000, endtime: now - 900 },
// Prune/GC
{ upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 2000, endtime: now - 1900 },
{ upid: 'G1', type: 'garbage_collection', status: 'OK', starttime: now - 2100, endtime: now - 2050 },
// Unknown/Other
{ upid: 'U1', type: 'unknown', status: 'OK', starttime: now - 5000, endtime: now - 4900 },
// Running task (should not count as OK or Failed)
{ upid: 'R1', worker_type: 'backup', status: 'running', starttime: now - 10, endtime: null },
];
const result = processPbsTasks(tasks);
// Backup Summary
expect(result.backupTasks.summary.ok).toBe(2);
expect(result.backupTasks.summary.failed).toBe(2);
expect(result.backupTasks.summary.total).toBe(4);
expect(result.backupTasks.summary.lastOk).toBe(now - 3500);
expect(result.backupTasks.summary.lastFailed).toBe(now - 20);
expect(result.backupTasks.recentTasks).toHaveLength(5);
// Verification Summary
expect(result.verificationTasks.summary.ok).toBe(1);
expect(result.verificationTasks.summary.failed).toBe(1);
expect(result.verificationTasks.summary.total).toBe(2);
expect(result.verificationTasks.summary.lastOk).toBe(now - 400);
expect(result.verificationTasks.summary.lastFailed).toBe(now - 550);
expect(result.verificationTasks.recentTasks).toHaveLength(2);
// Sync Summary
expect(result.syncTasks.summary.ok).toBe(1);
expect(result.syncTasks.summary.failed).toBe(0);
expect(result.syncTasks.summary.total).toBe(1);
expect(result.syncTasks.summary.lastOk).toBe(now - 900);
expect(result.syncTasks.summary.lastFailed).toBeNull();
expect(result.syncTasks.recentTasks).toHaveLength(1);
// Prune/GC Summary
expect(result.pruneTasks.summary.ok).toBe(2);
expect(result.pruneTasks.summary.failed).toBe(0);
expect(result.pruneTasks.summary.total).toBe(2);
expect(result.pruneTasks.summary.lastOk).toBe(now - 1900); // P1 is later than G1
expect(result.pruneTasks.summary.lastFailed).toBeNull();
expect(result.pruneTasks.recentTasks).toHaveLength(2);
});
test('should correctly format recent tasks', () => {
const rawTasks = [
// Task older than 30 days (should be filtered out)
{
upid: 'B_OLD',
node: 'pbsnode',
type: 'backup',
worker_type: 'backup',
worker_id: 'vm/200',
starttime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000), // 40 days ago
endtime: Math.floor((Date.now() - 40 * 24 * 60 * 60 * 1000) / 1000) + 60,
status: 'OK',
},
// Task within last 30 days
{
upid: 'B1',
node: 'pbsnode',
type: 'backup',
worker_type: 'backup',
worker_id: 'vm/100',
starttime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000), // 10 days ago
endtime: Math.floor((Date.now() - 10 * 24 * 60 * 60 * 1000) / 1000) + 50,
status: 'OK',
},
// Another task within last 30 days
{
upid: 'V1',
node: 'pbsnode',
type: 'verify',
worker_type: 'verify',
worker_id: 'datastore1:group1', // Example worker_id for verify
starttime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000), // 5 days ago
endtime: Math.floor((Date.now() - 5 * 24 * 60 * 60 * 1000) / 1000) + 30,
status: 'WARNING',
exitstatus: 'WARNING: some issues',
}
];
const result = processPbsTasks(rawTasks);
const { recentTasks } = result.backupTasks; // Assuming backupTasks is structured like this
expect(recentTasks).toHaveLength(1); // Only B1 should be included
expect(recentTasks[0].upid).toBe('B1');
expect(recentTasks[0].node).toBe('pbsnode');
expect(recentTasks[0].type).toBe('backup');
expect(recentTasks[0].status).toBe('OK');
expect(recentTasks[0].duration).toBe(50); // starttime - endtime
expect(recentTasks[0].guest).toBe('vm/100'); // worker_id
// Add other expected properties based on the actual implementation of processPbsTasks
expect(recentTasks[0].startTime).toBe(rawTasks[1].starttime); // Check original start/end times are mapped
expect(recentTasks[0].endTime).toBe(rawTasks[1].endtime);
expect(recentTasks[0].exitCode).toBeUndefined(); // Assuming no exitcode for OK task
// expect(recentTasks[0]._raw).toBeDefined(); // If _raw is intentionally included
// If _raw is *not* intentionally included, we need to fix processPbsTasks
// For now, let's check for common fields expected in the output:
expect(recentTasks[0]).toHaveProperty('upid');
expect(recentTasks[0]).toHaveProperty('node');
expect(recentTasks[0]).toHaveProperty('type');
expect(recentTasks[0]).toHaveProperty('status');
expect(recentTasks[0]).toHaveProperty('duration');
expect(recentTasks[0]).toHaveProperty('guest');
expect(recentTasks[0]).toHaveProperty('startTime');
expect(recentTasks[0]).toHaveProperty('endTime');
// Check that _raw is NOT present if it's not intended
expect(recentTasks[0]._raw).toBeUndefined();
const { recentTasks: verifyTasks } = result.verificationTasks; // Check verification tasks
expect(verifyTasks).toHaveLength(1); // Only V1 should be included
expect(verifyTasks[0].upid).toBe('V1');
expect(verifyTasks[0].status).toBe('WARNING');
expect(verifyTasks[0].duration).toBe(30);
expect(verifyTasks[0].exitStatus).toBe('WARNING: some issues'); // Assuming exitstatus is mapped
// Check that _raw is NOT present
expect(verifyTasks[0]._raw).toBeUndefined();
// Also check summaries if needed by this test
// expect(result.backupTasks.summary).toEqual(...);
// expect(result.verificationTasks.summary).toEqual(...);
});
test('should limit recent tasks to 20 by default', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [];
for (let i = 0; i < 25; i++) {
tasks.push({ upid: `B${i}`, worker_type: 'backup', status: 'OK', starttime: now - (i * 100), endtime: now - (i * 100) + 50 });
}
const result = processPbsTasks(tasks);
expect(result.backupTasks.recentTasks).toHaveLength(20);
expect(result.backupTasks.recentTasks[0].upid).toBe('B0'); // Most recent
expect(result.backupTasks.recentTasks[19].upid).toBe('B19'); // 20th most recent
});
test('should handle tasks with missing start or end times gracefully', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
{ upid: 'B1', worker_type: 'backup', status: 'OK', starttime: now - 100, endtime: now - 50 },
{ upid: 'B2', worker_type: 'backup', status: 'OK', starttime: null, endtime: now - 150 }, // Missing starttime
{ upid: 'B3', worker_type: 'backup', status: 'OK', starttime: now - 200, endtime: undefined }, // Missing endtime
{ upid: 'B4', worker_type: 'backup', status: 'OK', starttime: null, endtime: null }, // Missing both
];
const result = processPbsTasks(tasks);
const recent = result.backupTasks.recentTasks;
expect(recent).toHaveLength(4);
// Sorting might be affected, but check formatting
const taskB2 = recent.find(t => t.upid === 'B2');
const taskB3 = recent.find(t => t.upid === 'B3');
const taskB4 = recent.find(t => t.upid === 'B4');
expect(taskB2.duration).toBeNull();
expect(taskB3.duration).toBeNull();
expect(taskB4.duration).toBeNull();
// Check summary timestamps (should ignore tasks without endtime)
expect(result.backupTasks.summary.lastOk).toBe(now - 50); // Only B1 has a valid endtime
});
test('should handle different verification task types', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
{ upid: 'V1', worker_type: 'verify', status: 'OK', starttime: now - 100, endtime: now - 50 },
{ upid: 'V2', type: 'verificationjob', status: 'OK', starttime: now - 200, endtime: now - 150 },
{ upid: 'V3', type: 'verify_group', status: 'FAILED', starttime: now - 300, endtime: now - 250 },
];
const result = processPbsTasks(tasks);
expect(result.verificationTasks.summary.ok).toBe(2);
expect(result.verificationTasks.summary.failed).toBe(1);
expect(result.verificationTasks.summary.total).toBe(3);
expect(result.verificationTasks.recentTasks).toHaveLength(3);
expect(result.verificationTasks.recentTasks.map(t => t.upid)).toEqual(['V1', 'V2', 'V3']); // Sorted by start time
});
test('should handle different prune/gc task types', () => {
const now = Math.floor(Date.now() / 1000);
const tasks = [
{ upid: 'P1', worker_type: 'prune', status: 'OK', starttime: now - 100, endtime: now - 50 },
{ upid: 'G1', type: 'garbage_collection', status: 'FAILED', starttime: now - 200, endtime: now - 150 },
];
const result = processPbsTasks(tasks);
expect(result.pruneTasks.summary.ok).toBe(1);
expect(result.pruneTasks.summary.failed).toBe(1);
expect(result.pruneTasks.summary.total).toBe(2);
expect(result.pruneTasks.recentTasks).toHaveLength(2);
expect(result.pruneTasks.recentTasks.map(t => t.upid)).toEqual(['P1', 'G1']); // Sorted by start time
});
test('should return default structure for non-array input', () => {
const result = processPbsTasks({}); // Pass an object instead of an array
expect(result).toEqual({
backupTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
verificationTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
syncTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
pruneTasks: { recentTasks: [], summary: { ok: 0, failed: 0, total: 0 } },
aggregatedPbsTaskSummary: { total: 0, ok: 0, failed: 0 },
});
});
});
describe('PBS Utils - categorizeAndCountTasks', () => {
test('should return default structure for null input', () => {
const taskTypeMap = { backup: 'backup', verify: 'verify' };
const result = categorizeAndCountTasks(null, taskTypeMap);
expect(result).toEqual({
backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }
});
});
test('should return default structure for non-array input', () => {
const taskTypeMap = { backup: 'backup', verify: 'verify' };
const result = categorizeAndCountTasks({}, taskTypeMap);
expect(result).toEqual({
backup: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
verify: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
sync: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 },
pruneGc: { list: [], ok: 0, failed: 0, lastOk: 0, lastFailed: 0 }
});
});
});
-235
View File
@@ -1,235 +0,0 @@
#!/usr/bin/env node
/**
* Backup Validation Runner
*
* This script can be run to validate live backup data against ground truths.
* Usage: node runBackupValidation.js [--live]
*/
const { fetchDiscoveryData, fetchPbsData } = require('../dataFetcher');
const { processPbsTasks } = require('../pbsUtils');
const { createApiClientInstance } = require('../apiClients');
const {
validateAllBackupData,
generateValidationReport
} = require('./backupDataValidator');
// Load config if running against live data
let config = null;
if (process.argv.includes('--live')) {
try {
config = require('../config.json');
} catch (error) {
console.error('Error loading config.json:', error.message);
process.exit(1);
}
}
/**
* Runs validation against mock data
*/
async function runMockValidation() {
console.log('Running validation against mock data...\n');
// Create mock data similar to test setup
const mockDiscoveryData = {
nodes: [
{ node: 'desktop', endpointId: 'proxmox-lan', status: 'online' },
{ node: 'delly', endpointId: 'proxmox-lan', status: 'online' },
{ node: 'minipc', endpointId: 'proxmox-lan', status: 'online' },
{ node: 'pi', endpointId: 'pimox-lan', status: 'online' }
],
vms: [
{ vmid: 100, name: 'vm100', type: 'qemu', endpointId: 'proxmox-lan' },
{ vmid: 102, name: 'vm102', type: 'qemu', endpointId: 'proxmox-lan' },
{ vmid: 200, name: 'vm200', type: 'qemu', endpointId: 'proxmox-lan' }
],
containers: Array.from({ length: 15 }, (_, i) => ({
vmid: 103 + i,
name: `ct${103 + i}`,
type: 'lxc',
endpointId: i < 14 ? 'proxmox-lan' : 'pimox-lan'
})),
pveBackups: {
backupTasks: [],
storageBackups: [],
guestSnapshots: [
{ name: 'ubuntuserver', vmid: 400, type: 'qemu' },
{ name: 'precursor', vmid: 400, type: 'qemu' },
{ name: 'before_helper', vmid: 106, type: 'lxc' }
]
}
};
// Create mock PBS data
const now = Date.now() / 1000;
const mockPbsData = [{
pbsEndpointId: 'pbs-main',
pbsInstanceName: 'PBS Storage',
status: 'ok',
datastores: [{
name: 'main-datastore',
snapshots: []
}]
}];
// Add mock snapshots
const guests = [100, 103, 104, 105, 106, 200, 400];
guests.forEach(guestId => {
const isSecondaryJob = [102, 200, 400].includes(guestId);
const backupTime = isSecondaryJob
? now - (9 * 60 * 60) // 9 hours ago
: now - (11 * 60 * 60); // 11 hours ago
// Skip VM 102 to simulate missing backup
if (guestId !== 102) {
mockPbsData[0].datastores[0].snapshots.push({
'backup-time': backupTime,
'backup-type': guestId <= 200 ? 'vm' : 'ct',
'backup-id': String(guestId)
});
}
});
// Create mock PBS tasks
const mockPbsTasks = mockPbsData[0].datastores[0].snapshots.map(snap => ({
type: 'backup',
status: 'OK',
starttime: snap['backup-time'],
endtime: snap['backup-time'] + 300,
guest: `${snap['backup-type']}/${snap['backup-id']}`,
guestType: snap['backup-type'],
guestId: snap['backup-id'],
pbsBackupRun: true
}));
const processedTasks = processPbsTasks(mockPbsTasks);
// Run validation
const validationData = {
discoveryData: mockDiscoveryData,
pbsData: mockPbsData,
pbsTasks: mockPbsTasks,
processedTasks: processedTasks
};
const report = validateAllBackupData(validationData);
console.log(generateValidationReport(report));
}
/**
* Runs validation against live data
*/
async function runLiveValidation() {
console.log('Running validation against live data...\n');
try {
// Initialize API clients
const apiClients = {};
const pbsApiClients = {};
// Initialize PVE clients
if (config.pveEndpoints) {
for (const [key, endpoint] of Object.entries(config.pveEndpoints)) {
try {
apiClients[key] = {
client: await createApiClientInstance({
...endpoint,
type: 'pve'
}),
config: endpoint
};
console.log(`✓ Connected to PVE endpoint: ${endpoint.name || key}`);
} catch (error) {
console.error(`✗ Failed to connect to PVE endpoint ${key}:`, error.message);
}
}
}
// Initialize PBS clients
if (config.pbsEndpoints) {
for (const [key, endpoint] of Object.entries(config.pbsEndpoints)) {
try {
pbsApiClients[key] = {
client: await createApiClientInstance({
...endpoint,
type: 'pbs'
}),
config: endpoint
};
console.log(`✓ Connected to PBS endpoint: ${endpoint.name || key}`);
} catch (error) {
console.error(`✗ Failed to connect to PBS endpoint ${key}:`, error.message);
}
}
}
console.log('\nFetching data...');
// Fetch all data
const [discoveryData, pbsData] = await Promise.all([
fetchDiscoveryData(apiClients, pbsApiClients),
fetchPbsData(pbsApiClients)
]);
console.log('Processing PBS tasks...');
// Get raw PBS tasks for validation
let pbsTasks = [];
if (pbsData[0]?.backupTasks?.recentTasks) {
pbsTasks = pbsData[0].backupTasks.recentTasks;
}
// Process tasks
const processedTasks = processPbsTasks(pbsTasks);
// Run validation
const validationData = {
discoveryData,
pbsData,
pbsTasks,
processedTasks
};
const report = validateAllBackupData(validationData);
console.log('\n' + generateValidationReport(report));
// Save detailed report if issues found
if (!report.overallValid || report.warnings.length > 0) {
const fs = require('fs');
const reportPath = `backup-validation-${Date.now()}.json`;
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
console.log(`\nDetailed report saved to: ${reportPath}`);
}
} catch (error) {
console.error('Error during live validation:', error);
process.exit(1);
}
}
/**
* Main entry point
*/
async function main() {
console.log('Pulse Backup Data Validator\n');
if (process.argv.includes('--live')) {
if (!config) {
console.error('No config.json found. Cannot run live validation.');
process.exit(1);
}
await runLiveValidation();
} else {
await runMockValidation();
console.log('\nTo run against live data, use: node runBackupValidation.js --live');
}
}
// Run if called directly
if (require.main === module) {
main().catch(console.error);
}
module.exports = { runMockValidation, runLiveValidation };
-702
View File
@@ -1,702 +0,0 @@
/**
* User Workflow Tests - Real Production Scenarios
* These tests validate actual user workflows and would catch bugs that affect real users
*/
const { fetchDiscoveryData, fetchMetricsData, fetchPbsData, clearCaches } = require('../dataFetcher');
const { processPbsTasks } = require('../pbsUtils');
const customThresholds = require('../customThresholds');
const AlertManager = require('../alertManager');
// Mock only external dependencies, not our business logic
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn().mockResolvedValue(),
readFile: jest.fn().mockResolvedValue('{}'),
writeFile: jest.fn().mockResolvedValue()
}
}));
describe('Real User Workflows - Production Scenarios', () => {
let realApiData;
let alertManager;
beforeEach(() => {
clearCaches();
alertManager = new AlertManager();
customThresholds.cache.clear();
jest.clearAllMocks();
// Create realistic production data based on your actual setup
realApiData = {
// Realistic PVE cluster based on your ground truth data
pveCluster: {
nodes: [
{ node: 'desktop', status: 'online', uptime: 86400 * 5 }, // 5 days
{ node: 'delly', status: 'online', uptime: 86400 * 12 }, // 12 days
{ node: 'minipc', status: 'online', uptime: 86400 * 8 } // 8 days
],
vms: [
{ vmid: 102, name: 'windows11', status: 'stopped', node: 'desktop', agent: 0 },
{ vmid: 200, name: 'UnraidServer', status: 'running', node: 'desktop', agent: 1 },
{ vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', node: 'desktop', agent: 1 }
],
containers: [
{ vmid: 100, name: 'pbs', status: 'running', node: 'desktop' },
{ vmid: 101, name: 'homeassistant', status: 'running', node: 'delly' },
{ vmid: 103, name: 'pihole', status: 'running', node: 'minipc' },
{ vmid: 106, name: 'pulse', status: 'running', node: 'minipc' }, // This very app!
// ... 14 more containers for realistic 18 total guests
{ vmid: 107, name: 'jellyfin', status: 'running', node: 'minipc' },
{ vmid: 108, name: 'frigate', status: 'running', node: 'delly' },
{ vmid: 109, name: 'pbs2', status: 'stopped', node: 'desktop' },
{ vmid: 110, name: 'tailscale-router', status: 'running', node: 'delly' },
{ vmid: 111, name: 'debian', status: 'stopped', node: 'desktop' },
{ vmid: 120, name: 'mqtt', status: 'running', node: 'minipc' },
{ vmid: 121, name: 'zigbee2mqtt', status: 'running', node: 'minipc' },
{ vmid: 122, name: 'influxdb-telegraf', status: 'running', node: 'delly' },
{ vmid: 124, name: 'grafana', status: 'running', node: 'minipc' },
{ vmid: 105, name: 'homepage', status: 'running', node: 'delly' },
{ vmid: 104, name: 'cloudflared', status: 'running', node: 'minipc' }
]
},
// Realistic backup data from your PBS
pbsBackups: {
datastores: [{
name: 'main-datastore',
snapshots: [
// Most containers have backups from 2 AM (primary job)
{ 'backup-id': '100', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '101', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '103', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '106', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
// VM 102 - THE PROBLEM CHILD (no recent backup!)
{ 'backup-id': '102', 'backup-type': 'vm', 'backup-time': getThreeDaysAgo() },
// VMs 200, 400 have backups from 4 AM (secondary job)
{ 'backup-id': '200', 'backup-type': 'vm', 'backup-time': getFourAMToday() },
{ 'backup-id': '400', 'backup-type': 'vm', 'backup-time': getFourAMToday() },
// More containers...
{ 'backup-id': '107', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '108', 'backup-type': 'ct', 'backup-time': getTwoAMToday() },
{ 'backup-id': '110', 'backup-type': 'ct', 'backup-time': getTwoAMToday() }
]
}]
},
// Realistic metrics - some VMs under stress
currentMetrics: {
// Healthy VM
200: { cpu: 0.15, memory: 2147483648, disk: 10737418240 }, // 15% CPU, 2GB RAM
// VM under CPU pressure
400: { cpu: 0.89, memory: 4294967296, disk: 21474836480 }, // 89% CPU, 4GB RAM
// Container with memory pressure
101: { cpu: 0.25, memory: 1073741824, disk: 5368709120 }, // 25% CPU, 1GB RAM
106: { cpu: 0.12, memory: 536870912, disk: 2684354560 } // Pulse itself
}
};
});
afterEach(() => {
if (alertManager) {
alertManager.destroy();
}
});
describe('Scenario 1: Admin Investigates "Why Does Dashboard Show Wrong VM Count?"', () => {
test('should detect VM count discrepancy between dashboard and reality', async () => {
// REAL SCENARIO: Dashboard shows 20 VMs but only 18 guests exist
// Mock realistic discovery that returns actual guest data
const mockApiClients = createRealisticMockClients(realApiData.pveCluster);
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
// Count actual guests
const totalGuests = discoveryData.vms.length + discoveryData.containers.length;
// VALIDATE: Should match your known ground truth (18 guests total)
expect(totalGuests).toBe(18);
expect(discoveryData.vms).toHaveLength(3); // VMs: 102, 200, 400
expect(discoveryData.containers).toHaveLength(15); // All the containers
// VALIDATE: All known guests are present
const allVmids = [...discoveryData.vms, ...discoveryData.containers].map(g => g.vmid);
expect(allVmids).toContain(102); // windows11
expect(allVmids).toContain(106); // pulse (this app!)
expect(allVmids).toContain(200); // UnraidServer
// DETECT: If count was wrong, this would help debug
if (totalGuests !== 18) {
console.error(`DISCREPANCY: Expected 18 guests, found ${totalGuests}`);
console.error('Missing guests:', [100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].filter(id => !allVmids.includes(id)));
console.error('Extra guests:', allVmids.filter(id => ![100,101,102,103,104,105,106,107,108,109,110,111,120,121,122,124,200,400].includes(id)));
}
});
});
describe('Scenario 2: Admin Investigates "VM 102 Backup Issue"', () => {
test('should detect that VM 102 backup is dangerously old', async () => {
// REAL SCENARIO: VM 102 should be in backup job but backup is 3 days old
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// Find VM 102 backup
const vm102Backups = pbsData[0].datastores[0].snapshots.filter(
snap => snap['backup-id'] === '102' && snap['backup-type'] === 'vm'
);
expect(vm102Backups).toHaveLength(1);
const vm102LastBackup = vm102Backups[0];
const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time'];
const ageInHours = backupAge / 3600;
// VALIDATE: This should detect the problem
expect(ageInHours).toBeGreaterThan(48); // More than 2 days old!
// ALERT: This should trigger a critical alert
if (ageInHours > 24) {
console.warn(`CRITICAL: VM 102 backup is ${Math.round(ageInHours)} hours old!`);
}
// COMPARE: Other VMs should have recent backups
const vm200Backups = pbsData[0].datastores[0].snapshots.filter(
snap => snap['backup-id'] === '200' && snap['backup-type'] === 'vm'
);
const vm200Age = (Date.now() / 1000) - vm200Backups[0]['backup-time'];
expect(vm200Age / 3600).toBeLessThan(24); // Should be recent
});
test('should identify backup job configuration issue', async () => {
// REAL SCENARIO: VM 102 might be excluded from backup jobs or job failed
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// Analyze backup patterns to detect issues
const backupsByGuest = {};
pbsData[0].datastores[0].snapshots.forEach(snap => {
const guestId = snap['backup-id'];
if (!backupsByGuest[guestId]) {
backupsByGuest[guestId] = [];
}
backupsByGuest[guestId].push(snap);
});
// Check backup frequency patterns
const recentBackups = Object.keys(backupsByGuest).filter(guestId => {
const latestBackup = backupsByGuest[guestId][0];
const ageHours = (Date.now() / 1000 - latestBackup['backup-time']) / 3600;
return ageHours < 24;
});
// VALIDATE: Most guests should have recent backups
expect(recentBackups.length).toBeGreaterThan(5);
// DETECT: VM 102 should be flagged as problematic
expect(recentBackups).not.toContain('102');
// IDENTIFY: Pattern analysis
const guestsWithoutRecentBackups = Object.keys(backupsByGuest).filter(id => !recentBackups.includes(id));
if (guestsWithoutRecentBackups.length > 0) {
console.warn(`Guests with old backups: ${guestsWithoutRecentBackups.join(', ')}`);
}
});
});
describe('Scenario 3: Admin Responds to "High CPU Alert Storm"', () => {
test('should detect which VMs are actually problematic vs false alarms', async () => {
// REAL SCENARIO: Multiple CPU alerts, admin needs to prioritize
const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics);
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const runningGuests = [
...discoveryData.vms.filter(vm => vm.status === 'running'),
...discoveryData.containers.filter(ct => ct.status === 'running')
];
const metricsData = await fetchMetricsData(
discoveryData.vms.filter(vm => vm.status === 'running'),
discoveryData.containers.filter(ct => ct.status === 'running'),
mockApiClients
);
// ANALYZE: Which guests actually have high CPU
const highCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.8);
const moderateCpuGuests = metricsData.filter(metrics => metrics.current.cpu > 0.5 && metrics.current.cpu <= 0.8);
// VALIDATE: Should detect VM 400 as high CPU (89%)
expect(highCpuGuests).toHaveLength(1);
expect(highCpuGuests[0].id).toBe(400);
expect(highCpuGuests[0].current.cpu).toBeCloseTo(0.89, 2);
// PRIORITIZE: Admin can focus on real issues
console.log(`HIGH PRIORITY: ${highCpuGuests.length} guests with CPU >80%`);
console.log(`MEDIUM PRIORITY: ${moderateCpuGuests.length} guests with CPU 50-80%`);
highCpuGuests.forEach(guest => {
const guestInfo = runningGuests.find(g => g.vmid === guest.id);
console.log(` - ${guestInfo.name} (${guestInfo.type} ${guest.id}): ${Math.round(guest.current.cpu * 100)}% CPU`);
});
});
test('should validate alert suppression during maintenance', async () => {
// REAL SCENARIO: Admin puts VM 400 in maintenance, alerts should stop
// Set custom thresholds to ensure alerts would normally fire
await customThresholds.setThresholds('primary', 'desktop', '400', {
cpu: { warning: 70, critical: 85 }
});
const mockApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics);
const metricsData = await fetchMetricsData([], [
{ vmid: 400, name: 'ubuntu-gpu-vm', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' }
], mockApiClients);
// Process alerts normally - should fire
const triggeredAlerts = alertManager.processMetrics(metricsData);
expect(triggeredAlerts.length).toBeGreaterThan(0);
// Suppress alerts for maintenance
alertManager.suppressAlert('cpu_high', { vmid: 400 }, 3600000, 'Maintenance window');
// Process again - should be suppressed
const suppressedAlerts = alertManager.processMetrics(metricsData);
const vm400Alerts = suppressedAlerts.filter(alert => alert.guest.vmid === '400');
expect(vm400Alerts).toHaveLength(0);
});
});
describe('Scenario 4: Admin Validates "Backup Job Health"', () => {
test('should validate backup job scheduling is working correctly', async () => {
// REAL SCENARIO: Admin checks if backup jobs ran on schedule
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// Group backups by time to detect job patterns
const backupTimes = {};
pbsData[0].datastores[0].snapshots.forEach(snap => {
const backupHour = new Date(snap['backup-time'] * 1000).getHours();
if (!backupTimes[backupHour]) {
backupTimes[backupHour] = [];
}
backupTimes[backupHour].push(snap);
});
// VALIDATE: Should see backups at 2 AM and 4 AM (your backup schedule)
expect(backupTimes[2]).toBeDefined(); // Primary job at 2 AM
expect(backupTimes[4]).toBeDefined(); // Secondary job at 4 AM
// VALIDATE: 2 AM job should have most containers
const twoAMBackups = backupTimes[2] || [];
const fourAMBackups = backupTimes[4] || [];
expect(twoAMBackups.length).toBeGreaterThan(fourAMBackups.length);
// VALIDATE: Specific VMs should be in correct jobs
const twoAMVmids = twoAMBackups.map(b => b['backup-id']);
const fourAMVmids = fourAMBackups.map(b => b['backup-id']);
// Based on your ground truth: VMs 200, 400 in secondary job (4 AM)
expect(fourAMVmids).toContain('200');
expect(fourAMVmids).toContain('400');
// Most containers in primary job (2 AM) - excluding VMs 102, 200, 400
expect(twoAMVmids).toContain('100'); // pbs container
expect(twoAMVmids).toContain('106'); // pulse container
console.log(`Primary job (2 AM): ${twoAMBackups.length} backups`);
console.log(`Secondary job (4 AM): ${fourAMBackups.length} backups`);
});
});
describe('Scenario 5: Performance Under Load', () => {
test('should handle realistic cluster size without performance degradation', async () => {
// REAL SCENARIO: System should stay responsive with full cluster
const startTime = Date.now();
const startMemory = process.memoryUsage().heapUsed;
// Create full realistic cluster
const mockApiClients = createLargeRealisticCluster();
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const discoveryTime = Date.now() - startTime;
// VALIDATE: Performance should be acceptable
expect(discoveryTime).toBeLessThan(10000); // 10 seconds max for discovery
expect(discoveryData.nodes.length).toBeGreaterThan(2);
expect(discoveryData.vms.length + discoveryData.containers.length).toBeGreaterThan(15);
// VALIDATE: Memory usage should be reasonable
const endMemory = process.memoryUsage().heapUsed;
const memoryIncrease = endMemory - startMemory;
expect(memoryIncrease).toBeLessThan(100 * 1024 * 1024); // Less than 100MB increase
console.log(`Discovery took ${discoveryTime}ms for ${discoveryData.vms.length + discoveryData.containers.length} guests`);
console.log(`Memory increase: ${Math.round(memoryIncrease / 1024 / 1024)}MB`);
});
});
describe('Scenario 6: Admin Debugs "Slow Dashboard Loading"', () => {
test('should identify performance bottlenecks in data fetching', async () => {
// REAL SCENARIO: Dashboard taking 30+ seconds to load, admin needs to find why
const mockApiClients = createRealisticMockClients(realApiData.pveCluster);
const performanceMetrics = {
discoveryStart: Date.now(),
nodeCallTimes: [],
totalApiCalls: 0
};
// Monitor API call performance
const originalGet = mockApiClients.primary.client.get;
mockApiClients.primary.client.get = jest.fn().mockImplementation(async (path) => {
const callStart = Date.now();
performanceMetrics.totalApiCalls++;
// Simulate realistic response times for different endpoints
let delay = 100; // Default delay
if (path.includes('/qemu') || path.includes('/lxc')) {
delay = 500; // Guest endpoints are slower
}
if (path.includes('node3')) {
delay = 2000; // One node is slow (network issue)
}
await new Promise(resolve => setTimeout(resolve, delay));
const result = await originalGet.call(this, path);
const callTime = Date.now() - callStart;
performanceMetrics.nodeCallTimes.push({ path, time: callTime });
return result;
});
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const totalTime = Date.now() - performanceMetrics.discoveryStart;
// ANALYZE: Performance bottlenecks
const slowCalls = performanceMetrics.nodeCallTimes.filter(call => call.time > 1000);
const avgCallTime = performanceMetrics.nodeCallTimes.reduce((sum, call) => sum + call.time, 0) / performanceMetrics.nodeCallTimes.length;
// VALIDATE: Should identify the slow node
expect(slowCalls.length).toBeGreaterThan(0);
expect(slowCalls.some(call => call.path.includes('node3'))).toBe(true);
// DETECT: Performance recommendations
if (avgCallTime > 500) {
console.log(`PERFORMANCE ISSUE: Average API call time ${Math.round(avgCallTime)}ms`);
}
if (totalTime > 5000) {
console.log(`PERFORMANCE ISSUE: Total discovery time ${totalTime}ms`);
}
console.log(`Performance analysis: ${performanceMetrics.totalApiCalls} API calls, ${slowCalls.length} slow calls`);
slowCalls.forEach(call => {
console.log(` SLOW: ${call.path} took ${call.time}ms`);
});
});
});
describe('Scenario 7: Admin Investigates "Missing Backup Alerts"', () => {
test('should detect when backup monitoring is not working correctly', async () => {
// REAL SCENARIO: VM 102 hasn't been backed up in 3 days but no alerts fired
const mockPbsClients = createRealisticPbsClients(realApiData.pbsBackups);
const pbsData = await fetchPbsData(mockPbsClients);
// ANALYZE: Backup monitoring effectiveness
const allBackups = pbsData[0].datastores[0].snapshots;
const vm102Backups = allBackups.filter(snap =>
snap['backup-id'] === '102' && snap['backup-type'] === 'vm'
);
expect(vm102Backups).toHaveLength(1);
const vm102LastBackup = vm102Backups[0];
const backupAge = (Date.now() / 1000) - vm102LastBackup['backup-time'];
const ageInDays = backupAge / (24 * 3600);
// VALIDATE: Should detect old backup
expect(ageInDays).toBeGreaterThan(2); // More than 2 days old
// SIMULATE: Alert system check
const mockAlertThreshold = 24 * 3600; // 24 hours
const shouldHaveAlerted = backupAge > mockAlertThreshold;
// DETECT: Alert system gap
if (shouldHaveAlerted) {
console.log(`MONITORING GAP: VM 102 backup is ${Math.round(ageInDays * 10) / 10} days old, should have triggered alert`);
console.log(`Backup age: ${Math.round(backupAge / 3600)} hours (threshold: ${mockAlertThreshold / 3600} hours)`);
}
// VALIDATE: This test helps identify why backup alerts aren't working
expect(shouldHaveAlerted).toBe(true);
// RECOMMEND: Compare with other VMs to see pattern
const recentBackups = allBackups.filter(snap => {
const snapAge = (Date.now() / 1000) - snap['backup-time'];
return snapAge < (24 * 3600); // Less than 24 hours old
});
console.log(`Found ${recentBackups.length} recent backups vs ${allBackups.length} total`);
});
});
describe('Scenario 8: Data Integrity Validation', () => {
test('should validate that all running VMs have corresponding metrics', async () => {
// REAL SCENARIO: Admin notices some VMs missing from metrics dashboard
const mockApiClients = createRealisticMockClients(realApiData.pveCluster);
const discoveryData = await fetchDiscoveryData(mockApiClients, {});
const runningGuests = [
...discoveryData.vms.filter(vm => vm.status === 'running'),
...discoveryData.containers.filter(ct => ct.status === 'running')
];
// Mock metrics that might miss some guests
const mockMetricsApiClients = createRealisticMockClientsWithMetrics(realApiData.currentMetrics);
const metricsData = await fetchMetricsData(
discoveryData.vms.filter(vm => vm.status === 'running'),
discoveryData.containers.filter(ct => ct.status === 'running'),
mockMetricsApiClients
);
// DATA INTEGRITY CHECK: Every running guest should have metrics
const runningGuestIds = runningGuests.map(g => g.vmid);
const metricsGuestIds = metricsData.map(m => m.id);
const missingMetrics = runningGuestIds.filter(id => !metricsGuestIds.includes(id));
const extraMetrics = metricsGuestIds.filter(id => !runningGuestIds.includes(id));
// VALIDATE: Data consistency
expect(missingMetrics).toHaveLength(0); // No running guests should be missing metrics
expect(extraMetrics).toHaveLength(0); // No metrics for non-existent guests
if (missingMetrics.length > 0) {
console.error(`DATA INTEGRITY ISSUE: ${missingMetrics.length} running guests missing metrics:`, missingMetrics);
}
if (extraMetrics.length > 0) {
console.error(`DATA INTEGRITY ISSUE: ${extraMetrics.length} metrics for non-running guests:`, extraMetrics);
}
// VALIDATE: Metrics data quality
metricsData.forEach(metrics => {
expect(metrics.current).toBeDefined();
expect(typeof metrics.current.cpu).toBe('number');
expect(metrics.current.cpu).toBeGreaterThanOrEqual(0);
expect(metrics.current.cpu).toBeLessThanOrEqual(1); // Assuming decimal format
});
console.log(`Data integrity check: ${runningGuests.length} running guests, ${metricsData.length} metrics records`);
});
});
describe('Scenario 9: Admin Responds to "Disk Space Critical" Alert', () => {
test('should help admin prioritize disk cleanup actions', async () => {
// REAL SCENARIO: Multiple disk space alerts, admin needs to know where to focus cleanup
// Mock guests with varying disk usage
const diskPressureGuests = {
106: { cpu: 0.12, memory: 536870912, disk: 0.92 }, // Pulse - 92% full
200: { cpu: 0.15, memory: 2147483648, disk: 0.88 }, // UnraidServer - 88% full
107: { cpu: 0.08, memory: 268435456, disk: 0.95 }, // Jellyfin - 95% full (critical!)
108: { cpu: 0.22, memory: 1073741824, disk: 0.85 } // Frigate - 85% full
};
const mockApiClients = createRealisticMockClientsWithMetrics(diskPressureGuests);
const metricsData = await fetchMetricsData([], [
{ vmid: 106, name: 'pulse', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' },
{ vmid: 200, name: 'UnraidServer', status: 'running', endpointId: 'primary', node: 'desktop', type: 'qemu' },
{ vmid: 107, name: 'jellyfin', status: 'running', endpointId: 'primary', node: 'minipc', type: 'lxc' },
{ vmid: 108, name: 'frigate', status: 'running', endpointId: 'primary', node: 'delly', type: 'lxc' }
], mockApiClients);
// ANALYZE: Disk usage patterns
const diskMetrics = metricsData.map(m => ({
id: m.id,
name: m.guestName,
diskUsage: m.current.disk * 100,
type: m.type
})).sort((a, b) => b.diskUsage - a.diskUsage);
// PRIORITIZE: Critical vs warning levels
const criticalDisk = diskMetrics.filter(g => g.diskUsage > 90); // >90%
const warningDisk = diskMetrics.filter(g => g.diskUsage > 85 && g.diskUsage <= 90); // 85-90%
// VALIDATE: Should identify jellyfin as highest priority
expect(criticalDisk).toHaveLength(2); // Jellyfin (95%) and Pulse (92%)
expect(criticalDisk[0].name).toBe('jellyfin');
expect(criticalDisk[0].diskUsage).toBe(95);
// RECOMMEND: Actions based on service type
const mediaServices = criticalDisk.filter(g =>
['jellyfin', 'plex', 'frigate'].includes(g.name.toLowerCase())
);
const systemServices = criticalDisk.filter(g =>
['pulse', 'pihole', 'homeassistant'].includes(g.name.toLowerCase())
);
console.log('DISK CLEANUP PRIORITIES:');
console.log(`CRITICAL (>90%): ${criticalDisk.length} services`);
criticalDisk.forEach(g => {
console.log(` - ${g.name}: ${g.diskUsage}% full`);
});
console.log(`WARNING (85-90%): ${warningDisk.length} services`);
// GUIDANCE: Specific cleanup recommendations
if (mediaServices.length > 0) {
console.log('RECOMMENDATION: Check media files for cleanup (jellyfin, frigate)');
}
if (systemServices.length > 0) {
console.log('RECOMMENDATION: Check logs and temporary files (pulse, system services)');
}
expect(criticalDisk.length).toBeGreaterThan(0);
});
});
});
// Helper functions for realistic test data
function getTwoAMToday() {
const now = new Date();
const twoAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 2, 0, 0);
if (twoAM > now) {
twoAM.setDate(twoAM.getDate() - 1); // Yesterday's 2 AM
}
return Math.floor(twoAM.getTime() / 1000);
}
function getFourAMToday() {
const now = new Date();
const fourAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 4, 0, 0);
if (fourAM > now) {
fourAM.setDate(fourAM.getDate() - 1); // Yesterday's 4 AM
}
return Math.floor(fourAM.getTime() / 1000);
}
function getThreeDaysAgo() {
const threeDaysAgo = new Date();
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
threeDaysAgo.setHours(2, 0, 0, 0); // 2 AM three days ago
return Math.floor(threeDaysAgo.getTime() / 1000);
}
function createRealisticMockClients(pveCluster) {
return {
'primary': {
client: {
get: jest.fn().mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: pveCluster.nodes } });
}
if (path.includes('/qemu')) {
const node = path.split('/')[2];
const nodeVms = pveCluster.vms.filter(vm => vm.node === node);
return Promise.resolve({ data: { data: nodeVms } });
}
if (path.includes('/lxc')) {
const node = path.split('/')[2];
const nodeContainers = pveCluster.containers.filter(ct => ct.node === node);
return Promise.resolve({ data: { data: nodeContainers } });
}
return Promise.resolve({ data: { data: [] } });
})
},
config: { id: 'primary', name: 'Test Cluster' }
}
};
}
function createRealisticPbsClients(pbsBackups) {
return {
'pbs-main': {
client: {
get: jest.fn().mockImplementation((path) => {
if (path === '/nodes') {
return Promise.resolve({ data: { data: [{ node: 'pbs-node' }] } });
}
if (path === '/config/datastore') {
return Promise.resolve({ data: { data: [{ name: 'main-datastore' }] } });
}
if (path.includes('/admin/datastore/main-datastore/snapshots')) {
return Promise.resolve({ data: { data: pbsBackups.datastores[0].snapshots } });
}
if (path.includes('/status/datastore-usage')) {
return Promise.resolve({ data: { data: [{ store: 'main-datastore', total: 1000000000, used: 500000000 }] } });
}
return Promise.resolve({ data: { data: [] } });
})
},
config: { id: 'pbs-main', name: 'Test PBS' }
}
};
}
function createRealisticMockClientsWithMetrics(currentMetrics) {
return {
'primary': {
client: {
get: jest.fn().mockImplementation((path) => {
if (path.includes('/status')) {
const vmidMatch = path.match(/\/(qemu|lxc)\/(\d+)\/status/);
if (vmidMatch) {
const vmid = parseInt(vmidMatch[2]);
const metrics = currentMetrics[vmid];
if (metrics) {
return Promise.resolve({ data: { data: metrics } });
}
}
return Promise.resolve({ data: { data: { cpu: 0.1, memory: 1073741824, disk: 5368709120 } } });
}
if (path.includes('/rrddata')) {
return Promise.resolve({ data: { data: [{ time: Date.now() / 1000, cpu: 0.1 }] } });
}
return Promise.resolve({ data: { data: [] } });
})
},
config: { id: 'primary', name: 'Test Cluster' }
}
};
}
function createLargeRealisticCluster() {
// Create a larger but still realistic cluster
const nodes = ['desktop', 'delly', 'minipc', 'server1', 'server2'];
const largeCluster = {
nodes: nodes.map(name => ({ node: name, status: 'online', uptime: 86400 })),
vms: [],
containers: []
};
// Add realistic VMs and containers distributed across nodes
let vmid = 100;
nodes.forEach((node, nodeIndex) => {
// Add some VMs per node
for (let i = 0; i < 3; i++) {
largeCluster.vms.push({
vmid: vmid++,
name: `vm-${node}-${i}`,
status: Math.random() > 0.1 ? 'running' : 'stopped',
node: node
});
}
// Add some containers per node
for (let i = 0; i < 8; i++) {
largeCluster.containers.push({
vmid: vmid++,
name: `ct-${node}-${i}`,
status: Math.random() > 0.05 ? 'running' : 'stopped',
node: node
});
}
});
return createRealisticMockClients(largeCluster);
}