# Conflicts:
#	dashcaddy-api/.license-counter
#	dashcaddy-api/__tests__/docker-security.test.js
This commit is contained in:
2026-03-23 13:42:22 -07:00
211 changed files with 9758 additions and 8859 deletions

388
DESLOPIFICATION-ROADMAP.md Normal file
View File

@@ -0,0 +1,388 @@
# DashCaddy API Deslopification Roadmap
**Audited:** 2026-03-22
**Version:** 1.1.0
**Total Lines:** ~26,000 (API), ~10,000 (dashboard)
**Priority:** API-first (make backend powerful, clean dashboard follows naturally)
---
## Executive Summary
The DashCaddy API is **feature-complete and security-hardened**, but the codebase shows signs of rapid evolution. While functionally robust, it would significantly benefit from architectural refactoring to improve maintainability, testability, and long-term scalability.
### Key Strengths
✅ Comprehensive feature set (76+ app templates, Docker/Caddy/DNS management)
✅ Security-conscious (TOTP auth, AES-256-GCM credentials, CSRF protection, audit logging)
✅ Recent test coverage additions (auth, credentials, Docker security)
✅ Modular route organization (routes/ subdirectories)
✅ Shared context pattern for dependency injection
### Core Issues
**Monolithic `server.js`** (1960 lines) — initialization, middleware, utilities, business logic all in one file
**God object `ctx`** — 50+ properties/methods across multiple domains with hidden dependencies
**Inconsistent patterns** — routes use classes, factory functions, or flat modules with no standard
**No code standards** — ESLint installed but no config, no formatting rules
**Mixed concerns** — HTTP handlers, business logic, validation intertwined in route files
---
## Current Architecture
```
dashcaddy-api/
├── server.js (1960 lines) ← MAIN PROBLEM
│ ├── 89 require() statements
│ ├── 131 top-level declarations
│ ├── Middleware setup
│ ├── Context (`ctx`) assembly (50+ properties)
│ ├── Route mounting
│ ├── Error handlers
│ └── Server startup
├── routes/
│ ├── auth/ (5 files, modular) ✅
│ ├── config/ (4 files, modular) ✅
│ ├── apps/ (6 files, helpers pattern) ⚠️
│ ├── arr/ (4 files, helpers pattern) ⚠️
│ ├── recipes/ (3 files) ⚠️
│ └── *.js (19 flat route files) ❌
├── Managers (clean, well-separated)
│ ├── auth-manager.js (307 lines) ✅
│ ├── credential-manager.js (395 lines) ✅
│ ├── state-manager.js (237 lines) ✅
│ ├── backup-manager.js (835 lines) ⚠️
│ ├── health-checker.js (591 lines) ⚠️
│ └── update-manager.js (911 lines) ⚠️
├── Utilities
│ ├── input-validator.js (606 lines) ⚠️
│ ├── crypto-utils.js (340 lines) ✅
│ ├── middleware.js (430 lines) ⚠️
│ └── constants.js ✅
└── Templates
├── app-templates.js (2496 lines) ⚠️
└── recipe-templates.js (339 lines) ✅
```
**Legend:**
✅ Good structure
⚠️ Works but could be cleaner
❌ Needs refactoring
---
## Deslopification Phases
### Phase 1: Foundation & Standards (IMMEDIATE)
**Goal:** Establish code quality baseline before refactoring
**Effort:** 2-4 hours
**Risk:** Low (tooling only, no code changes)
#### 1.1 Code Standards Setup
- [ ] Create `.eslintrc.js` with recommended rules
- [ ] Add Prettier config (`.prettierrc`)
- [ ] Add npm scripts: `lint`, `lint:fix`, `format`
- [ ] Run `npm run lint:fix` and commit baseline cleanup
- [ ] Add pre-commit hooks (optional)
**Why first:** Establish formatting/style consistency before making structural changes. Prevents "should I refactor this while I'm here?" scope creep.
#### 1.2 Dependency Graph Documentation
- [ ] Map `ctx` properties → which routes actually use them
- [ ] Identify circular dependencies (if any)
- [ ] Document shared utilities used across routes
**Deliverable:** `DEPENDENCIES.md` — reference for refactoring decisions
---
### Phase 2: Extract & Organize (HIGH PRIORITY)
**Goal:** Break `server.js` into logical modules
**Effort:** 1-2 days
**Risk:** Medium (requires testing at each step)
#### 2.1 Split `server.js` into Layers
**Before:** 1960-line monolith
**After:** Clean initialization flow
Create new structure:
```
src/
├── app.js ← Express app setup (middleware, routes)
├── server.js ← Entry point (load config, start server)
├── config/
│ ├── index.js ← Load all config (env, files, constants)
│ ├── env.js ← Environment variable validation
│ └── paths.js ← Platform-specific paths
├── context/
│ ├── index.js ← Assemble context (DI container)
│ ├── docker.js ← Docker-related context properties
│ ├── caddy.js ← Caddy-related context properties
│ ├── dns.js ← DNS context
│ ├── session.js ← Session context
│ └── notification.js ← Notification context
├── middleware/
│ ├── index.js ← Export all middleware
│ ├── auth.js ← Move from middleware.js
│ ├── error.js ← Error handlers
│ └── security.js ← Helmet, CORS, CSRF
└── routes/
└── (existing structure)
```
**Migration Steps:**
1. Create `src/config/` — extract all config loading from `server.js`
2. Create `src/context/` — split god object into domain modules
3. Create `src/middleware/` — break up `middleware.js` (430 lines)
4. Create `src/app.js` — Express setup + route mounting
5. Slim `server.js` → minimal entry point (~50 lines)
**Tests:** Ensure existing test suite still passes after each step
---
### Phase 3: Route Standardization (MEDIUM PRIORITY)
**Goal:** Consistent route module pattern across entire API
**Effort:** 2-3 days
**Risk:** Medium (touching business logic)
#### 3.1 Establish Route Pattern
**Chosen Pattern:** Factory function with explicit dependencies
```javascript
// routes/services.js (before)
module.exports = (ctx) => {
const router = express.Router();
// ... uses ctx.docker, ctx.servicesStateManager, ctx.log, etc.
return router;
};
// routes/services.js (after)
module.exports = ({ docker, servicesStateManager, log, asyncHandler }) => {
const router = express.Router();
// ... explicitly passed dependencies
return router;
};
```
**Benefits:**
- Self-documenting (you see what each route needs)
- Easier testing (mock only what's used)
- No hidden dependencies via god object
#### 3.2 Refactor Routes by Priority
**Order:** Most-used routes first
1. **High-traffic routes:**
- `routes/services.js` (467 lines) — core service management
- `routes/containers.js` (246 lines) — Docker operations
- `routes/health.js` (297 lines) — health checks
- `routes/dns.js` (632 lines) — DNS management
2. **Auth routes** (already modular, just align pattern):
- `routes/auth/*`
3. **Feature routes:**
- `routes/apps/*`
- `routes/arr/*`
- `routes/recipes/*`
4. **Utility routes:**
- `routes/logs.js`
- `routes/backups.js`
- `routes/ca.js`
- etc.
**Per-route checklist:**
- [ ] Extract dependencies from `ctx` → explicit parameters
- [ ] Move business logic to service layer (if complex)
- [ ] Validate inputs at route boundary
- [ ] Return consistent error format
- [ ] Add route-level tests
---
### Phase 4: Service Layer Introduction (LOWER PRIORITY)
**Goal:** Separate business logic from HTTP handlers
**Effort:** 3-5 days
**Risk:** Medium-High (significant refactor)
**Problem:** Routes currently mix HTTP concerns with business logic:
```javascript
// Current: Everything in route handler
router.post('/deploy', async (req, res) => {
// 1. Parse request
// 2. Validate inputs
// 3. Business logic (complex Docker operations)
// 4. Error handling
// 5. Format response
});
```
**Solution:** Service layer pattern
```javascript
// routes/apps/deploy.js
router.post('/deploy', async (req, res) => {
const result = await appDeployService.deploy(req.body);
res.json({ success: true, data: result });
});
// services/app-deploy-service.js
class AppDeployService {
async deploy({ templateId, config }) {
// Pure business logic, no HTTP awareness
}
}
```
**Candidates for service extraction:**
- `services/docker-service.js` — container lifecycle, networking
- `services/caddy-service.js` — Caddyfile manipulation, reload
- `services/dns-service.js` — record management, zone operations
- `services/app-deploy-service.js` — template-based deployment
- `services/backup-service.js` — backup/restore workflows
**Benefits:**
- Routes become thin HTTP adapters (easy to test)
- Business logic testable without HTTP mocking
- Reusable across routes (e.g., CLI tools, cron jobs)
---
### Phase 5: Manager Cleanup (ONGOING)
**Goal:** Refine existing manager modules
**Effort:** 1-2 days (parallel to other phases)
#### Issues to Address
1. **`backup-manager.js` (835 lines)** — too large, split backup vs restore logic
2. **`update-manager.js` (911 lines)** — complex state machine, extract version comparison utilities
3. **`health-checker.js` (591 lines)** — separate health check logic from notification daemon
4. **`input-validator.js` (606 lines)** — split by domain (docker, caddy, dns validators)
**Approach:** Incremental splitting, preserve existing API
---
### Phase 6: Template Organization (LOW PRIORITY)
**Goal:** Make templates maintainable and extensible
**Effort:** 1 day
**Problem:** `app-templates.js` is 2496 lines (76 templates in one file)
**Solution:**
```
templates/
├── index.js ← Export TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS
├── apps/
│ ├── media/
│ │ ├── plex.js
│ │ ├── jellyfin.js
│ │ └── ...
│ ├── automation/
│ └── ...
└── recipes/
├── arr-stack.js
└── ...
```
**Benefits:**
- Easier to find/edit specific templates
- Contributors can add templates without merge conflicts
- Templates can import shared snippets (e.g., common env vars)
---
## Metrics & Success Criteria
### Code Quality Metrics (Before → After)
| Metric | Before | Target | How to Measure |
|--------|--------|--------|----------------|
| `server.js` lines | 1960 | <200 | `wc -l server.js` |
| Avg route file size | ~300 | <150 | `find routes -name '*.js' -exec wc -l {} + \| awk '{sum+=$1; n++} END {print sum/n}'` |
| `ctx` properties | 50+ | 0 (removed) | Manual count |
| ESLint errors | Unknown | 0 | `npm run lint` |
| Test coverage | ~30% | >60% | `npm run test:coverage` |
| Files >500 lines | 8 | <3 | `find . -name '*.js' -exec wc -l {} + \| awk '$1 > 500'` |
### Developer Experience Improvements
- **Onboarding:** New contributor should understand route structure in <10 minutes
- **Testing:** Mock only what you use (no god object sprawl)
- **Changes:** Touching one domain shouldn't require understanding entire codebase
- **Deployment:** Confidence that refactor didn't break anything (test suite)
---
## Risk Mitigation
### How to Refactor Safely
1. **Test suite first** — before touching code:
- Run existing tests: `npm test`
- Identify untested critical paths → add tests
- Establish coverage baseline
2. **Incremental changes**:
- Each phase = separate branch
- Each phase passes full test suite
- Deploy to test environment (Contabo) before merging
3. **Preserve API contract**:
- Frontend expects same endpoints/responses
- Dashboard shouldn't need changes during API refactor
- Version routes if breaking changes needed
4. **Rollback plan**:
- Git tags before each phase merge
- Keep old code in `legacy/` until confidence is high
- Document what changed in each PR
---
## Recommended Order of Execution
**Week 1: Foundation**
- Day 1-2: Phase 1 (ESLint, Prettier, dependency mapping)
- Day 3-5: Phase 2.1 (split `server.js`)
**Week 2: Routes**
- Day 1-3: Phase 3.1 (standardize top 5 routes)
- Day 4-5: Phase 3.2 (remaining routes)
**Week 3: Refinement**
- Day 1-3: Phase 4 (service layer for complex routes)
- Day 4-5: Phase 5 (manager cleanup)
**Week 4: Polish**
- Day 1-2: Phase 6 (template organization)
- Day 3-5: Documentation, final testing, deployment
**Total:** ~4 weeks part-time or ~2 weeks full-time
---
## Questions for Sami
Before starting, clarify:
1. **Testing strategy:** Current test coverage is partial. Should we:
- Write tests BEFORE refactoring (safer, slower)?
- Refactor with existing tests, add coverage later (faster, riskier)?
2. **Breaking changes:** Can we introduce backwards-incompatible API changes if we version routes (`/api/v2/...`)?
3. **Deployment cadence:** Should each phase deploy to production, or batch into one big release?
4. **Priority tweaks:** Does this roadmap align with "deslopify → market → sell" timeline, or should we focus only on the most visible pain points first?
---
## Next Steps
**If approved:**
1. Create feature branch: `refactor/deslopification-phase-1`
2. Add ESLint + Prettier configs
3. Run `npm run lint:fix` and commit baseline
4. Create `DEPENDENCIES.md` (ctx usage map)
5. Review with Sami before proceeding to Phase 2
**Estimated time to first visible improvement:** 1 week (server.js split + linting)

View File

@@ -1,36 +0,0 @@
# DashCaddy API Environment Variables
# Copy this file to .env and fill in your actual values
# NEVER commit .env to git!
# JWT Secret (auto-generated if not set)
# JWT_SECRET=your-secret-key-here
# Credential Storage
# CREDENTIALS_FILE=./credentials.json
# Docker Configuration
# DOCKER_SOCKET=/var/run/docker.sock
# Caddy Admin API
# CADDY_ADMIN_URL=http://localhost:2019
# DNS Configuration (Technitium)
# DNS_API_URL=http://localhost:5380
# DNS_TOKEN=your-dns-token-here
# Port Configuration
# PORT=3001
# Environment
# NODE_ENV=production
# Notification Providers (optional)
# DISCORD_WEBHOOK_URL=
# TELEGRAM_BOT_TOKEN=
# TELEGRAM_CHAT_ID=
# NTFY_SERVER_URL=https://ntfy.sh
# NTFY_TOPIC=
# Tailscale OAuth (optional)
# TAILSCALE_CLIENT_ID=
# TAILSCALE_CLIENT_SECRET=

View File

@@ -1,423 +0,0 @@
/**
* API Endpoint Tests
*
* Comprehensive tests for critical DashCaddy API endpoints
* Tests the migrated StateManager integration and core functionality
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Create a test instance of the app
// Note: We need to mock the service file to avoid affecting production
const testServicesFile = path.join(os.tmpdir(), `test-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `test-config-${Date.now()}.json`);
// Set test environment
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.CADDYFILE_PATH = path.join(os.tmpdir(), 'test-Caddyfile');
process.env.CADDY_ADMIN_URL = 'http://localhost:2019';
process.env.ENABLE_HEALTH_CHECKER = 'false'; // Disable to avoid background processes
process.env.NODE_ENV = 'test';
// Initialize test files
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
fs.writeFileSync(process.env.CADDYFILE_PATH, '# Test Caddyfile', 'utf8');
// Now require the app (after env setup)
const app = require('../server');
describe('API Endpoints', () => {
// Clean up before each test
beforeEach(() => {
fs.writeFileSync(testServicesFile, '[]', 'utf8');
});
// Clean up after all tests
afterAll(() => {
try {
fs.unlinkSync(testServicesFile);
fs.unlinkSync(testConfigFile);
fs.unlinkSync(process.env.CADDYFILE_PATH);
} catch (e) {
// Ignore cleanup errors
}
});
describe('GET /api/health', () => {
test('should return healthy status', async () => {
const res = await request(app).get('/api/health');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('status', 'ok');
expect(res.body).toHaveProperty('timestamp');
});
});
describe('GET /api/services', () => {
test('should return empty array initially', async () => {
const res = await request(app).get('/api/services');
expect(res.statusCode).toBe(200);
expect(Array.isArray(res.body)).toBe(true);
expect(res.body.length).toBe(0);
});
test('should return services after adding', async () => {
// Add a service first
await request(app)
.post('/api/services')
.send({
id: 'test-service',
name: 'Test Service',
logo: '/assets/test.png',
ip: 'localhost',
tailscaleOnly: false
});
// Now get services
const res = await request(app).get('/api/services');
expect(res.statusCode).toBe(200);
expect(res.body.length).toBe(1);
expect(res.body[0]).toMatchObject({
id: 'test-service',
name: 'Test Service'
});
});
test('should use StateManager (thread-safe)', async () => {
// This test verifies StateManager is being used
// by checking that the file is read correctly
// Manually write to file
const testData = [{ id: 'manual', name: 'Manual Service' }];
fs.writeFileSync(testServicesFile, JSON.stringify(testData, null, 2));
const res = await request(app).get('/api/services');
expect(res.statusCode).toBe(200);
expect(res.body).toEqual(testData);
});
});
describe('POST /api/services', () => {
test('should add a new service', async () => {
const newService = {
id: 'plex',
name: 'Plex',
logo: '/assets/plex.png',
ip: 'localhost',
tailscaleOnly: false
};
const res = await request(app)
.post('/api/services')
.send(newService);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
// Verify service was added
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(1);
expect(services[0].id).toBe(newService.id);
expect(services[0].name).toBe(newService.name);
expect(services[0].logo).toBe(newService.logo);
});
test('should reject duplicate service IDs', async () => {
const service = {
id: 'duplicate',
name: 'Duplicate Service'
};
// Add first time
await request(app).post('/api/services').send(service);
// Try to add again
const res = await request(app).post('/api/services').send(service);
expect(res.statusCode).toBe(409); // Conflict is the correct status code
expect(res.body).toHaveProperty('success', false);
expect(res.body.error).toContain('already exists');
});
test('should validate required fields', async () => {
const res = await request(app)
.post('/api/services')
.send({
// Missing 'id' and 'name'
logo: '/assets/test.png'
});
expect(res.statusCode).toBe(400);
expect(res.body).toHaveProperty('success', false);
});
test('should sanitize user input (XSS protection)', async () => {
const maliciousService = {
id: 'test<script>alert(1)</script>',
name: '<img src=x onerror=alert(1)>',
logo: '/assets/test.png'
};
const res = await request(app)
.post('/api/services')
.send(maliciousService);
// Input should be sanitized or rejected
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
// If the service was added, script tags should be removed or escaped
if (services.length > 0) {
expect(services[0].id).not.toContain('<script>');
expect(services[0].name).not.toContain('<img');
} else {
// If rejected entirely, that's also valid XSS protection
expect(res.statusCode).toBeGreaterThanOrEqual(400);
}
});
test('should handle concurrent POST requests (StateManager)', async () => {
// Test that StateManager prevents race conditions
const promises = [];
for (let i = 0; i < 5; i++) {
promises.push(
request(app).post('/api/services').send({
id: `service-${i}`,
name: `Service ${i}`
})
);
}
const results = await Promise.all(promises);
// All should succeed
results.forEach(res => {
expect(res.statusCode).toBe(200);
});
// Verify all 5 services were added (no data loss)
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(5);
});
});
describe('DELETE /api/services/:id', () => {
beforeEach(async () => {
// Add test services
await request(app).post('/api/services').send({
id: 'service1',
name: 'Service 1'
});
await request(app).post('/api/services').send({
id: 'service2',
name: 'Service 2'
});
});
test('should delete existing service', async () => {
const res = await request(app).delete('/api/services/service1');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
// Verify service was removed
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(1);
expect(services[0].id).toBe('service2');
});
test('should return 404 for non-existent service', async () => {
const res = await request(app).delete('/api/services/nonexistent');
expect(res.statusCode).toBe(404);
expect(res.body).toHaveProperty('success', false);
});
test('should handle concurrent deletes gracefully', async () => {
// Try to delete the same service twice simultaneously
const promises = [
request(app).delete('/api/services/service1'),
request(app).delete('/api/services/service1')
];
const results = await Promise.all(promises);
// One should succeed, one should fail
const statuses = results.map(r => r.statusCode).sort();
expect(statuses).toContain(200); // One success
expect(statuses).toContain(404); // One not found
});
});
describe('PUT /api/services', () => {
test('should bulk import services', async () => {
const services = [
{ id: 'plex', name: 'Plex' },
{ id: 'jellyfin', name: 'Jellyfin' },
{ id: 'emby', name: 'Emby' }
];
const res = await request(app)
.put('/api/services')
.send(services);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
// Verify all services were imported
const storedServices = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(storedServices.length).toBe(3);
});
test('should replace existing services on import', async () => {
// Add initial service
await request(app).post('/api/services').send({
id: 'old',
name: 'Old Service'
});
// Import new services (should replace)
const newServices = [
{ id: 'new1', name: 'New Service 1' },
{ id: 'new2', name: 'New Service 2' }
];
await request(app).put('/api/services').send(newServices);
// Verify old service was replaced
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(2);
expect(services.find(s => s.id === 'old')).toBeUndefined();
});
});
describe('GET /api/apps/templates', () => {
test('should return app templates', async () => {
const res = await request(app).get('/api/apps/templates');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('templates');
expect(res.body).toHaveProperty('categories');
// Should have 50+ templates
expect(Object.keys(res.body.templates).length).toBeGreaterThan(50);
});
test.skip('should filter by category', async () => {
// TODO: Category filtering not yet implemented in the API
// This test will be enabled once the feature is added
const res = await request(app)
.get('/api/apps/templates')
.query({ category: 'Media' });
expect(res.statusCode).toBe(200);
const templates = Object.values(res.body.templates);
templates.forEach(template => {
expect(template.category).toContain('Media');
});
});
});
describe('GET /api/apps/templates/:appId', () => {
test('should return specific app template', async () => {
const res = await request(app).get('/api/apps/templates/plex');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
expect(res.body).toHaveProperty('template');
expect(res.body.template).toHaveProperty('name', 'Plex');
expect(res.body.template).toHaveProperty('docker');
expect(res.body.template.docker).toHaveProperty('image');
});
test('should return 404 for unknown app', async () => {
const res = await request(app).get('/api/apps/templates/nonexistent');
expect(res.statusCode).toBe(404);
});
});
describe('GET /api/config', () => {
test('should return config', async () => {
const res = await request(app).get('/api/config');
expect(res.statusCode).toBe(200);
expect(typeof res.body).toBe('object');
});
});
describe('POST /api/config', () => {
test('should save config', async () => {
const config = {
theme: 'dark',
domain: 'test.local'
};
const res = await request(app)
.post('/api/config')
.send(config);
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success', true);
// Verify config was saved
const savedConfig = JSON.parse(fs.readFileSync(testConfigFile, 'utf8'));
expect(savedConfig).toMatchObject(config);
});
});
describe('Rate Limiting', () => {
test('should have rate limiting configured', async () => {
// Rate limiting is skipped in test env, so verify the middleware is mounted
// by checking that the response succeeds (rate limiter doesn't block)
const res = await request(app).get('/api/services');
expect(res.statusCode).toBe(200);
});
});
describe('Error Handling', () => {
test('should return 404 for unknown routes', async () => {
const res = await request(app).get('/api/nonexistent');
expect(res.statusCode).toBe(404);
});
test('should handle malformed JSON gracefully', async () => {
const res = await request(app)
.post('/api/services')
.set('Content-Type', 'application/json')
.send('{ invalid json }');
expect(res.statusCode).toBe(400);
});
});
describe('CORS Headers', () => {
test('should include CORS headers for allowed origin', async () => {
const res = await request(app)
.get('/api/services')
.set('Origin', 'http://localhost:3001');
expect(res.headers).toHaveProperty('access-control-allow-origin');
});
test('should handle OPTIONS preflight requests', async () => {
const res = await request(app)
.options('/api/services')
.set('Origin', 'http://localhost:3001');
expect(res.statusCode).toBe(204);
expect(res.headers).toHaveProperty('access-control-allow-methods');
});
});
});

View File

@@ -1,155 +0,0 @@
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
describe('APP_TEMPLATES', () => {
const templateIds = Object.keys(APP_TEMPLATES);
const templates = Object.values(APP_TEMPLATES);
const dockerTemplates = templates.filter(t => !t.isStaticSite);
test('exports a non-empty object', () => {
expect(typeof APP_TEMPLATES).toBe('object');
expect(templateIds.length).toBeGreaterThan(0);
});
test('contains at least 50 templates', () => {
expect(templateIds.length).toBeGreaterThanOrEqual(50);
});
test('every template has required field: name', () => {
templates.forEach(t => {
expect(typeof t.name).toBe('string');
expect(t.name.length).toBeGreaterThan(0);
});
});
test('every template has required field: description', () => {
templates.forEach(t => {
expect(typeof t.description).toBe('string');
expect(t.description.length).toBeGreaterThan(0);
});
});
test('every template has required field: category', () => {
templates.forEach(t => {
expect(typeof t.category).toBe('string');
});
});
test('every Docker template has required field: docker', () => {
dockerTemplates.forEach(t => {
expect(typeof t.docker).toBe('object');
expect(t.docker).not.toBeNull();
});
});
test('every Docker template.docker has an image string', () => {
dockerTemplates.forEach(t => {
expect(typeof t.docker.image).toBe('string');
expect(t.docker.image.length).toBeGreaterThan(0);
});
});
test('every Docker template.docker has a ports array', () => {
dockerTemplates.forEach(t => {
expect(Array.isArray(t.docker.ports)).toBe(true);
});
});
test('every template has a difficulty field', () => {
templates.forEach(t => {
expect(t.difficulty).toBeDefined();
});
});
test('every template difficulty is one of Easy, Intermediate, Advanced', () => {
const validDifficulties = Object.keys(DIFFICULTY_LEVELS);
templates.forEach(t => {
expect(validDifficulties).toContain(t.difficulty);
});
});
test('every template has a subdomain field', () => {
templates.forEach(t => {
expect(typeof t.subdomain).toBe('string');
expect(t.subdomain.length).toBeGreaterThan(0);
});
});
test('every template subdomain matches DNS label regex', () => {
const dnsLabelRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/;
templates.forEach(t => {
expect(t.subdomain).toMatch(dnsLabelRegex);
});
});
test('every Docker template has a defaultPort that is a valid port number', () => {
dockerTemplates.forEach(t => {
expect(typeof t.defaultPort).toBe('number');
expect(t.defaultPort).toBeGreaterThanOrEqual(1);
expect(t.defaultPort).toBeLessThanOrEqual(65535);
});
});
test('has at most one duplicate subdomain (known: networking overlap)', () => {
const subdomains = templates.map(t => t.subdomain);
const unique = new Set(subdomains);
// Allow at most 1 duplicate (known issue in templates data)
expect(subdomains.length - unique.size).toBeLessThanOrEqual(1);
});
test('every category referenced by a template exists in TEMPLATE_CATEGORIES', () => {
const validCategories = Object.keys(TEMPLATE_CATEGORIES);
templates.forEach(t => {
expect(validCategories).toContain(t.category);
});
});
});
describe('TEMPLATE_CATEGORIES', () => {
const categories = Object.values(TEMPLATE_CATEGORIES);
test('exports a non-empty object', () => {
expect(Object.keys(TEMPLATE_CATEGORIES).length).toBeGreaterThan(0);
});
test('every category has icon field', () => {
categories.forEach(c => {
expect(typeof c.icon).toBe('string');
expect(c.icon.length).toBeGreaterThan(0);
});
});
test('every category has color field', () => {
categories.forEach(c => {
expect(typeof c.color).toBe('string');
expect(c.color.length).toBeGreaterThan(0);
});
});
test('every color is a valid hex color', () => {
categories.forEach(c => {
expect(c.color).toMatch(/^#[0-9a-fA-F]{6}$/);
});
});
});
describe('DIFFICULTY_LEVELS', () => {
test('has Easy, Intermediate, Advanced keys', () => {
expect(DIFFICULTY_LEVELS).toHaveProperty('Easy');
expect(DIFFICULTY_LEVELS).toHaveProperty('Intermediate');
expect(DIFFICULTY_LEVELS).toHaveProperty('Advanced');
});
test('every level has color field', () => {
Object.values(DIFFICULTY_LEVELS).forEach(level => {
expect(typeof level.color).toBe('string');
expect(level.color).toMatch(/^#[0-9a-fA-F]{6}$/);
});
});
test('every level has description field', () => {
Object.values(DIFFICULTY_LEVELS).forEach(level => {
expect(typeof level.description).toBe('string');
expect(level.description.length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,121 +0,0 @@
/**
* Arr Route Tests
*
* Tests Smart Arr Connect endpoints (detect, connect, credentials, test-connection)
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `arr-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `arr-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Arr Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/arr/smart-detect', () => {
test('should return detection results', async () => {
const res = await request(app).get('/api/arr/smart-detect');
// Might return empty results if no Docker containers running
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('services');
expect(typeof res.body.services).toBe('object');
});
});
describe('POST /api/arr/smart-connect', () => {
test('should return empty results for empty request body', async () => {
const res = await request(app)
.post('/api/arr/smart-connect')
.send({});
// With no services provided, the endpoint completes with empty steps
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('steps');
}, 15000);
});
describe('POST /api/arr/test-connection', () => {
test('should fail when missing url or apiKey', async () => {
const res = await request(app)
.post('/api/arr/test-connection')
.send({ service: 'radarr' });
// Validation error returns 400
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain('required');
});
test('should reject invalid URL format', async () => {
const res = await request(app)
.post('/api/arr/test-connection')
.send({ url: 'not-a-url', service: 'radarr', apiKey: 'test-api-key-12345' });
expect(res.statusCode).toBe(400);
});
});
describe('GET /api/arr/credentials', () => {
test('should return credentials list', async () => {
const res = await request(app).get('/api/arr/credentials');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('credentials');
});
});
describe('POST /api/arr/credentials', () => {
test('should reject missing service field', async () => {
const res = await request(app)
.post('/api/arr/credentials')
.send({ apiKey: 'test-key' });
expect(res.statusCode).toBe(400);
});
test('should reject missing apiKey field', async () => {
const res = await request(app)
.post('/api/arr/credentials')
.send({ service: 'radarr' });
expect(res.statusCode).toBe(400);
});
test('should store valid credentials', async () => {
const res = await request(app)
.post('/api/arr/credentials')
.send({ service: 'radarr', apiKey: 'test-api-key-12345' });
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('DELETE /api/arr/credentials/:service', () => {
test('should handle deleting non-existent credentials', async () => {
const res = await request(app).delete('/api/arr/credentials/nonexistent');
// Should succeed (idempotent) or return 404
expect([200, 404]).toContain(res.statusCode);
});
});
});

View File

@@ -1,367 +0,0 @@
/**
* @jest-environment node
* Comprehensive tests for auth-manager.js
* Tests JWT generation/validation, API key management, and security boundaries
*/
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const AuthManager = require('../auth-manager');
const credentialManager = require('../credential-manager');
// Mock credential manager
jest.mock('../credential-manager');
jest.mock('../logger-utils', () => ({
safeLog: jest.fn()
}));
describe('AuthManager', () => {
let authManager;
beforeEach(() => {
authManager = new AuthManager();
jest.clearAllMocks();
credentialManager.save.mockResolvedValue(true);
credentialManager.get.mockResolvedValue(null);
credentialManager.delete.mockResolvedValue(true);
credentialManager.list.mockResolvedValue([]);
});
describe('JWT Generation', () => {
test('should generate valid JWT token', async () => {
const payload = { sub: 'user123', role: 'admin' };
const token = await authManager.generateJWT(payload);
expect(token).toBeDefined();
expect(typeof token).toBe('string');
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
});
test('should include required claims in JWT', async () => {
const payload = { sub: 'user123', role: 'admin' };
const token = await authManager.generateJWT(payload);
const decoded = jwt.decode(token);
expect(decoded.sub).toBe('user123');
expect(decoded.role).toBe('admin');
expect(decoded.iat).toBeDefined();
expect(decoded.exp).toBeDefined();
expect(decoded.scope).toEqual(['read', 'write']); // default scopes
});
test('should respect custom expiration time', async () => {
const payload = { sub: 'user123' };
const token = await authManager.generateJWT(payload, '1h');
const decoded = jwt.decode(token);
const expectedExp = decoded.iat + 3600; // 1 hour = 3600 seconds
expect(decoded.exp).toBeCloseTo(expectedExp, -1); // Allow 1 sec tolerance
});
test('should include custom scopes', async () => {
const payload = { sub: 'user123', scope: ['read'] };
const token = await authManager.generateJWT(payload);
const decoded = jwt.decode(token);
expect(decoded.scope).toEqual(['read']);
});
test('should reject JWT generation without sub claim', async () => {
const payload = { role: 'admin' }; // Missing sub
await expect(authManager.generateJWT(payload))
.rejects.toThrow('JWT payload must include "sub"');
});
test('should reject JWT generation with null payload', async () => {
await expect(authManager.generateJWT(null))
.rejects.toThrow();
});
});
describe('JWT Verification', () => {
test('should verify valid JWT token', async () => {
const payload = { sub: 'user123', role: 'admin' };
const token = await authManager.generateJWT(payload);
const verified = await authManager.verifyJWT(token);
expect(verified).toBeDefined();
expect(verified.userId).toBe('user123');
expect(verified.scope).toEqual(['read', 'write']);
expect(verified.iat).toBeDefined();
expect(verified.exp).toBeDefined();
});
test('should reject invalid JWT token', async () => {
const invalidToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature';
const verified = await authManager.verifyJWT(invalidToken);
expect(verified).toBeNull();
});
test('should reject malformed JWT token', async () => {
const verified = await authManager.verifyJWT('not-a-jwt-token');
expect(verified).toBeNull();
});
test('should reject expired JWT token', async () => {
const payload = { sub: 'user123' };
const token = await authManager.generateJWT(payload, '-1s'); // Already expired
// Wait a tiny bit to ensure expiration
await new Promise(resolve => setTimeout(resolve, 100));
const verified = await authManager.verifyJWT(token);
expect(verified).toBeNull();
});
test('should reject JWT with wrong signature', async () => {
const payload = { sub: 'user123' };
const wrongSecret = 'wrong-secret-key';
const token = jwt.sign(payload, wrongSecret);
const verified = await authManager.verifyJWT(token);
expect(verified).toBeNull();
});
test('should handle empty token gracefully', async () => {
const verified = await authManager.verifyJWT('');
expect(verified).toBeNull();
});
test('should handle null token gracefully', async () => {
const verified = await authManager.verifyJWT(null);
expect(verified).toBeNull();
});
});
describe('API Key Generation', () => {
test('should generate valid API key', async () => {
const result = await authManager.generateAPIKey('test-key');
expect(result).toBeDefined();
expect(result.key).toMatch(/^dk_[a-f0-9]{32}_[a-f0-9]{64}$/);
expect(result.id).toMatch(/^[a-f0-9]{32}$/);
expect(result.name).toBe('test-key');
expect(result.scopes).toEqual(['read', 'write']);
expect(result.createdAt).toBeDefined();
});
test('should generate unique API keys', async () => {
const key1 = await authManager.generateAPIKey('key1');
const key2 = await authManager.generateAPIKey('key2');
expect(key1.key).not.toBe(key2.key);
expect(key1.id).not.toBe(key2.id);
});
test('should accept custom scopes', async () => {
const result = await authManager.generateAPIKey('readonly-key', ['read']);
expect(result.scopes).toEqual(['read']);
});
test('should store API key in credential manager', async () => {
await authManager.generateAPIKey('test-key');
expect(credentialManager.save).toHaveBeenCalledWith(
expect.stringMatching(/^auth\.apikey\./),
expect.objectContaining({
keySecret: expect.any(String)
})
);
});
test('should store API key metadata', async () => {
await authManager.generateAPIKey('test-key', ['read']);
expect(credentialManager.save).toHaveBeenCalledWith(
expect.stringMatching(/^auth\.metadata\./),
expect.objectContaining({
name: 'test-key',
scopes: ['read'],
createdAt: expect.any(String)
})
);
});
test('should reject API key generation without name', async () => {
await expect(authManager.generateAPIKey(''))
.rejects.toThrow('API key name is required');
await expect(authManager.generateAPIKey(null))
.rejects.toThrow('API key name is required');
await expect(authManager.generateAPIKey(undefined))
.rejects.toThrow('API key name is required');
});
test('should reject non-string name', async () => {
await expect(authManager.generateAPIKey(123))
.rejects.toThrow('API key name is required');
await expect(authManager.generateAPIKey({}))
.rejects.toThrow('API key name is required');
});
});
describe('API Key Validation', () => {
test('should validate correct API key', async () => {
const { key, id } = await authManager.generateAPIKey('test-key');
// Mock credential manager to return the stored key
credentialManager.get.mockResolvedValueOnce({
keySecret: key.split('_')[2]
});
credentialManager.get.mockResolvedValueOnce({
name: 'test-key',
scopes: ['read', 'write'],
createdAt: new Date().toISOString()
});
const validated = await authManager.validateAPIKey(key);
expect(validated).toBeDefined();
expect(validated.valid).toBe(true);
expect(validated.keyId).toBe(id);
expect(validated.scopes).toEqual(['read', 'write']);
});
test('should reject malformed API key', async () => {
const validated = await authManager.validateAPIKey('not-an-api-key');
expect(validated.valid).toBe(false);
});
test('should reject API key with wrong prefix', async () => {
const validated = await authManager.validateAPIKey('sk_abc123_def456');
expect(validated.valid).toBe(false);
});
test('should reject non-existent API key', async () => {
const fakeKey = 'dk_' + crypto.randomBytes(16).toString('hex') + '_' + crypto.randomBytes(32).toString('hex');
credentialManager.get.mockResolvedValue(null); // Key doesn't exist
const validated = await authManager.validateAPIKey(fakeKey);
expect(validated.valid).toBe(false);
});
test('should reject revoked API key', async () => {
const { key } = await authManager.generateAPIKey('test-key');
credentialManager.get.mockResolvedValueOnce({
keySecret: key.split('_')[2],
revoked: true // Key is revoked
});
const validated = await authManager.validateAPIKey(key);
expect(validated.valid).toBe(false);
});
test('should handle null key gracefully', async () => {
const validated = await authManager.validateAPIKey(null);
expect(validated.valid).toBe(false);
});
test('should handle empty key gracefully', async () => {
const validated = await authManager.validateAPIKey('');
expect(validated.valid).toBe(false);
});
});
describe('API Key Revocation', () => {
test('should revoke API key', async () => {
const { id } = await authManager.generateAPIKey('test-key');
credentialManager.get.mockResolvedValue({
keySecret: 'test-secret'
});
const revoked = await authManager.revokeAPIKey(id);
expect(revoked).toBe(true);
expect(credentialManager.save).toHaveBeenCalledWith(
`auth.apikey.${id}`,
expect.objectContaining({
revoked: true,
revokedAt: expect.any(String)
})
);
});
test('should reject revoking non-existent key', async () => {
credentialManager.get.mockResolvedValue(null);
await expect(authManager.revokeAPIKey('nonexistent'))
.rejects.toThrow();
});
});
describe('API Key Listing', () => {
test('should list all API keys with metadata', async () => {
credentialManager.list.mockResolvedValue([
'auth.metadata.key1',
'auth.metadata.key2'
]);
credentialManager.get.mockResolvedValueOnce({
name: 'Key 1',
scopes: ['read'],
createdAt: '2026-01-01T00:00:00Z'
});
credentialManager.get.mockResolvedValueOnce({
name: 'Key 2',
scopes: ['read', 'write'],
createdAt: '2026-01-02T00:00:00Z'
});
const keys = await authManager.listAPIKeys();
expect(keys).toHaveLength(2);
expect(keys[0].name).toBe('Key 1');
expect(keys[1].name).toBe('Key 2');
});
test('should return empty array when no keys exist', async () => {
credentialManager.list.mockResolvedValue([]);
const keys = await authManager.listAPIKeys();
expect(keys).toEqual([]);
});
});
describe('Security Boundaries', () => {
test('should not log sensitive token data', async () => {
const payload = { sub: 'user123' };
const token = await authManager.generateJWT(payload);
// Logger should never be called with the actual token
const { safeLog } = require('../logger-utils');
const calls = safeLog.mock.calls.flat();
expect(calls.some(arg => String(arg).includes(token))).toBe(false);
});
test('should not log API key secrets', async () => {
const { key } = await authManager.generateAPIKey('test-key');
const { safeLog } = require('../logger-utils');
const calls = safeLog.mock.calls.flat();
expect(calls.some(arg => String(arg).includes(key))).toBe(false);
});
test('should generate cryptographically secure API keys', async () => {
const key1 = await authManager.generateAPIKey('key1');
const key2 = await authManager.generateAPIKey('key2');
// Keys should be unrelated (not sequential)
expect(parseInt(key1.id, 16)).not.toBe(parseInt(key2.id, 16) + 1);
expect(parseInt(key1.id, 16)).not.toBe(parseInt(key2.id, 16) - 1);
});
});
});

View File

@@ -1,127 +0,0 @@
/**
* Auth Route Tests
*
* Tests TOTP configuration, session management, and SSO auth gate
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `auth-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `auth-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Auth Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/totp/config', () => {
test('should return TOTP configuration', async () => {
const res = await request(app).get('/api/totp/config');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.config).toHaveProperty('enabled');
expect(res.body.config).toHaveProperty('sessionDuration');
expect(res.body.config).toHaveProperty('isSetUp');
});
});
describe('POST /api/totp/setup', () => {
test('should generate QR code and secret', async () => {
const res = await request(app)
.post('/api/totp/setup')
.send({});
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('qrCode');
expect(res.body).toHaveProperty('manualKey');
expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/);
}, 15000);
test('should accept user-provided secret', async () => {
const res = await request(app)
.post('/api/totp/setup')
.send({ secret: 'JBSWY3DPEHPK3PXP' });
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.imported).toBe(true);
expect(res.body.manualKey).toBe('JBSWY3DPEHPK3PXP');
});
test('should reject invalid secret format', async () => {
const res = await request(app)
.post('/api/totp/setup')
.send({ secret: 'not-base32!' });
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain('Invalid secret');
});
});
describe('POST /api/totp/verify', () => {
test('should reject missing code', async () => {
const res = await request(app)
.post('/api/totp/verify')
.send({});
// Should fail — no code provided
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
test('should reject invalid code', async () => {
const res = await request(app)
.post('/api/totp/verify')
.send({ code: '000000' });
// Should fail — wrong code (TOTP not set up or wrong)
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
});
describe('GET /api/totp/check-session', () => {
test('should return session status', async () => {
const res = await request(app).get('/api/totp/check-session');
// If TOTP is not enabled, should return authenticated: true
// If enabled, should return 401 (no valid session)
expect([200, 401]).toContain(res.statusCode);
expect(res.body).toHaveProperty('authenticated');
});
});
describe('GET /api/auth/gate/:serviceId', () => {
test('should handle unknown service', async () => {
const res = await request(app).get('/api/auth/gate/nonexistent');
// Should return 200 with credentialsInjected: false (no creds found)
// or 401 if TOTP required
expect([200, 401]).toContain(res.statusCode);
});
});
describe('GET /api/auth/app-token/:serviceId', () => {
test('should handle unknown service', async () => {
const res = await request(app).get('/api/auth/app-token/nonexistent');
// Should return 404 (service not found) or 401 (TOTP required)
expect([401, 404, 500]).toContain(res.statusCode);
});
});
});

View File

@@ -1,209 +0,0 @@
const crypto = require('crypto');
const backupManager = require('../backup-manager');
beforeEach(() => {
// Reset singleton state
backupManager.history = [];
backupManager.config = { backups: {}, defaultRetention: { keep: 7 } };
backupManager.running = false;
for (const [, job] of backupManager.scheduledJobs.entries()) {
clearInterval(job);
}
backupManager.scheduledJobs.clear();
});
afterAll(() => {
backupManager.stop();
});
describe('calculateChecksum', () => {
test('returns SHA-256 hex string', () => {
const data = Buffer.from('test data');
const checksum = backupManager.calculateChecksum(data);
expect(checksum).toMatch(/^[0-9a-f]{64}$/);
});
test('same data produces same checksum', () => {
const data = Buffer.from('consistent');
expect(backupManager.calculateChecksum(data)).toBe(backupManager.calculateChecksum(data));
});
test('different data produces different checksum', () => {
const a = backupManager.calculateChecksum(Buffer.from('aaa'));
const b = backupManager.calculateChecksum(Buffer.from('bbb'));
expect(a).not.toBe(b);
});
});
describe('compressBackup / decompressBackup', () => {
test('round-trip preserves data', async () => {
const original = { services: [{ id: 'test', name: 'Test' }], config: { theme: 'dark' } };
const compressed = await backupManager.compressBackup(original);
const decompressed = await backupManager.decompressBackup(compressed);
expect(decompressed).toEqual(original);
});
test('compressed output is a Buffer', async () => {
const compressed = await backupManager.compressBackup({ test: true });
expect(Buffer.isBuffer(compressed)).toBe(true);
});
});
describe('encryptBackup / decryptBackup', () => {
const testKey = crypto.randomBytes(32).toString('hex');
test('round-trip preserves data with valid key', async () => {
const original = Buffer.from('backup data here');
const encrypted = await backupManager.encryptBackup(original, testKey);
const decrypted = await backupManager.decryptBackup(encrypted, testKey);
expect(decrypted.toString()).toBe(original.toString());
});
test('produces a non-empty buffer', async () => {
const original = Buffer.from('backup data here');
const encrypted = await backupManager.encryptBackup(original, testKey);
expect(Buffer.isBuffer(encrypted)).toBe(true);
expect(encrypted.length).toBeGreaterThan(0);
});
test('output differs from input', async () => {
const original = Buffer.from('backup data here');
const encrypted = await backupManager.encryptBackup(original, testKey);
expect(encrypted.toString()).not.toBe(original.toString());
});
test('throws on invalid encrypted format', async () => {
await expect(backupManager.decryptBackup(Buffer.from('bad'), testKey)).rejects.toThrow();
});
test('throws on wrong key', async () => {
const original = Buffer.from('secret data');
const encrypted = await backupManager.encryptBackup(original, testKey);
const wrongKey = crypto.randomBytes(32).toString('hex');
await expect(backupManager.decryptBackup(encrypted, wrongKey)).rejects.toThrow();
});
});
describe('scheduleBackup', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('parses hourly schedule', () => {
backupManager.scheduleBackup('test', { schedule: 'hourly' });
expect(backupManager.scheduledJobs.has('test')).toBe(true);
});
test('parses daily schedule', () => {
backupManager.scheduleBackup('test', { schedule: 'daily' });
expect(backupManager.scheduledJobs.has('test')).toBe(true);
});
test('parses weekly schedule', () => {
backupManager.scheduleBackup('test', { schedule: 'weekly' });
expect(backupManager.scheduledJobs.has('test')).toBe(true);
});
test('parses monthly schedule', () => {
backupManager.scheduleBackup('test', { schedule: 'monthly' });
expect(backupManager.scheduledJobs.has('test')).toBe(true);
});
test('parses custom numeric minute schedule', () => {
backupManager.scheduleBackup('test', { schedule: '30' });
expect(backupManager.scheduledJobs.has('test')).toBe(true);
});
test('logs error for invalid schedule', () => {
backupManager.scheduleBackup('test', { schedule: 'invalid' });
expect(backupManager.scheduledJobs.has('test')).toBe(false);
});
});
describe('addToHistory', () => {
test('appends entry to history', () => {
backupManager.addToHistory({ id: 'b1', status: 'success' });
expect(backupManager.history).toHaveLength(1);
});
test('trims history to 100 entries', () => {
for (let i = 0; i < 105; i++) {
backupManager.addToHistory({ id: `b${i}`, status: 'success' });
}
expect(backupManager.history.length).toBeLessThanOrEqual(100);
});
});
describe('getHistory', () => {
test('returns entries in reverse order', () => {
backupManager.addToHistory({ id: 'first' });
backupManager.addToHistory({ id: 'second' });
const history = backupManager.getHistory();
expect(history[0].id).toBe('second');
expect(history[1].id).toBe('first');
});
test('respects limit parameter', () => {
for (let i = 0; i < 10; i++) {
backupManager.addToHistory({ id: `b${i}` });
}
expect(backupManager.getHistory(3)).toHaveLength(3);
});
});
describe('getConfig / updateConfig', () => {
test('getConfig returns current config', () => {
const config = backupManager.getConfig();
expect(config).toHaveProperty('backups');
});
test('updateConfig merges new config', () => {
backupManager.updateConfig({ backups: { daily: { enabled: true, schedule: 'daily' } } });
expect(backupManager.config.backups.daily).toBeDefined();
});
});
describe('start / stop', () => {
test('start sets running flag', () => {
backupManager.start();
expect(backupManager.running).toBe(true);
backupManager.stop();
});
test('start is idempotent', () => {
backupManager.start();
backupManager.start();
expect(backupManager.running).toBe(true);
backupManager.stop();
});
test('stop clears running flag and jobs', () => {
backupManager.start();
backupManager.stop();
expect(backupManager.running).toBe(false);
expect(backupManager.scheduledJobs.size).toBe(0);
});
});
describe('cleanupOldBackups', () => {
test('keeps configured number of backups', async () => {
// Add 5 successful backups for 'daily'
for (let i = 0; i < 5; i++) {
backupManager.history.push({
id: `daily-${i}`,
name: 'daily',
status: 'success',
timestamp: new Date(Date.now() - i * 86400000).toISOString(),
locations: [{ type: 'local', path: `/tmp/fake-${i}.backup` }]
});
}
await backupManager.cleanupOldBackups('daily', { keep: 3 });
const remaining = backupManager.history.filter(b => b.name === 'daily' && b.status === 'success');
expect(remaining.length).toBe(3);
});
});

View File

@@ -1,64 +0,0 @@
/**
* Browse Route Tests
*
* Tests file browsing endpoints (roots, directories)
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `browse-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `browse-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Browse Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/browse/roots', () => {
test('should return 200 with success:true and roots array', async () => {
const res = await request(app).get('/api/browse/roots');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.roots)).toBe(true);
});
});
describe('GET /api/browse/directories', () => {
test('should return 400 when path is missing', async () => {
// When no path is provided and no MEDIA_BROWSE_ROOTS are configured,
// the endpoint returns the roots listing (empty items) with success
const res = await request(app).get('/api/browse/directories');
// Without MEDIA_BROWSE_ROOTS set, returns empty items list
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.items)).toBe(true);
expect(res.body.items.length).toBe(0);
});
test('should return an error for path not in browseable roots', async () => {
const res = await request(app)
.get('/api/browse/directories')
.query({ path: '/nonexistent' });
// Path is not in any configured browse root, so should return 400
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
});
});

View File

@@ -1,117 +0,0 @@
/**
* Config Route Tests
*
* Tests DashCaddy configuration endpoints (get, save, delete)
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `config-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `config-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Config Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
// Reset config file before each test to avoid leaking state
beforeEach(() => {
fs.writeFileSync(testConfigFile, '{}', 'utf8');
});
describe('GET /api/config', () => {
test('should return 200 with config object', async () => {
const res = await request(app).get('/api/config');
expect(res.statusCode).toBe(200);
expect(typeof res.body).toBe('object');
});
});
describe('POST /api/config', () => {
test('should return 200 with success:true for valid config', async () => {
const validConfig = {
tld: 'sami',
theme: 'dark',
timezone: 'America/New_York'
};
const res = await request(app)
.post('/api/config')
.send(validConfig);
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
// Verify config was persisted
const savedConfig = JSON.parse(fs.readFileSync(testConfigFile, 'utf8'));
expect(savedConfig.tld).toBe('sami');
expect(savedConfig.theme).toBe('dark');
});
test('should return 400 for invalid config body', async () => {
// Send a non-object body (string) which fails the typeof check
const res = await request(app)
.post('/api/config')
.set('Content-Type', 'application/json')
.send('"not an object"');
expect(res.statusCode).toBe(400);
});
test('should return 400 for config with invalid field values', async () => {
const invalidConfig = {
tld: 123, // tld must be a string
dns: 'not-an-object' // dns must be an object
};
const res = await request(app)
.post('/api/config')
.send(invalidConfig);
expect(res.statusCode).toBe(400);
});
});
describe('DELETE /api/config', () => {
test('should return 200 and reset config', async () => {
// First save a config
await request(app)
.post('/api/config')
.send({ tld: 'sami', theme: 'dark' });
// Then delete it
const res = await request(app).delete('/api/config');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
// Config file should no longer exist
expect(fs.existsSync(testConfigFile)).toBe(false);
});
test('should return 200 even when config does not exist', async () => {
// Remove the config file first
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
const res = await request(app).delete('/api/config');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
});
});
});

View File

@@ -1,73 +0,0 @@
/**
* Container Route Tests
*
* Tests Docker container management endpoints (start, stop, restart, discover)
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `containers-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `containers-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Container Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('POST /api/containers/:id/start', () => {
test('should return error for invalid container ID', async () => {
const res = await request(app)
.post('/api/containers/nonexistent-container-id/start');
// Docker will reject the invalid container ID with an error
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.body.success).toBe(false);
});
});
describe('POST /api/containers/:id/stop', () => {
test('should return error for invalid container ID', async () => {
const res = await request(app)
.post('/api/containers/nonexistent-container-id/stop');
// Docker will reject the invalid container ID with an error
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.body.success).toBe(false);
});
});
describe('POST /api/containers/:id/restart', () => {
test('should return error for invalid container ID', async () => {
const res = await request(app)
.post('/api/containers/nonexistent-container-id/restart');
// Docker will reject the invalid container ID with an error
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.body.success).toBe(false);
});
});
describe('GET /api/containers/discover', () => {
test('should return 200 with containers array', async () => {
const res = await request(app).get('/api/containers/discover');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.containers)).toBe(true);
});
});
});

View File

@@ -1,838 +0,0 @@
// credential-manager depends on keychain-manager and crypto-utils (both singletons).
// crypto-utils is already initialized via jest.setup.js env var.
// keychain-manager may not have OS keychain available in test env.
const fs = require('fs');
const os = require('os');
const path = require('path');
const credentialManager = require('../credential-manager');
// Use a temp file for credentials in tests
const TEMP_CREDS_FILE = path.join(os.tmpdir(), 'dashcaddy-test-creds.json');
beforeEach(() => {
// Reset singleton state
credentialManager.cache.clear();
// Clean up temp file
if (fs.existsSync(TEMP_CREDS_FILE)) {
fs.unlinkSync(TEMP_CREDS_FILE);
}
});
afterAll(() => {
if (fs.existsSync(TEMP_CREDS_FILE)) {
fs.unlinkSync(TEMP_CREDS_FILE);
}
});
describe('store', () => {
test('rejects invalid key (null)', async () => {
const result = await credentialManager.store(null, 'value');
expect(result).toBe(false);
});
test('rejects invalid key (non-string)', async () => {
const result = await credentialManager.store(123, 'value');
expect(result).toBe(false);
});
test('rejects invalid value (null)', async () => {
const result = await credentialManager.store('key', null);
expect(result).toBe(false);
});
test('rejects invalid value (non-string)', async () => {
const result = await credentialManager.store('key', 123);
expect(result).toBe(false);
});
test('stores credential and caches it', async () => {
const result = await credentialManager.store('test.key', 'secret123');
expect(result).toBe(true);
expect(credentialManager.cache.get('test.key')).toBe('secret123');
});
test('handles very long credential values', async () => {
const longValue = 'x'.repeat(100000); // 100KB value
const result = await credentialManager.store('long.value', longValue);
expect(result).toBe(true);
const retrieved = await credentialManager.retrieve('long.value');
expect(retrieved).toBe(longValue);
});
test('handles special characters in keys', async () => {
const specialKeys = [
'key.with.dots',
'key-with-dashes',
'key_with_underscores',
'key:with:colons',
'key/with/slashes'
];
for (const key of specialKeys) {
const result = await credentialManager.store(key, 'value');
expect(result).toBe(true);
expect(await credentialManager.retrieve(key)).toBe('value');
}
});
test('handles special characters in values', async () => {
const specialValues = [
'password!@#$%^&*()',
'token\nwith\nnewlines',
'json{"key":"value"}',
'unicode=ƒöÉ=ƒöæG£à',
'quotes"and\'apostrophes'
];
for (let i = 0; i < specialValues.length; i++) {
const key = `special.${i}`;
const result = await credentialManager.store(key, specialValues[i]);
expect(result).toBe(true);
expect(await credentialManager.retrieve(key)).toBe(specialValues[i]);
}
});
test('overwrites existing credential', async () => {
await credentialManager.store('overwrite.key', 'original');
expect(await credentialManager.retrieve('overwrite.key')).toBe('original');
await credentialManager.store('overwrite.key', 'updated');
expect(await credentialManager.retrieve('overwrite.key')).toBe('updated');
});
});
describe('retrieve', () => {
test('returns cached value when available', async () => {
credentialManager.cache.set('cached.key', 'cached-value');
const result = await credentialManager.retrieve('cached.key');
expect(result).toBe('cached-value');
});
test('returns null for non-existent key', async () => {
const result = await credentialManager.retrieve('nonexistent');
expect(result).toBeNull();
});
test('handles empty string key', async () => {
const result = await credentialManager.retrieve('');
expect(result).toBeNull();
});
});
describe('store + retrieve round-trip', () => {
test('retrieves what was stored', async () => {
await credentialManager.store('roundtrip.key', 'my-secret');
// Clear cache to force file read
credentialManager.cache.clear();
const result = await credentialManager.retrieve('roundtrip.key');
expect(result).toBe('my-secret');
});
test('handles binary-like data (base64)', async () => {
const binaryData = Buffer.from('binary content').toString('base64');
await credentialManager.store('binary.key', binaryData);
credentialManager.cache.clear();
const result = await credentialManager.retrieve('binary.key');
expect(result).toBe(binaryData);
});
});
describe('delete', () => {
test('removes from cache', async () => {
await credentialManager.store('delete.key', 'value');
expect(credentialManager.cache.has('delete.key')).toBe(true);
await credentialManager.delete('delete.key');
expect(credentialManager.cache.has('delete.key')).toBe(false);
});
test('deleted credential cannot be retrieved', async () => {
await credentialManager.store('delete2.key', 'value');
await credentialManager.delete('delete2.key');
credentialManager.cache.clear();
const result = await credentialManager.retrieve('delete2.key');
expect(result).toBeNull();
});
test('deleting non-existent key does not throw', async () => {
await expect(credentialManager.delete('nonexistent')).resolves.not.toThrow();
});
test('multiple deletes are idempotent', async () => {
await credentialManager.store('idempotent.key', 'value');
await credentialManager.delete('idempotent.key');
await credentialManager.delete('idempotent.key');
await credentialManager.delete('idempotent.key');
expect(await credentialManager.retrieve('idempotent.key')).toBeNull();
});
});
describe('list', () => {
test('returns array of credential keys', async () => {
await credentialManager.store('list.a', 'val1');
await credentialManager.store('list.b', 'val2');
const keys = await credentialManager.list();
expect(keys).toContain('list.a');
expect(keys).toContain('list.b');
});
test('returns empty array when no credentials', async () => {
const keys = await credentialManager.list();
expect(Array.isArray(keys)).toBe(true);
});
test('does not include deleted keys', async () => {
await credentialManager.store('list.deleted', 'value');
await credentialManager.delete('list.deleted');
const keys = await credentialManager.list();
expect(keys).not.toContain('list.deleted');
});
});
describe('getMetadata', () => {
test('returns metadata for existing key', async () => {
await credentialManager.store('meta.key', 'val', { description: 'Test credential' });
const meta = await credentialManager.getMetadata('meta.key');
expect(meta).toEqual({ description: 'Test credential' });
});
test('returns null for non-existent key', async () => {
const meta = await credentialManager.getMetadata('nonexistent');
expect(meta).toBeNull();
});
test('handles metadata with multiple fields', async () => {
const metadata = {
description: 'API Key',
service: 'GitHub',
expiresAt: '2026-12-31',
createdBy: 'admin'
};
await credentialManager.store('meta.complex', 'value', metadata);
const retrieved = await credentialManager.getMetadata('meta.complex');
expect(retrieved).toEqual(metadata);
});
});
describe('exportBackup / importBackup', () => {
test('export returns encrypted string', async () => {
await credentialManager.store('backup.key', 'backup-value');
const backup = await credentialManager.exportBackup();
expect(typeof backup).toBe('string');
expect(backup.split(':').length).toBe(3); // iv:authTag:ciphertext
});
test('import restores credentials from backup', async () => {
await credentialManager.store('backup.key', 'backup-value');
const backup = await credentialManager.exportBackup();
// Clear everything
await credentialManager.delete('backup.key');
credentialManager.cache.clear();
// Import backup
const result = await credentialManager.importBackup(backup);
expect(result).toBe(true);
// Verify restored
const keys = await credentialManager.list();
expect(keys).toContain('backup.key');
});
test('importBackup rejects unsupported version', async () => {
const cryptoUtils = require('../crypto-utils');
const badBackup = cryptoUtils.encrypt(JSON.stringify({ version: '99.0', credentials: {} }));
const result = await credentialManager.importBackup(badBackup);
expect(result).toBe(false);
});
test('export includes metadata', async () => {
await credentialManager.store('backup.meta', 'value', { description: 'Metadata test' });
const backup = await credentialManager.exportBackup();
await credentialManager.delete('backup.meta');
await credentialManager.importBackup(backup);
const meta = await credentialManager.getMetadata('backup.meta');
expect(meta).toHaveProperty('description', 'Metadata test');
});
test('import does not corrupt existing credentials', async () => {
await credentialManager.store('existing.key', 'existing-value');
// Create backup with different credential
await credentialManager.store('backup.key', 'backup-value');
const backup = await credentialManager.exportBackup();
await credentialManager.delete('backup.key');
// Import should add backup.key without affecting existing.key
await credentialManager.importBackup(backup);
expect(await credentialManager.retrieve('existing.key')).toBe('existing-value');
expect(await credentialManager.retrieve('backup.key')).toBe('backup-value');
});
test('handles empty backup', async () => {
const cryptoUtils = require('../crypto-utils');
const emptyBackup = cryptoUtils.encrypt(JSON.stringify({ version: '1.0', credentials: {} }));
const result = await credentialManager.importBackup(emptyBackup);
expect(result).toBe(true);
});
test('handles large backup (stress test)', async () => {
// Create 100 credentials
for (let i = 0; i < 100; i++) {
await credentialManager.store(`stress.${i}`, `value${i}`);
}
const backup = await credentialManager.exportBackup();
expect(backup.length).toBeGreaterThan(1000);
// Clear and restore
for (let i = 0; i < 100; i++) {
await credentialManager.delete(`stress.${i}`);
}
const result = await credentialManager.importBackup(backup);
expect(result).toBe(true);
const keys = await credentialManager.list();
expect(keys.filter(k => k.startsWith('stress.')).length).toBe(100);
});
});
describe('migrateToEncrypted', () => {
test('returns migration count', async () => {
const result = await credentialManager.migrateToEncrypted();
expect(result).toHaveProperty('migrated');
expect(result).toHaveProperty('skipped');
expect(result).toHaveProperty('total');
});
test('migration is idempotent', async () => {
const result1 = await credentialManager.migrateToEncrypted();
const result2 = await credentialManager.migrateToEncrypted();
expect(result2.migrated).toBe(0); // Nothing left to migrate
});
});
describe('Concurrent Access', () => {
test('handles concurrent writes to same key', async () => {
const promises = [
credentialManager.store('concurrent.key', 'value1'),
credentialManager.store('concurrent.key', 'value2'),
credentialManager.store('concurrent.key', 'value3')
];
await Promise.all(promises);
// One of them should have won
const final = await credentialManager.retrieve('concurrent.key');
expect(['value1', 'value2', 'value3']).toContain(final);
});
test('handles concurrent writes to different keys', async () => {
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(credentialManager.store(`concurrent.${i}`, `value${i}`));
}
await Promise.all(promises);
// All should be stored
for (let i = 0; i < 10; i++) {
const value = await credentialManager.retrieve(`concurrent.${i}`);
expect(value).toBe(`value${i}`);
}
});
test('handles concurrent read/write', async () => {
await credentialManager.store('readwrite.key', 'initial');
const promises = [
credentialManager.retrieve('readwrite.key'),
credentialManager.store('readwrite.key', 'updated'),
credentialManager.retrieve('readwrite.key')
];
const results = await Promise.all(promises);
// Should not throw or corrupt
expect(results[0]).toBeTruthy();
expect(results[1]).toBe(true);
expect(results[2]).toBeTruthy();
});
});
describe('Edge Cases', () => {
test('handles credential with empty string value', async () => {
const result = await credentialManager.store('empty.value', '');
expect(result).toBe(true);
expect(await credentialManager.retrieve('empty.value')).toBe('');
});
test('does not leak credentials in error messages', async () => {
// This is a security test - errors should not contain credential values
try {
// Try to trigger an error condition
await credentialManager.store('error.test', 'secret-password-123');
// Force an error by corrupting internal state
} catch (error) {
expect(error.message).not.toContain('secret-password-123');
}
});
test('cache size does not grow indefinitely', async () => {
// Store many credentials
for (let i = 0; i < 1000; i++) {
await credentialManager.store(`cache.${i}`, `value${i}`);
}
// Cache should still work
const result = await credentialManager.retrieve('cache.999');
expect(result).toBe('value999');
});
});
describe('Cache Behavior', () => {
test('cache speeds up repeated retrievals', async () => {
await credentialManager.store('cache.perf', 'value');
// First retrieval (from disk)
const start1 = Date.now();
await credentialManager.retrieve('cache.perf');
const time1 = Date.now() - start1;
// Second retrieval (from cache)
const start2 = Date.now();
await credentialManager.retrieve('cache.perf');
const time2 = Date.now() - start2;
// Cached should be faster (though this is not a guarantee in all test envs)
expect(time2).toBeLessThanOrEqual(time1 + 5);
});
test('cache invalidation on delete', async () => {
await credentialManager.store('cache.delete', 'value');
expect(credentialManager.cache.has('cache.delete')).toBe(true);
await credentialManager.delete('cache.delete');
expect(credentialManager.cache.has('cache.delete')).toBe(false);
});
test('cache invalidation on store', async () => {
await credentialManager.store('cache.update', 'original');
expect(credentialManager.cache.get('cache.update')).toBe('original');
await credentialManager.store('cache.update', 'updated');
expect(credentialManager.cache.get('cache.update')).toBe('updated');
});
});
// ========== EXTENDED COVERAGE TESTS ==========
// Additional tests for concurrency, edge cases, encryption, and recovery
describe('Credential Manager - Extended Coverage', () => {
describe('Concurrent Access', () => {
test('should handle concurrent store operations', async () => {
// Store multiple credentials concurrently
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(credentialManager.store(`concurrent.${i}`, `value${i}`));
}
const results = await Promise.all(promises);
// All should succeed
results.forEach(result => expect(result).toBe(true));
// All should be retrievable
for (let i = 0; i < 10; i++) {
const value = await credentialManager.retrieve(`concurrent.${i}`);
expect(value).toBe(`value${i}`);
}
});
test('should handle concurrent retrieve operations', async () => {
// Store a credential
await credentialManager.store('shared.key', 'shared-value');
// Retrieve it concurrently multiple times
const promises = [];
for (let i = 0; i < 20; i++) {
promises.push(credentialManager.retrieve('shared.key'));
}
const results = await Promise.all(promises);
// All should return the same value
results.forEach(result => expect(result).toBe('shared-value'));
});
test('should handle concurrent store/retrieve mix', async () => {
const operations = [];
// Mix of stores and retrieves
for (let i = 0; i < 5; i++) {
operations.push(credentialManager.store(`mix.${i}`, `value${i}`));
operations.push(credentialManager.retrieve(`mix.${i}`));
}
// Should not throw
await expect(Promise.all(operations)).resolves.toBeDefined();
});
test('should handle concurrent delete operations safely', async () => {
await credentialManager.store('delete.concurrent', 'value');
// Try to delete the same key concurrently
const promises = [
credentialManager.delete('delete.concurrent'),
credentialManager.delete('delete.concurrent'),
credentialManager.delete('delete.concurrent')
];
// Should not throw
await expect(Promise.all(promises)).resolves.toBeDefined();
// Key should be gone
const value = await credentialManager.retrieve('delete.concurrent');
expect(value).toBeNull();
});
});
describe('Special Characters & Edge Cases', () => {
test('should handle very long credential values', async () => {
const longValue = 'x'.repeat(10000);
const stored = await credentialManager.store('long.value', longValue);
expect(stored).toBe(true);
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('long.value');
expect(retrieved).toBe(longValue);
});
test('should handle credential values with special characters', async () => {
const specialChars = '!@#$%^&*()_+-=[]{}|;:\'",.<>?/~`\n\r\t\\';
const stored = await credentialManager.store('special.chars', specialChars);
expect(stored).toBe(true);
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('special.chars');
expect(retrieved).toBe(specialChars);
});
test('should handle unicode characters', async () => {
const unicode = 'S+ásÑ+S+ûtòî =ƒÜÇ +à+¦+¡+¿+º +º+ä+¦+º+ä+à';
const stored = await credentialManager.store('unicode.key', unicode);
expect(stored).toBe(true);
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('unicode.key');
expect(retrieved).toBe(unicode);
});
test('should handle JSON-like strings', async () => {
const jsonString = '{"nested": {"key": "value"}, "array": [1,2,3]}';
const stored = await credentialManager.store('json.string', jsonString);
expect(stored).toBe(true);
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('json.string');
expect(retrieved).toBe(jsonString);
});
test('should handle empty string values', async () => {
const stored = await credentialManager.store('empty.string', '');
expect(stored).toBe(true);
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('empty.string');
expect(retrieved).toBe('');
});
test('should handle whitespace-only values', async () => {
const whitespace = ' \n\t ';
const stored = await credentialManager.store('whitespace.key', whitespace);
expect(stored).toBe(true);
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('whitespace.key');
expect(retrieved).toBe(whitespace);
});
test('should handle keys with dots and dashes', async () => {
const complexKey = 'my-app.production.database.password';
const stored = await credentialManager.store(complexKey, 'secret123');
expect(stored).toBe(true);
const retrieved = await credentialManager.retrieve(complexKey);
expect(retrieved).toBe('secret123');
});
});
describe('Encryption & Security', () => {
test('should encrypt credentials before storage', async () => {
await credentialManager.store('encrypt.test', 'plaintext-secret');
// Try to read the file directly
// If properly encrypted, the plaintext should not appear in the file
// (This is a basic check - actual encryption is tested in crypto-utils.test.js)
const keys = await credentialManager.list();
expect(keys).toContain('encrypt.test');
});
test('should not leak credentials in error messages', async () => {
// Store a credential
await credentialManager.store('sensitive.key', 'super-secret-password');
// The cache should contain the value, but stringifying shouldn't expose it
const cacheString = JSON.stringify(credentialManager.cache);
// This is implementation-dependent, but generally caches are Map objects
// which stringify to empty objects
expect(cacheString).not.toContain('super-secret-password');
});
test('should handle corrupted credential data gracefully', async () => {
// This would require mocking file I/O or crypto-utils
// For now, test that invalid keys return null
const result = await credentialManager.retrieve('definitely.not.real');
expect(result).toBeNull();
});
});
describe('Metadata Operations', () => {
test('should store and retrieve metadata', async () => {
const metadata = {
description: 'Production database password',
createdAt: new Date().toISOString(),
owner: 'admin',
tags: ['production', 'database']
};
await credentialManager.store('meta.full', 'value', metadata);
const retrieved = await credentialManager.getMetadata('meta.full');
expect(retrieved).toEqual(metadata);
});
test('should allow updating metadata without changing value', async () => {
await credentialManager.store('meta.update', 'original-value', { version: 1 });
// Update metadata
await credentialManager.store('meta.update', 'original-value', { version: 2, updated: true });
const meta = await credentialManager.getMetadata('meta.update');
expect(meta.version).toBe(2);
expect(meta.updated).toBe(true);
// Value should be unchanged
const value = await credentialManager.retrieve('meta.update');
expect(value).toBe('original-value');
});
test('should handle metadata with special characters', async () => {
const metadata = {
description: 'Test with "quotes" and \'apostrophes\'',
notes: 'Line 1\nLine 2\tTabbed'
};
await credentialManager.store('meta.special', 'value', metadata);
const retrieved = await credentialManager.getMetadata('meta.special');
expect(retrieved.description).toBe(metadata.description);
expect(retrieved.notes).toBe(metadata.notes);
});
});
describe('Backup & Restore', () => {
test('should preserve metadata in backup', async () => {
const metadata = { description: 'Important credential', priority: 'high' };
await credentialManager.store('backup.meta', 'value123', metadata);
const backup = await credentialManager.exportBackup();
// Clear everything
await credentialManager.delete('backup.meta');
credentialManager.cache.clear();
// Restore
await credentialManager.importBackup(backup);
// Check metadata preserved
const restoredMeta = await credentialManager.getMetadata('backup.meta');
expect(restoredMeta).toEqual(metadata);
});
test('should handle backup of empty credential store', async () => {
const backup = await credentialManager.exportBackup();
expect(typeof backup).toBe('string');
expect(backup.length).toBeGreaterThan(0);
});
test('should handle importing same backup multiple times', async () => {
await credentialManager.store('backup.repeat', 'value1');
const backup = await credentialManager.exportBackup();
// Import once
await credentialManager.importBackup(backup);
// Import again
const result = await credentialManager.importBackup(backup);
expect(result).toBe(true);
// Should not cause duplicates or errors
const keys = await credentialManager.list();
const count = keys.filter(k => k === 'backup.repeat').length;
expect(count).toBe(1);
});
test('should handle corrupted backup data gracefully', async () => {
const result = await credentialManager.importBackup('corrupted:data:here');
expect(result).toBe(false);
});
test('should handle empty backup string', async () => {
const result = await credentialManager.importBackup('');
expect(result).toBe(false);
});
test('should handle backup with invalid JSON', async () => {
const cryptoUtils = require('../crypto-utils');
const invalidBackup = cryptoUtils.encrypt('{ invalid json }');
const result = await credentialManager.importBackup(invalidBackup);
expect(result).toBe(false);
});
});
describe('Cache Behavior', () => {
test('should cache retrieved values', async () => {
await credentialManager.store('cache.test', 'cached-value');
// First retrieval
await credentialManager.retrieve('cache.test');
expect(credentialManager.cache.has('cache.test')).toBe(true);
// Second retrieval should use cache
const cached = await credentialManager.retrieve('cache.test');
expect(cached).toBe('cached-value');
});
test('should invalidate cache on delete', async () => {
await credentialManager.store('cache.delete', 'value');
await credentialManager.retrieve('cache.delete');
expect(credentialManager.cache.has('cache.delete')).toBe(true);
await credentialManager.delete('cache.delete');
expect(credentialManager.cache.has('cache.delete')).toBe(false);
});
test('should invalidate cache on store update', async () => {
await credentialManager.store('cache.update', 'original');
await credentialManager.retrieve('cache.update');
// Update the credential
await credentialManager.store('cache.update', 'updated');
// Cache should have new value
expect(credentialManager.cache.get('cache.update')).toBe('updated');
// Retrieval should return updated value
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('cache.update');
expect(retrieved).toBe('updated');
});
test('should handle cache clearing during operations', async () => {
await credentialManager.store('cache.clear', 'value1');
// Clear cache manually
credentialManager.cache.clear();
// Should still be able to retrieve from storage
const retrieved = await credentialManager.retrieve('cache.clear');
expect(retrieved).toBe('value1');
});
});
describe('List Operations', () => {
test('should list credentials in sorted order', async () => {
await credentialManager.store('zebra', 'val1');
await credentialManager.store('alpha', 'val2');
await credentialManager.store('beta', 'val3');
const keys = await credentialManager.list();
// Should be sorted
const sortedKeys = [...keys].sort();
expect(keys).toEqual(sortedKeys);
});
test('should not include deleted credentials in list', async () => {
await credentialManager.store('list.keep', 'val1');
await credentialManager.store('list.delete', 'val2');
await credentialManager.delete('list.delete');
const keys = await credentialManager.list();
expect(keys).toContain('list.keep');
expect(keys).not.toContain('list.delete');
});
test('should return unique keys only', async () => {
await credentialManager.store('unique.key', 'val1');
await credentialManager.store('unique.key', 'val2'); // Update
const keys = await credentialManager.list();
const uniqueCount = keys.filter(k => k === 'unique.key').length;
expect(uniqueCount).toBe(1);
});
});
describe('Error Handling & Recovery', () => {
test('should handle retrieve errors gracefully', async () => {
// Try to retrieve with invalid key types
const result1 = await credentialManager.retrieve(null);
const result2 = await credentialManager.retrieve(undefined);
const result3 = await credentialManager.retrieve('');
expect(result1).toBeNull();
expect(result2).toBeNull();
expect(result3).toBeNull();
});
test('should handle delete of non-existent credential', async () => {
// Should not throw
await expect(credentialManager.delete('nonexistent.key')).resolves.toBeDefined();
});
test('should recover from partial operations', async () => {
// Store a credential
await credentialManager.store('recover.test', 'original');
// Try to store invalid data
await credentialManager.store('recover.test', null);
// Original should still be intact
credentialManager.cache.clear();
const retrieved = await credentialManager.retrieve('recover.test');
expect(retrieved).toBe('original');
});
});
});

View File

@@ -1,54 +0,0 @@
/**
* Credentials Route Tests
*
* Tests credential listing and encryption key rotation endpoints
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `credentials-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `credentials-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Credentials Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/credentials/list', () => {
test('should return 200 with credentials array', async () => {
const res = await request(app).get('/api/credentials/list');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.credentials)).toBe(true);
expect(typeof res.body.count).toBe('number');
expect(res.body.count).toBe(res.body.credentials.length);
});
});
describe('POST /api/credentials/rotate-key', () => {
test('should return 200 with success true', async () => {
const res = await request(app)
.post('/api/credentials/rotate-key')
.send({});
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('message');
});
});
});

View File

@@ -1,290 +0,0 @@
// crypto-utils exports a module that calls loadOrCreateKey() at load time (line 263).
// The jest.setup.js sets DASHCADDY_ENCRYPTION_KEY env var so it uses a deterministic key.
const cryptoUtils = require('../crypto-utils');
describe('encrypt / decrypt', () => {
test('round-trips a string', () => {
const plaintext = 'hello world';
const encrypted = cryptoUtils.encrypt(plaintext);
const decrypted = cryptoUtils.decrypt(encrypted);
expect(decrypted).toBe(plaintext);
});
test('round-trips an object via JSON', () => {
const obj = { user: 'admin', pass: 'secret123' };
const encrypted = cryptoUtils.encrypt(obj);
const decrypted = JSON.parse(cryptoUtils.decrypt(encrypted));
expect(decrypted).toEqual(obj);
});
test('encrypted output differs from plaintext', () => {
const plaintext = 'sensitive data';
const encrypted = cryptoUtils.encrypt(plaintext);
expect(encrypted).not.toBe(plaintext);
});
test('encrypted format is iv:authTag:ciphertext (3 colon-separated parts)', () => {
const encrypted = cryptoUtils.encrypt('test');
const parts = encrypted.split(':');
expect(parts.length).toBe(3);
});
test('each encryption produces different output (random IV)', () => {
const plaintext = 'same input';
const enc1 = cryptoUtils.encrypt(plaintext);
const enc2 = cryptoUtils.encrypt(plaintext);
expect(enc1).not.toBe(enc2);
// But both decrypt to same value
expect(cryptoUtils.decrypt(enc1)).toBe(plaintext);
expect(cryptoUtils.decrypt(enc2)).toBe(plaintext);
});
test('throws on tampered ciphertext', () => {
const encrypted = cryptoUtils.encrypt('test');
const parts = encrypted.split(':');
parts[2] = 'AAAA' + parts[2].slice(4); // tamper with ciphertext
expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow();
});
test('throws on tampered authTag', () => {
const encrypted = cryptoUtils.encrypt('test');
const parts = encrypted.split(':');
parts[1] = 'AAAA' + parts[1].slice(4); // tamper with auth tag
expect(() => cryptoUtils.decrypt(parts.join(':'))).toThrow();
});
test('throws on invalid encrypted format (wrong number of parts)', () => {
expect(() => cryptoUtils.decrypt('only:two')).toThrow('Invalid encrypted data format');
expect(() => cryptoUtils.decrypt('just-one')).toThrow('Invalid encrypted data format');
});
test('handles empty string', () => {
const encrypted = cryptoUtils.encrypt('');
expect(cryptoUtils.decrypt(encrypted)).toBe('');
});
test('handles special characters', () => {
const special = 'p@$$w0rd!<>&"\';DROP TABLE--';
expect(cryptoUtils.decrypt(cryptoUtils.encrypt(special))).toBe(special);
});
});
describe('isEncrypted', () => {
test('returns true for encrypted strings', () => {
const encrypted = cryptoUtils.encrypt('test');
expect(cryptoUtils.isEncrypted(encrypted)).toBe(true);
});
test('returns false for plain strings', () => {
expect(cryptoUtils.isEncrypted('hello world')).toBe(false);
});
test('returns false for non-string input', () => {
expect(cryptoUtils.isEncrypted(123)).toBe(false);
expect(cryptoUtils.isEncrypted(null)).toBe(false);
expect(cryptoUtils.isEncrypted(undefined)).toBe(false);
});
test('returns false for string with wrong number of colons', () => {
expect(cryptoUtils.isEncrypted('one:two')).toBe(false);
expect(cryptoUtils.isEncrypted('one:two:three:four')).toBe(false);
});
});
describe('encryptFields', () => {
test('encrypts only specified fields', () => {
const obj = { username: 'admin', password: 'secret', role: 'user' };
const result = cryptoUtils.encryptFields(obj, ['password']);
expect(result.username).toBe('admin');
expect(result.role).toBe('user');
expect(result.password).not.toBe('secret');
expect(cryptoUtils.isEncrypted(result.password)).toBe(true);
});
test('leaves non-specified fields unchanged', () => {
const obj = { a: '1', b: '2', c: '3' };
const result = cryptoUtils.encryptFields(obj, ['a']);
expect(result.b).toBe('2');
expect(result.c).toBe('3');
});
test('adds _encrypted marker', () => {
const result = cryptoUtils.encryptFields({ x: 'y' }, ['x']);
expect(result._encrypted).toBe(true);
});
test('adds _encryptedFields list', () => {
const result = cryptoUtils.encryptFields({ x: 'y' }, ['x']);
expect(result._encryptedFields).toEqual(['x']);
});
test('does not double-encrypt already-encrypted fields', () => {
const obj = { password: 'secret' };
const first = cryptoUtils.encryptFields(obj, ['password']);
const second = cryptoUtils.encryptFields(first, ['password']);
// Should still be decryptable to original
expect(cryptoUtils.decrypt(second.password)).toBe('secret');
});
test('skips null/undefined fields', () => {
const obj = { a: null, b: undefined, c: 'val' };
const result = cryptoUtils.encryptFields(obj, ['a', 'b', 'c']);
expect(result.a).toBeNull();
expect(result.b).toBeUndefined();
expect(cryptoUtils.isEncrypted(result.c)).toBe(true);
});
});
describe('decryptFields', () => {
test('decrypts specified fields', () => {
const encrypted = cryptoUtils.encryptFields({ password: 'secret', name: 'test' }, ['password']);
const decrypted = cryptoUtils.decryptFields(encrypted, ['password']);
expect(decrypted.password).toBe('secret');
expect(decrypted.name).toBe('test');
});
test('returns object without encryption markers', () => {
const encrypted = cryptoUtils.encryptFields({ x: 'y' }, ['x']);
const decrypted = cryptoUtils.decryptFields(encrypted);
expect(decrypted._encrypted).toBeUndefined();
expect(decrypted._encryptedFields).toBeUndefined();
});
test('returns object as-is when _encrypted is false/absent', () => {
const obj = { a: '1', b: '2' };
const result = cryptoUtils.decryptFields(obj);
expect(result).toEqual(obj);
});
test('uses _encryptedFields when fields param is null', () => {
const encrypted = cryptoUtils.encryptFields({ password: 'secret', token: 'abc' }, ['password', 'token']);
const decrypted = cryptoUtils.decryptFields(encrypted);
expect(decrypted.password).toBe('secret');
expect(decrypted.token).toBe('abc');
});
});
describe('encryptFields + decryptFields round-trip', () => {
test('full round-trip preserves all field values', () => {
const original = { user: 'admin', pass: 'p@ss', apiKey: 'key123', role: 'editor' };
const fields = ['pass', 'apiKey'];
const encrypted = cryptoUtils.encryptFields(original, fields);
const decrypted = cryptoUtils.decryptFields(encrypted, fields);
expect(decrypted.user).toBe(original.user);
expect(decrypted.pass).toBe(original.pass);
expect(decrypted.apiKey).toBe(original.apiKey);
expect(decrypted.role).toBe(original.role);
});
});
describe('migrateToEncrypted', () => {
test('encrypts plaintext credentials', () => {
const plain = { password: 'secret', token: 'abc123' };
const result = cryptoUtils.migrateToEncrypted(plain, ['password', 'token']);
expect(result._encrypted).toBe(true);
expect(cryptoUtils.isEncrypted(result.password)).toBe(true);
});
test('returns already-encrypted credentials unchanged', () => {
const encrypted = cryptoUtils.encryptFields({ password: 'secret' }, ['password']);
const result = cryptoUtils.migrateToEncrypted(encrypted, ['password']);
expect(result).toEqual(encrypted);
});
});
describe('loadOrCreateKey', () => {
test('returns a buffer', () => {
const key = cryptoUtils.loadOrCreateKey();
expect(Buffer.isBuffer(key)).toBe(true);
});
test('returns 32-byte key', () => {
const key = cryptoUtils.loadOrCreateKey();
expect(key.length).toBe(32);
});
test('returns cached key on subsequent calls', () => {
const key1 = cryptoUtils.loadOrCreateKey();
const key2 = cryptoUtils.loadOrCreateKey();
expect(key1).toBe(key2); // same reference (cached)
});
});
describe('readEncryptedFile', () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
test('returns null when file does not exist', () => {
const result = cryptoUtils.readEncryptedFile('/nonexistent/file.json');
expect(result).toBeNull();
});
test('reads and returns plaintext JSON file', () => {
const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-plain.json');
fs.writeFileSync(tmpFile, JSON.stringify({ username: 'admin', password: 'plain' }));
try {
const result = cryptoUtils.readEncryptedFile(tmpFile);
expect(result.username).toBe('admin');
expect(result.password).toBe('plain');
} finally {
fs.unlinkSync(tmpFile);
}
});
test('reads and decrypts encrypted JSON file', () => {
const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-encrypted.json');
const data = { username: 'admin', password: 'secret' };
cryptoUtils.writeEncryptedFile(tmpFile, data, ['password']);
try {
const result = cryptoUtils.readEncryptedFile(tmpFile, ['password']);
expect(result.username).toBe('admin');
expect(result.password).toBe('secret');
} finally {
fs.unlinkSync(tmpFile);
}
});
test('returns null on JSON parse error', () => {
const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-bad.json');
fs.writeFileSync(tmpFile, 'not json at all {{{');
try {
const result = cryptoUtils.readEncryptedFile(tmpFile);
expect(result).toBeNull();
} finally {
fs.unlinkSync(tmpFile);
}
});
});
describe('writeEncryptedFile', () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
test('writes valid JSON to disk', () => {
const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-write.json');
cryptoUtils.writeEncryptedFile(tmpFile, { user: 'test', token: 'abc' }, ['token']);
try {
const content = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
expect(content._encrypted).toBe(true);
expect(content.user).toBe('test');
expect(cryptoUtils.isEncrypted(content.token)).toBe(true);
} finally {
fs.unlinkSync(tmpFile);
}
});
test('encrypts specified fields', () => {
const tmpFile = path.join(os.tmpdir(), 'dashcaddy-test-write2.json');
cryptoUtils.writeEncryptedFile(tmpFile, { a: 'plain', b: 'secret' }, ['b']);
try {
const content = JSON.parse(fs.readFileSync(tmpFile, 'utf8'));
expect(content.a).toBe('plain');
expect(content.b).not.toBe('secret');
} finally {
fs.unlinkSync(tmpFile);
}
});
});

View File

@@ -1,142 +0,0 @@
/**
* DNS Route Tests
*
* Tests DNS record management endpoints (create, delete, resolve)
* Note: All DNS routes require a token. We pass token='test-token' to bypass
* credential lookup (requireDnsToken returns providedToken if truthy).
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `dns-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `dns-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('DNS Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('POST /api/dns/record', () => {
test('should reject missing domain', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ ip: '192.168.1.1', token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
expect(res.body.error).toContain('domain');
});
test('should reject missing ip', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.success).toBe(false);
});
test('should reject invalid domain format', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: '!!!invalid!!!', ip: '192.168.1.1', token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('Invalid domain');
});
test('should reject invalid IP address', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', ip: 'not-an-ip', token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('Invalid IP');
});
test('should reject invalid TTL', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', ip: '192.168.1.1', ttl: 10, token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('TTL');
});
});
describe('DELETE /api/dns/record', () => {
test('should reject missing domain', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('domain');
});
test('should reject invalid domain format', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: '!!!bad!!!', token: 'test-token' });
expect(res.statusCode).toBe(400);
});
test('should reject invalid record type', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: 'test.sami', type: 'INVALID', token: 'test-token' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('Invalid DNS record type');
});
test('should reject invalid IP address in query', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: 'test.sami', ipAddress: 'not-ip', token: 'test-token' });
expect(res.statusCode).toBe(400);
});
test('should reject invalid server address', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: 'test.sami', server: 'not-ip', token: 'test-token' });
expect(res.statusCode).toBe(400);
});
});
describe('GET /api/dns/resolve', () => {
test('should reject missing domain', async () => {
const res = await request(app)
.get('/api/dns/resolve')
.query({ token: 'test-token' });
expect(res.statusCode).toBe(400);
});
test('should reject invalid domain format', async () => {
const res = await request(app)
.get('/api/dns/resolve')
.query({ domain: '!!!bad!!!', token: 'test-token' });
expect(res.statusCode).toBe(400);
});
});
});

View File

@@ -1,604 +0,0 @@
/**
* Edge Case Tests
*
* Tests boundary conditions, invalid inputs, and extreme scenarios
* Validates system behavior under unusual or stressful conditions
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Create test instance with isolated environment
const testServicesFile = path.join(os.tmpdir(), `edge-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `edge-config-${Date.now()}.json`);
// Set test environment
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
// Initialize test files
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
// Require app after environment setup
const app = require('../server');
describe('Edge Case Tests', () => {
beforeEach(async () => {
// Reset state through the API to respect file locks
await request(app).put('/api/services').send([]);
fs.writeFileSync(testConfigFile, '{}', 'utf8');
});
afterAll(() => {
// Cleanup test files
try {
fs.unlinkSync(testServicesFile);
fs.unlinkSync(testConfigFile);
} catch (e) {
// Ignore cleanup errors
}
});
describe('Boundary Conditions', () => {
test('should handle empty service ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: '', name: 'Empty ID Service' });
// Should reject empty ID
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
test('should handle very long service ID (1000 chars)', async () => {
const longId = 'a'.repeat(1000);
const res = await request(app)
.post('/api/services')
.send({ id: longId, name: 'Long ID' });
// Might accept or reject depending on validation
expect([200, 400, 413]).toContain(res.statusCode);
});
test('should handle very long service name (10000 chars)', async () => {
const longName = 'Name '.repeat(2000);
const res = await request(app)
.post('/api/services')
.send({ id: 'test', name: longName });
// Should handle gracefully
expect([200, 400, 413]).toContain(res.statusCode);
});
test('should handle service with exactly 0 properties', async () => {
const res = await request(app)
.post('/api/services')
.send({});
// Should reject - missing required fields
expect(res.statusCode).toBe(400);
});
test('should handle service with 100+ properties', async () => {
const service = { id: 'many-props', name: 'Many Props' };
for (let i = 0; i < 100; i++) {
service[`prop${i}`] = `value${i}`;
}
const res = await request(app)
.post('/api/services')
.send(service);
// Should handle extra properties gracefully
expect([200, 400]).toContain(res.statusCode);
});
});
describe('Invalid Input Types', () => {
test('should handle null service ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: null, name: 'Null ID' });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
test('should handle number as service ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 12345, name: 'Number ID' });
// Might convert to string or reject
expect([200, 400]).toContain(res.statusCode);
});
test('should handle array as service ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: ['array', 'id'], name: 'Array ID' });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
test('should handle object as service ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: { nested: 'object' }, name: 'Object ID' });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
test('should handle boolean as service name', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'bool-test', name: true });
// Might convert to string or reject
expect([200, 400]).toContain(res.statusCode);
});
test('should handle undefined properties', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'test', name: undefined });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
});
});
describe('Special Characters and Encoding', () => {
test('should handle Unicode characters in service name', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'unicode', name: '🚀 Rocket Service 中文 العربية' });
// Should handle Unicode properly
expect([200, 400]).toContain(res.statusCode);
if (res.statusCode === 200) {
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services[0].name).toContain('🚀');
}
});
test('should handle special characters in ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'test!@#$%^&*()', name: 'Special ID' });
// Might sanitize or reject
expect([200, 400]).toContain(res.statusCode);
});
test('should handle newlines in service name', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'newline', name: 'Line 1\nLine 2\nLine 3' });
expect([200, 400]).toContain(res.statusCode);
});
test('should handle SQL injection attempt in ID', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: "'; DROP TABLE services; --", name: 'SQL Injection' });
// Should reject or sanitize
expect([200, 400]).toContain(res.statusCode);
// Verify file is still valid JSON
const content = fs.readFileSync(testServicesFile, 'utf8');
expect(() => JSON.parse(content)).not.toThrow();
});
test('should handle path traversal attempt in logo', async () => {
const res = await request(app)
.post('/api/services')
.send({
id: 'path-traversal',
name: 'Path Traversal',
logo: '../../../../../../etc/passwd'
});
// Should handle safely
expect([200, 400]).toContain(res.statusCode);
});
test('should handle null bytes in input', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'null\x00byte', name: 'Test\x00Name' });
// Should reject or sanitize
expect([200, 400]).toContain(res.statusCode);
});
});
describe('Large Datasets', () => {
test('should handle 100 services', async () => {
// Add 100 services
for (let i = 0; i < 100; i++) {
await request(app)
.post('/api/services')
.send({ id: `service-${i}`, name: `Service ${i}` });
}
// Verify all exist
const res = await request(app).get('/api/services');
expect(res.statusCode).toBe(200);
expect(res.body.length).toBe(100);
}, 60000);
test('should handle deleting from large dataset', async () => {
// Add 50 services
for (let i = 0; i < 50; i++) {
await request(app)
.post('/api/services')
.send({ id: `bulk-${i}`, name: `Bulk ${i}` });
}
// Delete 25 services
for (let i = 0; i < 25; i++) {
await request(app).delete(`/api/services/bulk-${i}`);
}
// Verify 25 remain
const res = await request(app).get('/api/services');
expect(res.body.length).toBe(25);
}, 30000);
test('should handle bulk import of 200 services', async () => {
const bulkServices = Array.from({ length: 200 }, (_, i) => ({
id: `bulk-${i}`,
name: `Bulk Service ${i}`
}));
const res = await request(app)
.put('/api/services')
.send(bulkServices);
expect(res.statusCode).toBe(200);
// Verify all imported
const getRes = await request(app).get('/api/services');
expect(getRes.body.length).toBe(200);
}, 10000); // Longer timeout
test('should handle service with very large property value (1MB)', async () => {
const largeData = 'x'.repeat(1024 * 1024); // 1MB string
const res = await request(app)
.post('/api/services')
.send({
id: 'large-data',
name: 'Large Data',
description: largeData
});
// Might reject due to size
expect([200, 413]).toContain(res.statusCode);
});
});
describe('Concurrent Operations and Race Conditions', () => {
test('should handle 20 concurrent POSTs without corruption', async () => {
const promises = Array.from({ length: 20 }, (_, i) =>
request(app)
.post('/api/services')
.send({ id: `concurrent-${i}`, name: `Concurrent ${i}` })
);
const results = await Promise.all(promises);
// With file locking, some may fail with 500 (lock contention) — that's expected
const successes = results.filter(r => r.statusCode === 200);
expect(successes.length).toBeGreaterThanOrEqual(1);
// The critical check: file must be valid JSON (no corruption)
const content = fs.readFileSync(testServicesFile, 'utf8');
expect(() => JSON.parse(content)).not.toThrow();
// And the count must match the number of successes
const services = JSON.parse(content);
expect(services.length).toBe(successes.length);
});
test('should handle concurrent add and delete of same service', async () => {
// Add a service
await request(app)
.post('/api/services')
.send({ id: 'race', name: 'Race Service' });
// Simultaneously add again and delete
const [addRes, deleteRes] = await Promise.all([
request(app).post('/api/services').send({ id: 'race', name: 'Race 2' }),
request(app).delete('/api/services/race')
]);
// One should succeed, states should be consistent
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(() => JSON.parse(fs.readFileSync(testServicesFile, 'utf8'))).not.toThrow();
});
test('should handle concurrent bulk imports', async () => {
const set1 = [{ id: 's1', name: 'Set 1' }];
const set2 = [{ id: 's2', name: 'Set 2' }];
const [res1, res2] = await Promise.all([
request(app).put('/api/services').send(set1),
request(app).put('/api/services').send(set2)
]);
// Both operations should complete
expect([200]).toContain(res1.statusCode);
expect([200]).toContain(res2.statusCode);
// Final state should have one complete set (last write wins)
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBeGreaterThanOrEqual(1);
});
});
describe('File System Edge Cases', () => {
test('should handle file with read-only after writing', async () => {
// Add a service
await request(app)
.post('/api/services')
.send({ id: 'readonly-test', name: 'Read Only' });
// Make file read-only
fs.chmodSync(testServicesFile, 0o444);
// Try to add another service
const res = await request(app)
.post('/api/services')
.send({ id: 'should-fail', name: 'Should Fail' });
// Should fail with 500 error
expect(res.statusCode).toBe(500);
// Restore permissions for cleanup
fs.chmodSync(testServicesFile, 0o666);
});
test('should handle missing services file gracefully', async () => {
// Delete the file
fs.unlinkSync(testServicesFile);
// Try to get services
const res = await request(app).get('/api/services');
// Should either return empty array or create file
expect([200, 500]).toContain(res.statusCode);
// File should be recreated or error handled
if (res.statusCode === 200) {
expect(Array.isArray(res.body)).toBe(true);
}
});
test('should handle empty file (0 bytes)', async () => {
// Create empty file
fs.writeFileSync(testServicesFile, '', 'utf8');
const res = await request(app).get('/api/services');
// Should handle gracefully
expect([200, 500]).toContain(res.statusCode);
});
test('should handle file with only whitespace', async () => {
fs.writeFileSync(testServicesFile, ' \n\t\r ', 'utf8');
const res = await request(app).get('/api/services');
// Should handle gracefully
expect([200, 500]).toContain(res.statusCode);
});
test('should handle file with BOM (Byte Order Mark)', async () => {
const bomContent = '\uFEFF[]';
fs.writeFileSync(testServicesFile, bomContent, 'utf8');
const res = await request(app).get('/api/services');
// BOM may cause JSON parse to fail (500) or be handled (200)
expect([200, 500]).toContain(res.statusCode);
if (res.statusCode === 200) {
expect(Array.isArray(res.body)).toBe(true);
}
});
});
describe('API Request Edge Cases', () => {
test('should handle missing Content-Type header', async () => {
const res = await request(app)
.post('/api/services')
.set('Content-Type', '')
.send('{"id":"test","name":"Test"}');
// Should handle gracefully
expect([200, 400]).toContain(res.statusCode);
});
test('should handle wrong Content-Type (text/plain)', async () => {
const res = await request(app)
.post('/api/services')
.set('Content-Type', 'text/plain')
.send('{"id":"test","name":"Test"}');
// Might still parse or reject
expect([200, 400, 415]).toContain(res.statusCode);
});
test('should handle extremely nested JSON (50 levels)', async () => {
let nested = { value: 'deep' };
for (let i = 0; i < 50; i++) {
nested = { level: nested };
}
const res = await request(app)
.post('/api/services')
.send({ id: 'nested', name: 'Nested', data: nested });
// Should handle or reject
expect([200, 400]).toContain(res.statusCode);
});
test('should handle request with circular reference (if possible)', async () => {
// Can't send actual circular JSON, but test large nested structure
const data = { id: 'circular', name: 'Test' };
const res = await request(app)
.post('/api/services')
.send(data);
expect([200, 400]).toContain(res.statusCode);
});
test('should handle double-encoded JSON', async () => {
const doubleEncoded = JSON.stringify(
JSON.stringify({ id: 'double', name: 'Double Encoded' })
);
const res = await request(app)
.post('/api/services')
.set('Content-Type', 'application/json')
.send(doubleEncoded);
// Should reject - wrong format
expect([400, 500]).toContain(res.statusCode);
});
});
describe('Template Edge Cases', () => {
test('should handle requesting template with special chars in ID', async () => {
const res = await request(app).get('/api/apps/templates/test%20space');
expect([404, 400]).toContain(res.statusCode);
});
test('should handle requesting template with very long ID', async () => {
const longId = 'a'.repeat(1000);
const res = await request(app).get(`/api/apps/templates/${longId}`);
expect([404, 414]).toContain(res.statusCode);
});
test('should handle template with path traversal', async () => {
const res = await request(app).get('/api/apps/templates/../../secrets');
expect([404, 400]).toContain(res.statusCode);
});
});
describe('Configuration Edge Cases', () => {
test('should handle empty configuration object', async () => {
const res = await request(app)
.post('/api/config')
.send({});
expect(res.statusCode).toBe(200);
// Verify empty config saved
const config = JSON.parse(fs.readFileSync(testConfigFile, 'utf8'));
expect(typeof config).toBe('object');
});
test('should handle configuration with 1000 properties', async () => {
const largeConfig = {};
for (let i = 0; i < 1000; i++) {
largeConfig[`setting${i}`] = `value${i}`;
}
const res = await request(app)
.post('/api/config')
.send(largeConfig);
expect([200, 413]).toContain(res.statusCode);
});
test('should handle configuration with nested arrays', async () => {
const config = {
nested: [[['deep', 'array'], ['values']], [['more']]]
};
const res = await request(app)
.post('/api/config')
.send(config);
expect(res.statusCode).toBe(200);
});
});
describe('Delete Edge Cases', () => {
test('should handle deleting non-existent service', async () => {
const res = await request(app).delete('/api/services/does-not-exist');
expect(res.statusCode).toBe(404);
});
test('should handle deleting with special characters in ID', async () => {
const res = await request(app).delete('/api/services/test%2Fslash');
expect([404, 400]).toContain(res.statusCode);
});
test('should handle deleting same service twice simultaneously', async () => {
// Add service
await request(app)
.post('/api/services')
.send({ id: 'delete-me', name: 'Delete Me' });
// Delete twice at once
const [res1, res2] = await Promise.all([
request(app).delete('/api/services/delete-me'),
request(app).delete('/api/services/delete-me')
]);
// One should succeed (200), one should fail (404)
const statuses = [res1.statusCode, res2.statusCode].sort();
expect(statuses).toContain(200);
expect(statuses).toContain(404);
});
});
describe('State Consistency Edge Cases', () => {
test('should recover if file becomes corrupted mid-operation', async () => {
// Add initial service
await request(app)
.post('/api/services')
.send({ id: 'initial', name: 'Initial' });
// Corrupt the file
fs.writeFileSync(testServicesFile, '{corrupted', 'utf8');
// Try to read
const res = await request(app).get('/api/services');
// Should handle error gracefully
expect([200, 500]).toContain(res.statusCode);
});
test('should handle file replaced with directory', async () => {
// Delete file
fs.unlinkSync(testServicesFile);
// Create directory with same name
fs.mkdirSync(testServicesFile);
// Try to read
const res = await request(app).get('/api/services');
expect(res.statusCode).toBe(500);
// Cleanup
fs.rmdirSync(testServicesFile);
});
});
});

View File

@@ -1,70 +0,0 @@
/**
* Error Log and Audit Log Route Tests
*
* Tests error log retrieval/clearing and audit log retrieval/clearing
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `errorlogs-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `errorlogs-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Error Log and Audit Log Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/error-logs', () => {
test('should return 200 with logs array', async () => {
const res = await request(app).get('/api/error-logs');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.logs)).toBe(true);
});
});
describe('GET /api/audit-logs', () => {
test('should return 200 with entries array', async () => {
const res = await request(app).get('/api/audit-logs');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.entries)).toBe(true);
});
});
describe('DELETE /api/error-logs', () => {
test('should return 200 with success message', async () => {
const res = await request(app).delete('/api/error-logs');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('message');
});
});
describe('DELETE /api/audit-logs', () => {
test('should return 200 with success message', async () => {
const res = await request(app).delete('/api/audit-logs');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('message');
});
});
});

View File

@@ -1,361 +0,0 @@
// health-checker.js exports a singleton that reads config/history from disk on construction.
// The jest.setup.js suppresses console and the files don't exist in test env, so it falls back to defaults.
const healthChecker = require('../health-checker');
beforeEach(() => {
// Reset singleton state between tests
healthChecker.currentStatus = new Map();
healthChecker.incidents = [];
healthChecker.history = {};
healthChecker.config = { services: {} };
healthChecker.checking = false;
if (healthChecker.checkInterval) {
clearInterval(healthChecker.checkInterval);
healthChecker.checkInterval = null;
}
});
afterAll(() => {
healthChecker.stop();
});
describe('evaluateHealth', () => {
test('returns true for status code in expectedStatusCodes', () => {
expect(healthChecker.evaluateHealth(200, '', { expectedStatusCodes: [200, 201] })).toBe(true);
});
test('returns false for status code not in expectedStatusCodes', () => {
expect(healthChecker.evaluateHealth(500, '', { expectedStatusCodes: [200] })).toBe(false);
});
test('uses default expected codes when not configured', () => {
expect(healthChecker.evaluateHealth(200, '', {})).toBe(true);
expect(healthChecker.evaluateHealth(301, '', {})).toBe(true);
expect(healthChecker.evaluateHealth(500, '', {})).toBe(false);
});
test('returns false when expectedBodyPattern regex does not match', () => {
expect(healthChecker.evaluateHealth(200, 'error occurred', {
expectedBodyPattern: 'ok|healthy'
})).toBe(false);
});
test('returns true when expectedBodyPattern regex matches', () => {
expect(healthChecker.evaluateHealth(200, 'status: healthy', {
expectedBodyPattern: 'healthy'
})).toBe(true);
});
test('returns false when expectedBodyContains text is missing', () => {
expect(healthChecker.evaluateHealth(200, 'some response', {
expectedBodyContains: 'healthy'
})).toBe(false);
});
test('returns true when expectedBodyContains text is present', () => {
expect(healthChecker.evaluateHealth(200, 'service is healthy', {
expectedBodyContains: 'healthy'
})).toBe(true);
});
test('checks all conditions: status code AND body pattern AND body contains', () => {
// All pass
expect(healthChecker.evaluateHealth(200, 'healthy ok', {
expectedStatusCodes: [200],
expectedBodyPattern: 'healthy',
expectedBodyContains: 'ok'
})).toBe(true);
// Status fails
expect(healthChecker.evaluateHealth(500, 'healthy ok', {
expectedStatusCodes: [200],
expectedBodyPattern: 'healthy',
expectedBodyContains: 'ok'
})).toBe(false);
// Body pattern fails
expect(healthChecker.evaluateHealth(200, 'error', {
expectedStatusCodes: [200],
expectedBodyPattern: 'healthy',
expectedBodyContains: 'error'
})).toBe(false);
});
});
describe('calculateSeverity', () => {
test('returns critical for outage', () => {
expect(healthChecker.calculateSeverity('outage')).toBe('critical');
});
test('returns high for sla-violation', () => {
expect(healthChecker.calculateSeverity('sla-violation')).toBe('high');
});
test('returns medium for slow-response', () => {
expect(healthChecker.calculateSeverity('slow-response')).toBe('medium');
});
test('returns low for unknown type', () => {
expect(healthChecker.calculateSeverity('unknown')).toBe('low');
});
});
describe('calculateUptime', () => {
test('returns 100 when no history', () => {
expect(healthChecker.calculateUptime('svc1')).toBe(100);
});
test('returns 100 when all checks are up', () => {
const now = new Date().toISOString();
healthChecker.history['svc1'] = [
{ status: 'up', timestamp: now },
{ status: 'up', timestamp: now },
{ status: 'up', timestamp: now },
];
expect(healthChecker.calculateUptime('svc1')).toBe(100);
});
test('returns 0 when all checks are down', () => {
const now = new Date().toISOString();
healthChecker.history['svc1'] = [
{ status: 'down', timestamp: now },
{ status: 'down', timestamp: now },
];
expect(healthChecker.calculateUptime('svc1')).toBe(0);
});
test('returns 50 when half are up', () => {
const now = new Date().toISOString();
healthChecker.history['svc1'] = [
{ status: 'up', timestamp: now },
{ status: 'down', timestamp: now },
];
expect(healthChecker.calculateUptime('svc1')).toBe(50);
});
});
describe('calculateAverageResponseTime', () => {
test('returns 0 when no history', () => {
expect(healthChecker.calculateAverageResponseTime('svc1')).toBe(0);
});
test('calculates correct average', () => {
const now = new Date().toISOString();
healthChecker.history['svc1'] = [
{ responseTime: 100, timestamp: now },
{ responseTime: 200, timestamp: now },
{ responseTime: 300, timestamp: now },
];
expect(healthChecker.calculateAverageResponseTime('svc1')).toBe(200);
});
});
describe('calculatePercentile', () => {
test('returns p95 correctly', () => {
const values = Array.from({ length: 100 }, (_, i) => i + 1);
expect(healthChecker.calculatePercentile(values, 95)).toBe(95);
});
test('returns p99 correctly', () => {
const values = Array.from({ length: 100 }, (_, i) => i + 1);
expect(healthChecker.calculatePercentile(values, 99)).toBe(99);
});
test('returns 0 for empty array', () => {
expect(healthChecker.calculatePercentile([], 95)).toBe(0);
});
test('handles single-element array', () => {
expect(healthChecker.calculatePercentile([42], 95)).toBe(42);
});
test('sorts values before calculating', () => {
const unsorted = [50, 10, 90, 30, 70, 20, 80, 40, 60, 100];
expect(healthChecker.calculatePercentile(unsorted, 50)).toBe(50);
});
});
describe('recordStatus', () => {
test('adds status to currentStatus map', () => {
const status = { serviceId: 'svc1', status: 'up', timestamp: new Date().toISOString() };
healthChecker.recordStatus('svc1', status);
expect(healthChecker.currentStatus.get('svc1')).toEqual(status);
});
test('creates history array for new serviceId', () => {
const status = { serviceId: 'new-svc', status: 'up', timestamp: new Date().toISOString() };
healthChecker.recordStatus('new-svc', status);
expect(healthChecker.history['new-svc']).toHaveLength(1);
});
test('appends to existing history', () => {
healthChecker.history['svc1'] = [{ status: 'up', timestamp: new Date().toISOString() }];
const status = { status: 'down', timestamp: new Date().toISOString() };
healthChecker.recordStatus('svc1', status);
expect(healthChecker.history['svc1']).toHaveLength(2);
});
test('emits status-check event', () => {
const handler = jest.fn();
healthChecker.on('status-check', handler);
healthChecker.recordStatus('svc1', { status: 'up', timestamp: new Date().toISOString() });
expect(handler).toHaveBeenCalled();
healthChecker.removeListener('status-check', handler);
});
});
describe('createIncident', () => {
test('creates incident with correct structure', () => {
const status = { timestamp: new Date().toISOString() };
healthChecker.createIncident('svc1', 'outage', 'Service down', status);
expect(healthChecker.incidents).toHaveLength(1);
expect(healthChecker.incidents[0].serviceId).toBe('svc1');
expect(healthChecker.incidents[0].type).toBe('outage');
expect(healthChecker.incidents[0].status).toBe('open');
expect(healthChecker.incidents[0].severity).toBe('critical');
expect(healthChecker.incidents[0].occurrences).toBe(1);
});
test('emits incident-created event', () => {
const handler = jest.fn();
healthChecker.on('incident-created', handler);
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: new Date().toISOString() });
expect(handler).toHaveBeenCalledWith(expect.objectContaining({ serviceId: 'svc1' }));
healthChecker.removeListener('incident-created', handler);
});
test('does not duplicate open incidents of same type', () => {
const status = { timestamp: new Date().toISOString() };
healthChecker.createIncident('svc1', 'outage', 'Down', status);
healthChecker.createIncident('svc1', 'outage', 'Still down', status);
expect(healthChecker.incidents).toHaveLength(1);
});
test('increments occurrences on existing open incident', () => {
const status = { timestamp: new Date().toISOString() };
healthChecker.createIncident('svc1', 'outage', 'Down', status);
healthChecker.createIncident('svc1', 'outage', 'Still down', status);
expect(healthChecker.incidents[0].occurrences).toBe(2);
});
});
describe('resolveIncident', () => {
test('marks incident as resolved with duration', () => {
const created = new Date(Date.now() - 60000).toISOString();
const resolved = new Date().toISOString();
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: created });
healthChecker.resolveIncident('svc1', 'outage', { timestamp: resolved });
expect(healthChecker.incidents[0].status).toBe('resolved');
expect(healthChecker.incidents[0].resolvedAt).toBe(resolved);
expect(healthChecker.incidents[0].duration).toBeGreaterThan(0);
});
test('emits incident-resolved event', () => {
const handler = jest.fn();
healthChecker.on('incident-resolved', handler);
const ts = new Date().toISOString();
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: ts });
healthChecker.resolveIncident('svc1', 'outage', { timestamp: ts });
expect(handler).toHaveBeenCalled();
healthChecker.removeListener('incident-resolved', handler);
});
test('handles no matching incident gracefully', () => {
// Should not throw
healthChecker.resolveIncident('nonexistent', 'outage', { timestamp: new Date().toISOString() });
expect(healthChecker.incidents).toHaveLength(0);
});
});
describe('configureService / removeService', () => {
test('adds service config with defaults', () => {
healthChecker.configureService('svc1', { url: 'http://localhost:3000', name: 'Test' });
expect(healthChecker.config.services['svc1']).toBeDefined();
expect(healthChecker.config.services['svc1'].method).toBe('GET');
expect(healthChecker.config.services['svc1'].timeout).toBe(10000);
});
test('removes service and cleans up', () => {
healthChecker.configureService('svc1', { url: 'http://localhost:3000' });
healthChecker.currentStatus.set('svc1', { status: 'up' });
healthChecker.history['svc1'] = [{ status: 'up' }];
healthChecker.removeService('svc1');
expect(healthChecker.config.services['svc1']).toBeUndefined();
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
expect(healthChecker.history['svc1']).toBeUndefined();
});
});
describe('getOpenIncidents / getIncidentHistory', () => {
test('getOpenIncidents returns only open incidents', () => {
const ts = new Date().toISOString();
healthChecker.createIncident('svc1', 'outage', 'Down', { timestamp: ts });
healthChecker.createIncident('svc2', 'slow-response', 'Slow', { timestamp: ts });
healthChecker.resolveIncident('svc1', 'outage', { timestamp: ts });
expect(healthChecker.getOpenIncidents()).toHaveLength(1);
expect(healthChecker.getOpenIncidents()[0].serviceId).toBe('svc2');
});
test('getIncidentHistory returns reverse chronological order', () => {
const ts = new Date().toISOString();
healthChecker.createIncident('svc1', 'outage', 'First', { timestamp: ts });
healthChecker.createIncident('svc2', 'outage', 'Second', { timestamp: ts });
const history = healthChecker.getIncidentHistory();
expect(history[0].serviceId).toBe('svc2');
});
});
describe('getServiceStats', () => {
test('returns null for service with no history', () => {
expect(healthChecker.getServiceStats('nonexistent')).toBeNull();
});
test('returns correct stats structure', () => {
const now = new Date().toISOString();
healthChecker.history['svc1'] = [
{ status: 'up', responseTime: 100, timestamp: now },
{ status: 'up', responseTime: 200, timestamp: now },
{ status: 'down', responseTime: 0, timestamp: now },
];
const stats = healthChecker.getServiceStats('svc1');
expect(stats.totalChecks).toBe(3);
expect(stats.upChecks).toBe(2);
expect(stats.downChecks).toBe(1);
expect(stats.responseTime.avg).toBe(100);
expect(stats.responseTime.min).toBe(0);
expect(stats.responseTime.max).toBe(200);
expect(stats.responseTime).toHaveProperty('p95');
expect(stats.responseTime).toHaveProperty('p99');
});
});
describe('start / stop', () => {
test('start sets checking flag', () => {
jest.useFakeTimers();
healthChecker.start();
expect(healthChecker.checking).toBe(true);
healthChecker.stop();
jest.useRealTimers();
});
test('stop clears interval and checking flag', () => {
jest.useFakeTimers();
healthChecker.start();
healthChecker.stop();
expect(healthChecker.checking).toBe(false);
expect(healthChecker.checkInterval).toBeNull();
jest.useRealTimers();
});
test('start is idempotent', () => {
jest.useFakeTimers();
healthChecker.start();
const firstInterval = healthChecker.checkInterval;
healthChecker.start();
expect(healthChecker.checkInterval).toBe(firstInterval);
healthChecker.stop();
jest.useRealTimers();
});
});

View File

@@ -1,727 +0,0 @@
const {
ValidationError,
validateDNSRecord,
validateDockerDeployment,
validateFilePath,
validateVolumePath,
validateURL,
validateToken,
validateServiceConfig,
sanitizeString,
isValidPort,
isPrivateIP
} = require('../input-validator');
// Helper: extract .errors from ValidationError
function getErrors(fn) {
try {
fn();
return null;
} catch (e) {
return e;
}
}
describe('ValidationError', () => {
test('creates error with message and field', () => {
const err = new ValidationError('bad input', 'name');
expect(err.message).toBe('bad input');
expect(err.field).toBe('name');
});
test('has statusCode 400', () => {
expect(new ValidationError('x').statusCode).toBe(400);
});
test('has name "ValidationError"', () => {
expect(new ValidationError('x').name).toBe('ValidationError');
});
test('defaults field to null', () => {
expect(new ValidationError('x').field).toBeNull();
});
test('is instance of Error', () => {
expect(new ValidationError('x')).toBeInstanceOf(Error);
});
});
describe('validateDNSRecord', () => {
const valid = { subdomain: 'myapp', ip: '192.168.1.1' };
describe('valid inputs', () => {
test('accepts valid subdomain and ip', () => {
const result = validateDNSRecord(valid);
expect(result.subdomain).toBe('myapp');
expect(result.ip).toBe('192.168.1.1');
});
test('returns sanitized lowercase output', () => {
const result = validateDNSRecord({ subdomain: 'MyApp', ip: '10.0.0.1' });
expect(result.subdomain).toBe('myapp');
});
test('defaults ttl to 3600 when not provided', () => {
expect(validateDNSRecord(valid).ttl).toBe(3600);
});
test('accepts explicit ttl', () => {
expect(validateDNSRecord({ ...valid, ttl: 300 }).ttl).toBe(300);
});
test('accepts IPv6 addresses', () => {
const result = validateDNSRecord({ subdomain: 'test', ip: '::1' });
expect(result.ip).toBe('::1');
});
test('accepts valid domain', () => {
const result = validateDNSRecord({ ...valid, domain: 'example.local' });
expect(result.domain).toBe('example.local');
});
test('returns null domain when not provided', () => {
expect(validateDNSRecord(valid).domain).toBeNull();
});
test('lowercases and trims subdomain in output', () => {
const result = validateDNSRecord({ subdomain: 'MyApp', ip: '10.0.0.1' });
expect(result.subdomain).toBe('myapp');
});
});
describe('subdomain validation', () => {
test('rejects missing subdomain', () => {
const err = getErrors(() => validateDNSRecord({ ip: '1.2.3.4' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects non-string subdomain', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: 123, ip: '1.2.3.4' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects subdomain starting with hyphen', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: '-bad', ip: '1.2.3.4' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects subdomain ending with hyphen', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: 'bad-', ip: '1.2.3.4' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts single-character subdomain', () => {
expect(validateDNSRecord({ subdomain: 'a', ip: '1.2.3.4' }).subdomain).toBe('a');
});
test('accepts subdomain with hyphens in middle', () => {
expect(validateDNSRecord({ subdomain: 'my-app', ip: '1.2.3.4' }).subdomain).toBe('my-app');
});
test('rejects subdomain exceeding 63 characters', () => {
const long = 'a'.repeat(64);
const err = getErrors(() => validateDNSRecord({ subdomain: long, ip: '1.2.3.4' }));
expect(err).toBeInstanceOf(ValidationError);
});
});
describe('injection prevention', () => {
const chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\'];
chars.forEach(char => {
test(`rejects "${char === '\n' ? '\\n' : char === '\r' ? '\\r' : char}" in subdomain`, () => {
const err = getErrors(() => validateDNSRecord({ subdomain: `test${char}bad`, ip: '1.2.3.4' }));
expect(err).toBeInstanceOf(ValidationError);
});
});
});
describe('IP validation', () => {
test('rejects missing IP', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: 'test' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects invalid IP format', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: 'test', ip: '999.999.999.999' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects non-string IP', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: 'test', ip: 12345 }));
expect(err).toBeInstanceOf(ValidationError);
});
test('blocks private IP when blockPrivateIPs is true', () => {
const err = getErrors(() => validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1', blockPrivateIPs: true }));
expect(err).toBeInstanceOf(ValidationError);
});
test('allows private IP when blockPrivateIPs is absent', () => {
expect(validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1' }).ip).toBe('192.168.1.1');
});
});
describe('TTL validation', () => {
test('rejects TTL below 60', () => {
const err = getErrors(() => validateDNSRecord({ ...valid, ttl: 10 }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects TTL above 86400', () => {
const err = getErrors(() => validateDNSRecord({ ...valid, ttl: 100000 }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects non-numeric TTL', () => {
const err = getErrors(() => validateDNSRecord({ ...valid, ttl: 'abc' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts TTL at lower boundary (60)', () => {
expect(validateDNSRecord({ ...valid, ttl: 60 }).ttl).toBe(60);
});
test('accepts TTL at upper boundary (86400)', () => {
expect(validateDNSRecord({ ...valid, ttl: 86400 }).ttl).toBe(86400);
});
});
describe('error aggregation', () => {
test('returns multiple errors for multiple invalid fields', () => {
const err = getErrors(() => validateDNSRecord({ ttl: 1 }));
expect(err.errors.length).toBeGreaterThan(1);
});
test('throws ValidationError with .errors array', () => {
const err = getErrors(() => validateDNSRecord({}));
expect(err).toBeInstanceOf(ValidationError);
expect(Array.isArray(err.errors)).toBe(true);
});
});
});
describe('validateDockerDeployment', () => {
const valid = { name: 'myapp', image: 'nginx:latest' };
describe('valid inputs', () => {
test('accepts valid name and image', () => {
const result = validateDockerDeployment(valid);
expect(result.name).toBe('myapp');
expect(result.image).toBe('nginx:latest');
});
test('returns defaults for optional fields', () => {
const result = validateDockerDeployment(valid);
expect(result.ports).toEqual([]);
expect(result.volumes).toEqual([]);
expect(result.environment).toEqual({});
});
});
describe('container name validation', () => {
test('rejects missing name', () => {
const err = getErrors(() => validateDockerDeployment({ image: 'nginx' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects name starting with special char', () => {
const err = getErrors(() => validateDockerDeployment({ name: '-bad', image: 'nginx' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects name exceeding 255 characters', () => {
const err = getErrors(() => validateDockerDeployment({ name: 'a'.repeat(256), image: 'nginx' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts name with underscores, periods, hyphens', () => {
const result = validateDockerDeployment({ name: 'my_app.v1-test', image: 'nginx' });
expect(result.name).toBe('my_app.v1-test');
});
});
describe('image validation', () => {
test('rejects missing image', () => {
const err = getErrors(() => validateDockerDeployment({ name: 'app' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts simple image', () => {
expect(validateDockerDeployment({ name: 'a', image: 'alpine' }).image).toBe('alpine');
});
test('accepts image with tag', () => {
expect(validateDockerDeployment({ name: 'a', image: 'nginx:latest' }).image).toBe('nginx:latest');
});
test('accepts fully qualified image', () => {
const result = validateDockerDeployment({ name: 'a', image: 'docker.io/library/nginx:1.21' });
expect(result.image).toBe('docker.io/library/nginx:1.21');
});
test('rejects image with semicolon', () => {
const err = getErrors(() => validateDockerDeployment({ name: 'a', image: 'nginx;rm -rf /' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects image with $( subshell', () => {
const err = getErrors(() => validateDockerDeployment({ name: 'a', image: 'nginx$(evil)' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects image exceeding 512 characters', () => {
const err = getErrors(() => validateDockerDeployment({ name: 'a', image: 'a'.repeat(513) }));
expect(err).toBeInstanceOf(ValidationError);
});
});
describe('ports validation', () => {
test('rejects non-array ports', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, ports: 'bad' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts string port format "8080:80"', () => {
const result = validateDockerDeployment({ ...valid, ports: ['8080:80'] });
expect(result.ports).toEqual(['8080:80']);
});
test('accepts port format with protocol "8080:80/tcp"', () => {
const result = validateDockerDeployment({ ...valid, ports: ['8080:80/tcp'] });
expect(result.ports).toEqual(['8080:80/tcp']);
});
test('rejects invalid port format', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, ports: ['bad'] }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects port numbers > 65535', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, ports: ['70000:80'] }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects port numbers < 1', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, ports: ['0:80'] }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts numeric port values', () => {
const result = validateDockerDeployment({ ...valid, ports: [8080] });
expect(result.ports).toEqual([8080]);
});
test('rejects non-string non-number port values', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, ports: [{}] }));
expect(err).toBeInstanceOf(ValidationError);
});
});
describe('volumes validation', () => {
test('rejects non-array volumes', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, volumes: 'bad' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects non-string volume entries', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, volumes: [123] }));
expect(err).toBeInstanceOf(ValidationError);
});
});
describe('environment validation', () => {
test('rejects non-object environment', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, environment: 'bad' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects array as environment', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, environment: [] }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects invalid env var names', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, environment: { '1BAD': 'val' } }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts valid env var names', () => {
const result = validateDockerDeployment({ ...valid, environment: { MY_VAR: 'test', _under: '1' } });
expect(result.environment).toEqual({ MY_VAR: 'test', _under: '1' });
});
test('accepts string, number, boolean values', () => {
const env = { A: 'str', B: 42, C: true };
const result = validateDockerDeployment({ ...valid, environment: env });
expect(result.environment).toEqual(env);
});
test('rejects object values', () => {
const err = getErrors(() => validateDockerDeployment({ ...valid, environment: { X: { nested: true } } }));
expect(err).toBeInstanceOf(ValidationError);
});
});
});
describe('validateFilePath', () => {
const isWindows = process.platform === 'win32';
test('rejects empty path', () => {
expect(() => validateFilePath('')).toThrow(ValidationError);
});
test('rejects null path', () => {
expect(() => validateFilePath(null)).toThrow(ValidationError);
});
test('rejects path with ~', () => {
expect(() => validateFilePath('~/secrets')).toThrow(ValidationError);
});
// On Windows, path.normalize resolves '..' so the normalized path may not contain '..'
// On Linux, '/app/../etc/passwd' normalizes to '/etc/passwd' which is blocked
test('blocks C:\\Windows path', () => {
expect(() => validateFilePath('C:\\Windows\\System32')).toThrow(ValidationError);
});
test('blocks C:\\Program Files path', () => {
expect(() => validateFilePath('C:\\Program Files\\test')).toThrow(ValidationError);
});
if (!isWindows) {
test('rejects path with ..', () => {
expect(() => validateFilePath('/app/../etc/passwd')).toThrow(ValidationError);
});
test('blocks /etc path', () => {
expect(() => validateFilePath('/etc/passwd')).toThrow(ValidationError);
});
test('blocks /proc path', () => {
expect(() => validateFilePath('/proc/self/environ')).toThrow(ValidationError);
});
test('blocks /sys path', () => {
expect(() => validateFilePath('/sys/class')).toThrow(ValidationError);
});
test('blocks /root path', () => {
expect(() => validateFilePath('/root/.ssh')).toThrow(ValidationError);
});
test('blocks /var/run path', () => {
expect(() => validateFilePath('/var/run/docker.sock')).toThrow(ValidationError);
});
test('blocks /var/lib/docker path', () => {
expect(() => validateFilePath('/var/lib/docker/containers')).toThrow(ValidationError);
});
}
test('returns normalized path for valid input', () => {
const testPath = isWindows ? 'D:\\app\\data\\config' : '/app/data/config';
const result = validateFilePath(testPath);
expect(result).toBeTruthy();
});
test('enforces allowedBasePaths when specified', () => {
const testPath = isWindows ? 'D:\\app\\data' : '/app/data';
const allowedBase = isWindows ? 'D:\\opt' : '/opt';
expect(() => validateFilePath(testPath, [allowedBase])).toThrow(ValidationError);
});
test('accepts path within allowedBasePaths', () => {
const testPath = isWindows ? 'D:\\opt\\myapp\\config' : '/opt/myapp/config';
const allowedBase = isWindows ? 'D:\\opt' : '/opt';
const result = validateFilePath(testPath, [allowedBase]);
expect(result).toBeTruthy();
});
test('accepts any path when allowedBasePaths is empty', () => {
const testPath = isWindows ? 'D:\\app\\data' : '/app/data';
const result = validateFilePath(testPath, []);
expect(result).toBeTruthy();
});
});
describe('validateVolumePath', () => {
test('rejects invalid volume format', () => {
const errors = validateVolumePath('not-a-volume', 0);
expect(errors.length).toBeGreaterThan(0);
});
test('rejects container path with ..', () => {
const errors = validateVolumePath('/app/data:/../etc:ro', 0);
expect(errors.length).toBeGreaterThan(0);
});
test('accepts valid modes: ro, rw, z, Z', () => {
['ro', 'rw', 'z', 'Z'].forEach(mode => {
const errors = validateVolumePath(`/app/data:/container/path:${mode}`, 0);
// Filter to only mode-related errors
const modeErrors = errors.filter(e => e.field && e.field.includes('mode'));
expect(modeErrors).toHaveLength(0);
});
});
test('accepts valid volume without mode', () => {
const errors = validateVolumePath('/app/data:/container/path', 0);
// Should have no container path errors
const containerErrors = errors.filter(e => e.field && e.field.includes('containerPath'));
expect(containerErrors).toHaveLength(0);
});
});
describe('validateURL', () => {
test('rejects empty URL', () => {
expect(() => validateURL('')).toThrow(ValidationError);
});
test('rejects null URL', () => {
expect(() => validateURL(null)).toThrow(ValidationError);
});
test('accepts valid https URL', () => {
expect(validateURL('https://example.com')).toBe('https://example.com');
});
test('accepts valid http URL', () => {
expect(validateURL('http://example.com')).toBe('http://example.com');
});
test('rejects non-URL strings', () => {
expect(() => validateURL('not a url')).toThrow(ValidationError);
});
test('blocks localhost when blockPrivate is true', () => {
expect(() => validateURL('http://localhost:3000', { blockPrivate: true })).toThrow(ValidationError);
});
test('blocks 127.0.0.1 when blockPrivate is true', () => {
expect(() => validateURL('http://127.0.0.1:3000', { blockPrivate: true })).toThrow(ValidationError);
});
test('blocks private IPs when blockPrivate is true', () => {
expect(() => validateURL('http://192.168.1.1', { blockPrivate: true })).toThrow(ValidationError);
});
test('allows private IPs when blockPrivate is false', () => {
expect(validateURL('http://192.168.1.1')).toBe('http://192.168.1.1');
});
});
describe('validateToken', () => {
test('rejects empty token', () => {
expect(() => validateToken('')).toThrow(ValidationError);
});
test('rejects null token', () => {
expect(() => validateToken(null)).toThrow(ValidationError);
});
test('rejects token shorter than 8 chars', () => {
expect(() => validateToken('short')).toThrow(ValidationError);
});
test('rejects token longer than 512 chars', () => {
expect(() => validateToken('a'.repeat(513))).toThrow(ValidationError);
});
test('rejects token with semicolon', () => {
expect(() => validateToken('token123;evil')).toThrow(ValidationError);
});
test('rejects token with $( subshell', () => {
expect(() => validateToken('token123$(evil)')).toThrow(ValidationError);
});
test('rejects token with &&', () => {
expect(() => validateToken('token123&&evil')).toThrow(ValidationError);
});
test('accepts valid alphanumeric token', () => {
expect(validateToken('abcdef12345678')).toBe('abcdef12345678');
});
test('trims whitespace', () => {
expect(validateToken(' abcdef12345678 ')).toBe('abcdef12345678');
});
test('accepts token at minimum length (8)', () => {
expect(validateToken('12345678')).toBe('12345678');
});
test('accepts token at maximum length (512)', () => {
const token = 'a'.repeat(512);
expect(validateToken(token)).toBe(token);
});
});
describe('validateServiceConfig', () => {
const valid = { id: 'my-service', name: 'My Service' };
test('accepts valid service config', () => {
const result = validateServiceConfig(valid);
expect(result.id).toBe('my-service');
});
test('rejects missing ID', () => {
const err = getErrors(() => validateServiceConfig({ name: 'Test' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects invalid ID format', () => {
const err = getErrors(() => validateServiceConfig({ id: 'bad id!', name: 'Test' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects missing name', () => {
const err = getErrors(() => validateServiceConfig({ id: 'test' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('rejects name exceeding 100 chars', () => {
const err = getErrors(() => validateServiceConfig({ id: 'test', name: 'a'.repeat(101) }));
expect(err).toBeInstanceOf(ValidationError);
});
test('validates URL when present', () => {
const err = getErrors(() => validateServiceConfig({ ...valid, url: 'not a url' }));
expect(err).toBeInstanceOf(ValidationError);
});
test('validates port when present', () => {
const err = getErrors(() => validateServiceConfig({ ...valid, port: 99999 }));
expect(err).toBeInstanceOf(ValidationError);
});
test('accepts valid URL and port', () => {
const result = validateServiceConfig({ ...valid, url: 'http://example.com', port: 8080 });
expect(result.id).toBe('my-service');
});
test('aggregates multiple errors', () => {
const err = getErrors(() => validateServiceConfig({}));
expect(err.errors.length).toBeGreaterThan(1);
});
});
describe('isValidPort', () => {
test('accepts port 1', () => {
expect(isValidPort(1)).toBe(true);
});
test('accepts port 65535', () => {
expect(isValidPort(65535)).toBe(true);
});
test('rejects port 0', () => {
expect(isValidPort(0)).toBe(false);
});
test('rejects port 65536', () => {
expect(isValidPort(65536)).toBe(false);
});
test('accepts string port "8080"', () => {
expect(isValidPort('8080')).toBe(true);
});
test('rejects NaN', () => {
expect(isValidPort('abc')).toBe(false);
});
test('rejects negative port', () => {
expect(isValidPort(-1)).toBe(false);
});
});
describe('isPrivateIP', () => {
test('detects 10.x.x.x as private', () => {
expect(isPrivateIP('10.0.0.1')).toBe(true);
expect(isPrivateIP('10.255.255.255')).toBe(true);
});
test('detects 172.16-31.x.x as private', () => {
expect(isPrivateIP('172.16.0.1')).toBe(true);
expect(isPrivateIP('172.31.255.255')).toBe(true);
});
test('does not flag 172.15.x.x as private', () => {
expect(isPrivateIP('172.15.0.1')).toBe(false);
});
test('does not flag 172.32.x.x as private', () => {
expect(isPrivateIP('172.32.0.1')).toBe(false);
});
test('detects 192.168.x.x as private', () => {
expect(isPrivateIP('192.168.1.1')).toBe(true);
});
test('detects 127.x.x.x as private', () => {
expect(isPrivateIP('127.0.0.1')).toBe(true);
expect(isPrivateIP('127.255.255.255')).toBe(true);
});
test('detects 169.254.x.x as private', () => {
expect(isPrivateIP('169.254.1.1')).toBe(true);
});
test('detects ::1 as private', () => {
expect(isPrivateIP('::1')).toBe(true);
});
test('detects fc00: as private', () => {
expect(isPrivateIP('fc00::1')).toBe(true);
});
test('detects fe80: as private', () => {
expect(isPrivateIP('fe80::1')).toBe(true);
});
test('identifies 8.8.8.8 as public', () => {
expect(isPrivateIP('8.8.8.8')).toBe(false);
});
test('identifies 1.1.1.1 as public', () => {
expect(isPrivateIP('1.1.1.1')).toBe(false);
});
});
describe('sanitizeString', () => {
test('escapes < to &lt;', () => {
expect(sanitizeString('<script>')).toBe('&lt;script&gt;');
});
test('escapes > to &gt;', () => {
expect(sanitizeString('a>b')).toBe('a&gt;b');
});
test('escapes single quote to &#39;', () => {
expect(sanitizeString("it's")).toBe('it&#39;s');
});
test('escapes double quote to &quot;', () => {
expect(sanitizeString('say "hi"')).toBe('say &quot;hi&quot;');
});
test('truncates to maxLength', () => {
expect(sanitizeString('hello world', 5)).toBe('hello');
});
test('returns empty string for non-string input', () => {
expect(sanitizeString(123)).toBe('');
expect(sanitizeString(null)).toBe('');
expect(sanitizeString(undefined)).toBe('');
});
test('uses default maxLength of 1000', () => {
const long = 'a'.repeat(1500);
expect(sanitizeString(long).length).toBe(1000);
});
test('returns safe strings unchanged', () => {
expect(sanitizeString('hello world')).toBe('hello world');
});
});

View File

@@ -1,564 +0,0 @@
/**
* Integration Tests
*
* Tests multi-component workflows and end-to-end scenarios
* Validates that all DashCaddy components work together correctly
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
// Create test instance with isolated environment
const testServicesFile = path.join(os.tmpdir(), `integration-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `integration-config-${Date.now()}.json`);
const testDnsCredsFile = path.join(os.tmpdir(), `integration-dns-${Date.now()}.json`);
const testCaddyfile = path.join(os.tmpdir(), `integration-Caddyfile-${Date.now()}`);
// Set test environment
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.DNS_CREDENTIALS_FILE = testDnsCredsFile;
process.env.CADDYFILE_PATH = testCaddyfile;
process.env.CADDY_ADMIN_URL = 'http://localhost:2019';
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
// Initialize test files
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{"domain": "test.local"}', 'utf8');
fs.writeFileSync(testDnsCredsFile, '{}', 'utf8');
fs.writeFileSync(testCaddyfile, '# Test Caddyfile\n', 'utf8');
// Require app after environment setup
const app = require('../server');
describe('Integration Tests', () => {
beforeEach(async () => {
// Reset state through the API to respect file locks
await request(app).put('/api/services').send([]);
fs.writeFileSync(testConfigFile, '{"domain": "test.local"}', 'utf8');
});
afterAll(() => {
// Cleanup test files
try {
fs.unlinkSync(testServicesFile);
fs.unlinkSync(testConfigFile);
fs.unlinkSync(testDnsCredsFile);
fs.unlinkSync(testCaddyfile);
} catch (e) {
// Ignore cleanup errors
}
});
describe('End-to-End Service Deployment', () => {
test('should complete full service lifecycle: add → configure → verify → delete', async () => {
// Step 1: Add a new service
const newService = {
id: 'test-app',
name: 'Test Application',
logo: '/assets/test.png',
url: 'https://test.test.local'
};
const addRes = await request(app)
.post('/api/services')
.send(newService);
expect(addRes.statusCode).toBe(200);
expect(addRes.body.success).toBe(true);
// Step 2: Verify service appears in list
const listRes = await request(app).get('/api/services');
expect(listRes.statusCode).toBe(200);
expect(listRes.body.length).toBe(1);
expect(listRes.body[0].id).toBe('test-app');
// Step 3: Update service configuration
const updatedServices = [{
...newService,
status: 'online',
responseTime: 150
}];
const updateRes = await request(app)
.put('/api/services')
.send(updatedServices);
expect(updateRes.statusCode).toBe(200);
// Step 4: Verify update
const verifyRes = await request(app).get('/api/services');
expect(verifyRes.body[0].status).toBe('online');
// Step 5: Delete service
const deleteRes = await request(app).delete('/api/services/test-app');
expect(deleteRes.statusCode).toBe(200);
// Step 6: Verify deletion
const finalRes = await request(app).get('/api/services');
expect(finalRes.body.length).toBe(0);
});
test('should handle app deployment workflow: template → configure → deploy', async () => {
// Step 1: Get app template
const templateRes = await request(app).get('/api/apps/templates/jellyfin');
expect(templateRes.statusCode).toBe(200);
expect(templateRes.body.success).toBe(true);
const template = templateRes.body.template;
// Step 2: Configure app from template
const appConfig = {
id: 'jellyfin',
name: template.name,
logo: template.logo,
port: 8096,
subdomain: 'jellyfin'
};
// Step 3: Add configured service
const deployRes = await request(app)
.post('/api/services')
.send(appConfig);
expect(deployRes.statusCode).toBe(200);
// Step 4: Verify service is listed
const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body).toContainEqual(
expect.objectContaining({ id: 'jellyfin' })
);
});
});
describe('Multi-Service Management', () => {
test('should handle multiple services concurrently', async () => {
// Deploy 5 services simultaneously (reduced from 10 to avoid overwhelming)
const services = Array.from({ length: 5 }, (_, i) => ({
id: `concurrent-${i}`,
name: `Concurrent Service ${i}`,
logo: `/assets/service-${i}.png`
}));
const deployPromises = services.map(service =>
request(app).post('/api/services').send(service)
);
const results = await Promise.all(deployPromises);
// All deployments should succeed
results.forEach((res, index) => {
if (res.statusCode !== 200) {
console.log(`Service ${index} failed:`, res.body);
}
expect(res.statusCode).toBe(200);
});
// Verify all services are listed
const listRes = await request(app).get('/api/services');
expect(listRes.body.length).toBe(5);
});
test('should handle bulk import and individual updates', async () => {
// Step 1: Bulk import services
const bulkServices = [
{ id: 'plex', name: 'Plex' },
{ id: 'jellyfin', name: 'Jellyfin' },
{ id: 'emby', name: 'Emby' }
];
const importRes = await request(app)
.put('/api/services')
.send(bulkServices);
expect(importRes.statusCode).toBe(200);
// Step 2: Update individual service
const updatedServices = [
{ id: 'plex', name: 'Plex', status: 'online' },
{ id: 'jellyfin', name: 'Jellyfin' },
{ id: 'emby', name: 'Emby' }
];
await request(app).put('/api/services').send(updatedServices);
// Step 3: Verify specific service was updated
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
const plexService = services.find(s => s.id === 'plex');
expect(plexService.status).toBe('online');
});
test('should maintain data consistency across operations', async () => {
// Perform series of operations
await request(app).post('/api/services').send({ id: 's1', name: 'Service 1' });
await request(app).post('/api/services').send({ id: 's2', name: 'Service 2' });
await request(app).post('/api/services').send({ id: 's3', name: 'Service 3' });
// Verify count
let services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(3);
// Delete one
await request(app).delete('/api/services/s2');
// Verify count and content
services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(2);
expect(services.find(s => s.id === 's2')).toBeUndefined();
expect(services.find(s => s.id === 's1')).toBeDefined();
expect(services.find(s => s.id === 's3')).toBeDefined();
});
});
describe('Configuration Management Integration', () => {
test('should coordinate config changes with service updates', async () => {
// Step 1: Set initial config
const config = {
domain: 'example.local',
theme: 'dark',
enableHealthCheck: false
};
const configRes = await request(app)
.post('/api/config')
.send(config);
expect(configRes.statusCode).toBe(200);
// Step 2: Add service that uses config
const service = {
id: 'test',
name: 'Test Service',
subdomain: 'test'
};
await request(app).post('/api/services').send(service);
// Step 3: Verify config persists
const getConfigRes = await request(app).get('/api/config');
expect(getConfigRes.body.domain).toBe('example.local');
// Step 4: Update config
const newConfig = { ...config, theme: 'light' };
await request(app).post('/api/config').send(newConfig);
// Step 5: Verify service still exists and config updated
const servicesRes = await request(app).get('/api/services');
const configCheckRes = await request(app).get('/api/config');
expect(servicesRes.body.length).toBe(1);
expect(configCheckRes.body.theme).toBe('light');
});
});
describe('Template Discovery and Deployment', () => {
test('should list templates, select one, and deploy', async () => {
// Step 1: Get all templates
const templatesRes = await request(app).get('/api/apps/templates');
expect(templatesRes.statusCode).toBe(200);
expect(Object.keys(templatesRes.body.templates).length).toBeGreaterThan(50);
// Step 2: Verify categories exist (format may vary)
expect(templatesRes.body).toHaveProperty('categories');
const categories = templatesRes.body.categories;
// Categories might be an array or object depending on implementation
expect(categories).toBeTruthy();
// Step 3: Select a specific template
const templateIds = Object.keys(templatesRes.body.templates);
const firstTemplateId = templateIds[0];
const singleTemplateRes = await request(app)
.get(`/api/apps/templates/${firstTemplateId}`);
expect(singleTemplateRes.statusCode).toBe(200);
expect(singleTemplateRes.body.template).toHaveProperty('name');
expect(singleTemplateRes.body.template).toHaveProperty('docker');
// Step 4: Deploy service from template
const service = {
id: firstTemplateId,
name: singleTemplateRes.body.template.name,
logo: singleTemplateRes.body.template.logo
};
const deployRes = await request(app)
.post('/api/services')
.send(service);
expect(deployRes.statusCode).toBe(200);
});
test('should handle template with complex configuration', async () => {
// Get a complex template (Plex has environment variables, volumes, etc.)
const templateRes = await request(app).get('/api/apps/templates/plex');
expect(templateRes.statusCode).toBe(200);
const template = templateRes.body.template;
// Verify template has complex config
expect(template.docker).toHaveProperty('image');
expect(template.docker).toHaveProperty('environment');
expect(template.docker).toHaveProperty('volumes');
// Deploy with configuration
const service = {
id: 'plex-prod',
name: 'Plex Production',
logo: template.logo,
port: 32400,
subdomain: 'plex'
};
const deployRes = await request(app)
.post('/api/services')
.send(service);
expect(deployRes.statusCode).toBe(200);
// Verify service exists
const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body).toContainEqual(
expect.objectContaining({ id: 'plex-prod' })
);
});
});
describe('Error Recovery and Resilience', () => {
test('should recover from invalid service data', async () => {
// Add valid service
await request(app)
.post('/api/services')
.send({ id: 'valid', name: 'Valid Service' });
// Try to add invalid service
const invalidRes = await request(app)
.post('/api/services')
.send({ id: 'invalid' }); // Missing required 'name' field
expect(invalidRes.statusCode).toBe(400);
// Verify valid service still exists
const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body.length).toBe(1);
expect(servicesRes.body[0].id).toBe('valid');
});
test('should handle file corruption gracefully', async () => {
// Add some services
await request(app)
.post('/api/services')
.send({ id: 's1', name: 'Service 1' });
// Simulate file corruption (invalid JSON)
fs.writeFileSync(testServicesFile, '{ invalid json }', 'utf8');
// API should handle this gracefully
const res = await request(app).get('/api/services');
// Should either return error or empty array (depending on implementation)
expect([200, 500]).toContain(res.statusCode);
});
test('should maintain consistency during concurrent modifications', async () => {
// Start with empty state
const initialServices = [
{ id: 'base1', name: 'Base 1' },
{ id: 'base2', name: 'Base 2' }
];
await request(app).put('/api/services').send(initialServices);
// Perform concurrent operations
const operations = [
request(app).post('/api/services').send({ id: 'new1', name: 'New 1' }),
request(app).post('/api/services').send({ id: 'new2', name: 'New 2' }),
request(app).delete('/api/services/base1'),
request(app).post('/api/services').send({ id: 'new3', name: 'New 3' })
];
await Promise.all(operations);
// Verify final state is consistent
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
// Should have base2 + 3 new services = 4 total
expect(services.length).toBe(4);
expect(services.find(s => s.id === 'base1')).toBeUndefined();
expect(services.find(s => s.id === 'base2')).toBeDefined();
expect(services.find(s => s.id === 'new1')).toBeDefined();
expect(services.find(s => s.id === 'new2')).toBeDefined();
expect(services.find(s => s.id === 'new3')).toBeDefined();
});
});
describe('Health Check Integration', () => {
test('should verify API health before operations', async () => {
// Check health
const healthRes = await request(app).get('/api/health');
expect(healthRes.statusCode).toBe(200);
expect(healthRes.body.status).toBe('ok');
// Perform operation
const addRes = await request(app)
.post('/api/services')
.send({ id: 'test', name: 'Test' });
expect(addRes.statusCode).toBe(200);
// Check health again
const healthRes2 = await request(app).get('/api/health');
expect(healthRes2.statusCode).toBe(200);
});
});
describe('Real-World Workflow Scenarios', () => {
test('Scenario: User discovers and deploys multiple media apps', async () => {
// Step 1: Browse templates
const templatesRes = await request(app).get('/api/apps/templates');
const templates = templatesRes.body.templates;
// Step 2: Find media apps
const mediaApps = ['plex', 'jellyfin', 'emby'];
const selectedApps = mediaApps.map(id => ({
id,
name: templates[id].name,
logo: templates[id].logo
}));
// Step 3: Deploy all media apps
for (const serviceConfig of selectedApps) {
const res = await request(app)
.post('/api/services')
.send(serviceConfig);
expect(res.statusCode).toBe(200);
}
// Step 4: Verify all deployed
const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body.length).toBe(3);
mediaApps.forEach(appId => {
expect(servicesRes.body.find(s => s.id === appId)).toBeDefined();
});
});
test('Scenario: Admin configures system and imports existing services', async () => {
// Step 1: Set system configuration
const config = {
domain: 'homelab.local',
theme: 'dark',
enableHealthCheck: true
};
await request(app).post('/api/config').send(config);
// Step 2: Import existing services from backup
const existingServices = [
{ id: 'router', name: 'Router', logo: '/assets/router.png' },
{ id: 'nas', name: 'NAS', logo: '/assets/nas.png' },
{ id: 'pihole', name: 'Pi-hole', logo: '/assets/pihole.png' }
];
await request(app).put('/api/services').send(existingServices);
// Step 3: Add new service
await request(app)
.post('/api/services')
.send({ id: 'newapp', name: 'New App' });
// Step 4: Verify all services
const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body.length).toBe(4);
// Step 5: Verify config persisted
const configRes = await request(app).get('/api/config');
expect(configRes.body.domain).toBe('homelab.local');
});
test('Scenario: User reorganizes services (delete old, add new)', async () => {
// Step 1: Start with existing services
const oldServices = [
{ id: 'old1', name: 'Old Service 1' },
{ id: 'old2', name: 'Old Service 2' },
{ id: 'keep', name: 'Keep This' }
];
await request(app).put('/api/services').send(oldServices);
// Step 2: Delete old services
await request(app).delete('/api/services/old1');
await request(app).delete('/api/services/old2');
// Step 3: Add new services
await request(app)
.post('/api/services')
.send({ id: 'new1', name: 'New Service 1' });
await request(app)
.post('/api/services')
.send({ id: 'new2', name: 'New Service 2' });
// Step 4: Verify final state
const servicesRes = await request(app).get('/api/services');
expect(servicesRes.body.length).toBe(3);
const serviceIds = servicesRes.body.map(s => s.id);
expect(serviceIds).toContain('keep');
expect(serviceIds).toContain('new1');
expect(serviceIds).toContain('new2');
expect(serviceIds).not.toContain('old1');
expect(serviceIds).not.toContain('old2');
});
});
describe('Data Persistence and State Management', () => {
test('should persist data across multiple operations', async () => {
// Create initial state
await request(app)
.post('/api/services')
.send({ id: 'persistent', name: 'Persistent Service' });
// Read file directly
const services1 = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services1.length).toBe(1);
// Modify through API
await request(app)
.post('/api/services')
.send({ id: 'another', name: 'Another Service' });
// Read file again
const services2 = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services2.length).toBe(2);
// Verify through API
const apiRes = await request(app).get('/api/services');
expect(apiRes.body.length).toBe(2);
// All three methods should show same data
expect(services2).toEqual(apiRes.body);
});
test('should handle rapid sequential operations', async () => {
// Perform 10 rapid operations (sequential, not parallel)
for (let i = 0; i < 10; i++) {
const res = await request(app)
.post('/api/services')
.send({ id: `rapid-${i}`, name: `Rapid ${i}` });
if (res.statusCode !== 200) {
console.log(`Rapid operation ${i} failed:`, res.body);
}
expect(res.statusCode).toBe(200);
}
// Verify all 10 services exist
const services = JSON.parse(fs.readFileSync(testServicesFile, 'utf8'));
expect(services.length).toBe(10);
});
});
});

View File

@@ -1,21 +0,0 @@
const os = require('os');
const path = require('path');
// Use temp directory for all file-based operations during tests
const tmpDir = path.join(os.tmpdir(), 'dashcaddy-tests');
// Prevent modules from touching production files
process.env.ENCRYPTION_KEY_FILE = path.join(tmpDir, '.encryption-key');
process.env.DASHCADDY_ENCRYPTION_KEY = 'a'.repeat(64); // 32 bytes in hex for test determinism
// Suppress console output during tests (set DEBUG_TESTS=1 to enable)
if (!process.env.DEBUG_TESTS) {
global.console = {
...console,
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
}

View File

@@ -1,164 +0,0 @@
/**
* Tests for logger-utils.js
* Created: 2026-03-21
*/
const { sanitizeForLog, redactCredential, safeLog, SENSITIVE_FIELDS } = require('../logger-utils');
describe('logger-utils', () => {
describe('sanitizeForLog', () => {
test('should redact sensitive field names', () => {
const input = {
username: 'admin',
password: 'secret123',
apiKey: 'abc-def-ghi',
token: 'xyz123'
};
const result = sanitizeForLog(input);
expect(result.username).toBe('admin');
expect(result.password).toBe('[REDACTED]');
expect(result.apiKey).toBe('[REDACTED]');
expect(result.token).toBe('[REDACTED]');
});
test('should handle nested objects', () => {
const input = {
user: {
name: 'Alice',
credentials: {
password: 'secret',
token: 'abc123'
}
}
};
const result = sanitizeForLog(input);
expect(result.user.name).toBe('Alice');
expect(result.user.credentials.password).toBe('[REDACTED]');
expect(result.user.credentials.token).toBe('[REDACTED]');
});
test('should handle arrays', () => {
const input = [
{ name: 'user1', password: 'pass1' },
{ name: 'user2', secret: 'pass2' }
];
const result = sanitizeForLog(input);
expect(result[0].name).toBe('user1');
expect(result[0].password).toBe('[REDACTED]');
expect(result[1].name).toBe('user2');
expect(result[1].secret).toBe('[REDACTED]');
});
test('should handle null and undefined', () => {
expect(sanitizeForLog(null)).toBeNull();
expect(sanitizeForLog(undefined)).toBeUndefined();
});
test('should support additional sensitive keys', () => {
const input = {
email: 'user@example.com',
ssn: '123-45-6789'
};
const result = sanitizeForLog(input, ['ssn']);
expect(result.email).toBe('user@example.com');
expect(result.ssn).toBe('[REDACTED]');
});
test('should be case-insensitive for field matching', () => {
const input = {
PASSWORD: 'secret',
ApiKey: 'key123',
Bearer_Token: 'token456'
};
const result = sanitizeForLog(input);
expect(result.PASSWORD).toBe('[REDACTED]');
expect(result.ApiKey).toBe('[REDACTED]');
expect(result.Bearer_Token).toBe('[REDACTED]');
});
});
describe('redactCredential', () => {
test('should show first and last 4 characters for long strings', () => {
const input = 'abcdefghijklmnop';
const result = redactCredential(input);
expect(result).toMatch(/^abcd.*mnop$/);
expect(result).toContain('*');
});
test('should fully redact short strings', () => {
expect(redactCredential('short')).toBe('[REDACTED]');
expect(redactCredential('12345678')).toBe('[REDACTED]');
});
test('should handle null/undefined', () => {
expect(redactCredential(null)).toBe('[REDACTED]');
expect(redactCredential(undefined)).toBe('[REDACTED]');
});
test('should handle non-string input', () => {
expect(redactCredential(12345)).toBe('[REDACTED]');
expect(redactCredential({})).toBe('[REDACTED]');
});
test('should limit middle asterisks to 10', () => {
const input = 'a'.repeat(100);
const result = redactCredential(input);
const asteriskMatch = result.match(/\*/g);
expect(asteriskMatch).toBeTruthy();
expect(asteriskMatch.length).toBe(10);
});
});
describe('safeLog', () => {
test('should create safe log object with message and sanitized data', () => {
const result = safeLog('User login', {
username: 'alice',
password: 'secret123'
});
expect(result).toHaveProperty('message', 'User login');
expect(result).toHaveProperty('timestamp');
expect(result.data.username).toBe('alice');
expect(result.data.password).toBe('[REDACTED]');
});
test('should include timestamp in ISO format', () => {
const result = safeLog('Test message');
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
test('should handle empty data', () => {
const result = safeLog('Test message');
expect(result.message).toBe('Test message');
expect(result.data).toEqual({});
});
});
describe('SENSITIVE_FIELDS constant', () => {
test('should include common sensitive field names', () => {
expect(SENSITIVE_FIELDS).toContain('password');
expect(SENSITIVE_FIELDS).toContain('token');
expect(SENSITIVE_FIELDS).toContain('secret');
expect(SENSITIVE_FIELDS).toContain('apiKey');
expect(SENSITIVE_FIELDS).toContain('privateKey');
});
test('should have reasonable length', () => {
expect(SENSITIVE_FIELDS.length).toBeGreaterThan(10);
});
});
});

View File

@@ -1,51 +0,0 @@
/**
* Container Log Route Tests
*
* Tests Docker container log listing and retrieval endpoints
* Note: These tests run against the real Docker socket if available,
* or will gracefully handle Docker being unavailable in CI.
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `logs-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `logs-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Container Log Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/logs/containers', () => {
test('should return 200 with containers array', async () => {
const res = await request(app).get('/api/logs/containers');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(Array.isArray(res.body.containers)).toBe(true);
});
});
describe('GET /api/logs/container/:id', () => {
test('should return 404 or 500 for nonexistent container', async () => {
const res = await request(app).get('/api/logs/container/nonexistent');
// Docker will throw a not-found error for an invalid container ID
expect([404, 500]).toContain(res.statusCode);
});
});
});

View File

@@ -1,98 +0,0 @@
/**
* Monitoring Route Tests
*
* Tests resource monitoring endpoints and legacy container stats endpoints.
* Note: GET /api/stats/containers requires a live Docker connection, so in the
* test environment it will return 500 (Docker unavailable). We assert both
* the happy path (200) and the expected failure (500) to keep the test green
* regardless of whether Docker is running.
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `monitoring-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `monitoring-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Monitoring Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/monitoring/stats', () => {
test('should return 200 with stats data', async () => {
const res = await request(app).get('/api/monitoring/stats');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('stats');
});
});
describe('GET /api/monitoring/stats/:containerId', () => {
test('should return 404 for non-existent container', async () => {
const res = await request(app).get('/api/monitoring/stats/nonexistent-container');
expect(res.statusCode).toBe(404);
});
});
describe('GET /api/monitoring/history/:containerId', () => {
test('should return 200 with history array for any container ID', async () => {
const res = await request(app).get('/api/monitoring/history/some-container');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('history');
expect(res.body).toHaveProperty('hours');
});
test('should accept hours query parameter', async () => {
const res = await request(app)
.get('/api/monitoring/history/some-container')
.query({ hours: 6 });
expect(res.statusCode).toBe(200);
expect(res.body.hours).toBe(6);
});
});
describe('GET /api/monitoring/alerts/:containerId', () => {
test('should return 200 with alert config (empty for unknown container)', async () => {
const res = await request(app).get('/api/monitoring/alerts/unknown-container');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('config');
});
});
describe('GET /api/stats/containers', () => {
test('should return 200 with containers array or 500 if Docker unavailable', async () => {
const res = await request(app).get('/api/stats/containers');
// In test environment Docker may not be available
expect([200, 500]).toContain(res.statusCode);
if (res.statusCode === 200) {
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('stats');
expect(Array.isArray(res.body.stats)).toBe(true);
expect(res.body).toHaveProperty('timestamp');
}
});
});
});

View File

@@ -1,181 +0,0 @@
/**
* Notification Route Tests
*
* Tests notification configuration, test delivery, and history endpoints.
* Notifications are mounted at /api/notifications/ prefix.
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `notifications-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `notifications-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Notification Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/notifications/config', () => {
test('should return 200 with config object', async () => {
const res = await request(app).get('/api/notifications/config');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('config');
expect(res.body.config).toHaveProperty('enabled');
expect(res.body.config).toHaveProperty('providers');
expect(res.body.config.providers).toHaveProperty('discord');
expect(res.body.config.providers).toHaveProperty('telegram');
expect(res.body.config.providers).toHaveProperty('ntfy');
});
test('should redact sensitive provider data', async () => {
const res = await request(app).get('/api/notifications/config');
expect(res.statusCode).toBe(200);
// Should show enabled/configured flags, not raw webhook URLs or tokens
const discord = res.body.config.providers.discord;
expect(discord).toHaveProperty('enabled');
expect(discord).toHaveProperty('configured');
expect(discord).not.toHaveProperty('webhookUrl');
});
});
describe('POST /api/notifications/config', () => {
test('should return 200 when updating enabled state', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({ enabled: true });
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toContain('updated');
});
test('should return 200 when updating event settings', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({
events: {
containerDown: true,
containerUp: false
}
});
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
});
test('should reject invalid Discord webhook URL', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({
providers: {
discord: {
enabled: true,
webhookUrl: 'not-a-valid-url'
}
}
});
expect(res.statusCode).toBe(400);
});
test('should reject invalid ntfy topic', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({
providers: {
ntfy: {
enabled: true,
topic: 'invalid topic with spaces!!!'
}
}
});
expect(res.statusCode).toBe(400);
});
});
describe('POST /api/notifications/test', () => {
test('should handle test with unknown provider', async () => {
const res = await request(app)
.post('/api/notifications/test')
.send({ provider: 'unknown_provider' });
expect(res.statusCode).toBe(400);
});
test('should handle test with no provider (tests all enabled)', async () => {
const res = await request(app)
.post('/api/notifications/test')
.send({});
// When no providers are configured, should still return 200
// with sent: true (but results array may be empty or have failures)
expect([200, 400]).toContain(res.statusCode);
if (res.statusCode === 200) {
expect(res.body.success).toBe(true);
}
});
test('should handle discord test gracefully when not configured', async () => {
const res = await request(app)
.post('/api/notifications/test')
.send({ provider: 'discord' });
// Discord test without a webhook URL configured will fail
// but should still return 200 with success: false
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('success');
expect(res.body.provider).toBe('discord');
});
});
describe('GET /api/notifications/history', () => {
test('should return 200 with history array', async () => {
const res = await request(app).get('/api/notifications/history');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('history');
expect(Array.isArray(res.body.history)).toBe(true);
expect(res.body).toHaveProperty('total');
expect(typeof res.body.total).toBe('number');
});
test('should respect limit query parameter', async () => {
const res = await request(app)
.get('/api/notifications/history')
.query({ limit: 10 });
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.history.length).toBeLessThanOrEqual(10);
});
});
describe('DELETE /api/notifications/history', () => {
test('should clear notification history', async () => {
const res = await request(app).delete('/api/notifications/history');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toContain('cleared');
});
});
});

View File

@@ -1,294 +0,0 @@
// resource-monitor.js creates a Docker instance at module level.
// On test machines without Docker, the constructor reads from non-existent files (returns defaults).
const resourceMonitor = require('../resource-monitor');
beforeEach(() => {
// Reset singleton state
resourceMonitor.stats = new Map();
resourceMonitor.alerts = new Map();
resourceMonitor.lastAlerts = new Map();
resourceMonitor.monitoring = false;
if (resourceMonitor.monitoringInterval) {
clearInterval(resourceMonitor.monitoringInterval);
resourceMonitor.monitoringInterval = null;
}
});
afterAll(() => {
resourceMonitor.stop();
});
// Helper: create a stat entry
function makeStat(cpu = 10, memory = 50, timestamp = new Date().toISOString()) {
return {
timestamp,
cpu: { percent: cpu, usage: cpu * 1000 },
memory: { usage: memory * 1024 * 1024, limit: 1024 * 1024 * 1024, percent: memory, usageMB: memory, limitMB: 1024 },
network: { rxBytes: 0, txBytes: 0, rxMB: 0, txMB: 0 },
disk: { readBytes: 0, writeBytes: 0, readMB: 0, writeMB: 0 },
pids: 5
};
}
describe('recordStats', () => {
test('creates new entry for unknown container', () => {
resourceMonitor.recordStats('c1', '/my-app', makeStat());
expect(resourceMonitor.stats.has('c1')).toBe(true);
expect(resourceMonitor.stats.get('c1').history).toHaveLength(1);
});
test('appends to existing history', () => {
resourceMonitor.recordStats('c1', '/my-app', makeStat());
resourceMonitor.recordStats('c1', '/my-app', makeStat());
expect(resourceMonitor.stats.get('c1').history).toHaveLength(2);
});
test('updates container name', () => {
resourceMonitor.recordStats('c1', '/old-name', makeStat());
resourceMonitor.recordStats('c1', '/new-name', makeStat());
expect(resourceMonitor.stats.get('c1').name).toBe('/new-name');
});
});
describe('getCurrentStats', () => {
test('returns null for unknown container', () => {
expect(resourceMonitor.getCurrentStats('nonexistent')).toBeNull();
});
test('returns latest history entry', () => {
const stat1 = makeStat(10);
const stat2 = makeStat(50);
resourceMonitor.recordStats('c1', '/app', stat1);
resourceMonitor.recordStats('c1', '/app', stat2);
expect(resourceMonitor.getCurrentStats('c1').cpu.percent).toBe(50);
});
});
describe('getHistoricalStats', () => {
test('returns empty array for unknown container', () => {
expect(resourceMonitor.getHistoricalStats('nonexistent')).toEqual([]);
});
test('filters by time window', () => {
const recent = makeStat(10, 50, new Date().toISOString());
const old = makeStat(10, 50, new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString());
resourceMonitor.stats.set('c1', { name: '/app', history: [old, recent] });
const result = resourceMonitor.getHistoricalStats('c1', 24);
expect(result).toHaveLength(1);
expect(result[0]).toBe(recent);
});
});
describe('getAggregatedStats', () => {
test('returns null for unknown container', () => {
expect(resourceMonitor.getAggregatedStats('nonexistent')).toBeNull();
});
test('returns null when no recent history', () => {
const old = makeStat(10, 50, new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString());
resourceMonitor.stats.set('c1', { name: '/app', history: [old] });
expect(resourceMonitor.getAggregatedStats('c1', 24)).toBeNull();
});
test('calculates correct avg/min/max for CPU', () => {
const now = new Date().toISOString();
resourceMonitor.stats.set('c1', {
name: '/app',
history: [makeStat(10, 50, now), makeStat(30, 50, now), makeStat(20, 50, now)]
});
const agg = resourceMonitor.getAggregatedStats('c1', 24);
expect(agg.cpu.avg).toBe(20);
expect(agg.cpu.min).toBe(10);
expect(agg.cpu.max).toBe(30);
});
test('calculates correct avg/min/max for memory', () => {
const now = new Date().toISOString();
resourceMonitor.stats.set('c1', {
name: '/app',
history: [makeStat(10, 40, now), makeStat(10, 60, now), makeStat(10, 80, now)]
});
const agg = resourceMonitor.getAggregatedStats('c1', 24);
expect(agg.memory.avg).toBe(60);
expect(agg.memory.min).toBe(40);
expect(agg.memory.max).toBe(80);
});
test('includes dataPoints and timeRange', () => {
const now = new Date().toISOString();
resourceMonitor.stats.set('c1', { name: '/app', history: [makeStat(10, 50, now)] });
const agg = resourceMonitor.getAggregatedStats('c1', 24);
expect(agg.dataPoints).toBe(1);
expect(agg.timeRange).toBe(24);
});
});
describe('checkAlerts', () => {
test('does nothing when alert config is missing', () => {
const handler = jest.fn();
resourceMonitor.on('alert', handler);
resourceMonitor.checkAlerts('c1', '/app', makeStat(99));
expect(handler).not.toHaveBeenCalled();
resourceMonitor.removeListener('alert', handler);
});
test('does nothing when alerts are disabled', () => {
resourceMonitor.alerts.set('c1', { enabled: false, cpuThreshold: 50 });
const handler = jest.fn();
resourceMonitor.on('alert', handler);
resourceMonitor.checkAlerts('c1', '/app', makeStat(99));
expect(handler).not.toHaveBeenCalled();
resourceMonitor.removeListener('alert', handler);
});
test('triggers CPU alert when threshold exceeded', () => {
resourceMonitor.alerts.set('c1', { enabled: true, cpuThreshold: 50, cooldownMinutes: 0 });
const handler = jest.fn();
resourceMonitor.on('alert', handler);
resourceMonitor.checkAlerts('c1', '/app', makeStat(75));
expect(handler).toHaveBeenCalled();
const alertData = handler.mock.calls[0][0];
expect(alertData.alerts[0].type).toBe('cpu');
resourceMonitor.removeListener('alert', handler);
});
test('triggers memory alert when threshold exceeded', () => {
resourceMonitor.alerts.set('c1', { enabled: true, memoryThreshold: 70, cooldownMinutes: 0 });
const handler = jest.fn();
resourceMonitor.on('alert', handler);
resourceMonitor.checkAlerts('c1', '/app', makeStat(10, 80));
expect(handler).toHaveBeenCalled();
const alertData = handler.mock.calls[0][0];
expect(alertData.alerts[0].type).toBe('memory');
resourceMonitor.removeListener('alert', handler);
});
test('respects cooldown period', () => {
resourceMonitor.alerts.set('c1', { enabled: true, cpuThreshold: 50, cooldownMinutes: 15 });
resourceMonitor.lastAlerts.set('c1', Date.now()); // Just alerted
const handler = jest.fn();
resourceMonitor.on('alert', handler);
resourceMonitor.checkAlerts('c1', '/app', makeStat(99));
expect(handler).not.toHaveBeenCalled();
resourceMonitor.removeListener('alert', handler);
});
test('does not trigger when below threshold', () => {
resourceMonitor.alerts.set('c1', { enabled: true, cpuThreshold: 90, cooldownMinutes: 0 });
const handler = jest.fn();
resourceMonitor.on('alert', handler);
resourceMonitor.checkAlerts('c1', '/app', makeStat(50));
expect(handler).not.toHaveBeenCalled();
resourceMonitor.removeListener('alert', handler);
});
});
describe('setAlertConfig / getAlertConfig / removeAlertConfig', () => {
test('stores alert config', () => {
resourceMonitor.setAlertConfig('c1', { cpuThreshold: 80 });
expect(resourceMonitor.alerts.has('c1')).toBe(true);
});
test('retrieves stored config', () => {
resourceMonitor.setAlertConfig('c1', { cpuThreshold: 80 });
const config = resourceMonitor.getAlertConfig('c1');
expect(config.cpuThreshold).toBe(80);
});
test('returns null for non-existent config', () => {
expect(resourceMonitor.getAlertConfig('nonexistent')).toBeNull();
});
test('removes config and last alert', () => {
resourceMonitor.setAlertConfig('c1', { cpuThreshold: 80 });
resourceMonitor.lastAlerts.set('c1', Date.now());
resourceMonitor.removeAlertConfig('c1');
expect(resourceMonitor.alerts.has('c1')).toBe(false);
expect(resourceMonitor.lastAlerts.has('c1')).toBe(false);
});
});
describe('getAllStats', () => {
test('returns empty object when no stats', () => {
expect(resourceMonitor.getAllStats()).toEqual({});
});
test('includes current and aggregated for each container', () => {
const now = new Date().toISOString();
resourceMonitor.stats.set('c1', { name: '/app', history: [makeStat(10, 50, now)] });
const all = resourceMonitor.getAllStats();
expect(all['c1']).toBeDefined();
expect(all['c1'].name).toBe('/app');
expect(all['c1'].current).toBeDefined();
expect(all['c1'].aggregated).toBeDefined();
});
});
describe('exportStats / importStats', () => {
test('export returns object with stats and alerts', () => {
const now = new Date().toISOString();
resourceMonitor.stats.set('c1', { name: '/app', history: [makeStat(10, 50, now)] });
resourceMonitor.alerts.set('c1', { enabled: true, cpuThreshold: 80 });
const exported = resourceMonitor.exportStats();
expect(exported.stats).toBeDefined();
expect(exported.alerts).toBeDefined();
expect(exported.exportedAt).toBeDefined();
});
test('import restores stats from backup', () => {
const backup = {
stats: { 'c1': { name: '/app', history: [makeStat()] } },
alerts: { 'c1': { enabled: true, cpuThreshold: 80 } }
};
resourceMonitor.importStats(backup);
expect(resourceMonitor.stats.has('c1')).toBe(true);
expect(resourceMonitor.alerts.has('c1')).toBe(true);
});
});
describe('cleanupOldStats', () => {
test('removes entries older than retention period', () => {
const old = makeStat(10, 50, new Date(Date.now() - 200 * 60 * 60 * 1000).toISOString());
const recent = makeStat(10, 50, new Date().toISOString());
resourceMonitor.stats.set('c1', { name: '/app', history: [old, recent] });
resourceMonitor.cleanupOldStats();
expect(resourceMonitor.stats.get('c1').history).toHaveLength(1);
});
test('deletes container entirely when no recent data', () => {
const old = makeStat(10, 50, new Date(Date.now() - 200 * 60 * 60 * 1000).toISOString());
resourceMonitor.stats.set('c1', { name: '/app', history: [old] });
resourceMonitor.cleanupOldStats();
expect(resourceMonitor.stats.has('c1')).toBe(false);
});
});
describe('start / stop', () => {
test('start sets monitoring flag', () => {
jest.useFakeTimers();
resourceMonitor.start();
expect(resourceMonitor.monitoring).toBe(true);
resourceMonitor.stop();
jest.useRealTimers();
});
test('stop clears interval', () => {
jest.useFakeTimers();
resourceMonitor.start();
resourceMonitor.stop();
expect(resourceMonitor.monitoring).toBe(false);
expect(resourceMonitor.monitoringInterval).toBeNull();
jest.useRealTimers();
});
test('start is idempotent', () => {
jest.useFakeTimers();
resourceMonitor.start();
const first = resourceMonitor.monitoringInterval;
resourceMonitor.start();
expect(resourceMonitor.monitoringInterval).toBe(first);
resourceMonitor.stop();
jest.useRealTimers();
});
});

View File

@@ -1,721 +0,0 @@
/**
* Security Regression Tests
*
* Tests for all 24 security fixes applied to DashCaddy.
* These tests verify that previously-fixed vulnerabilities remain patched.
* Grouped by the module/route they protect.
*/
const request = require('supertest');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const tmpDir = path.join(os.tmpdir(), `security-tests-${Date.now()}`);
fs.mkdirSync(tmpDir, { recursive: true });
const testServicesFile = path.join(tmpDir, 'services.json');
const testConfigFile = path.join(tmpDir, 'config.json');
const testCaddyfile = path.join(tmpDir, 'Caddyfile');
const testCredentialsFile = path.join(tmpDir, 'credentials.json');
const testTotpConfigFile = path.join(tmpDir, 'totp-config.json');
const testErrorLogFile = path.join(tmpDir, 'error.log');
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.CADDYFILE_PATH = testCaddyfile;
process.env.CREDENTIALS_FILE = testCredentialsFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
fs.writeFileSync(testCaddyfile, '# Test Caddyfile\n', 'utf8');
const app = require('../server');
afterAll(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// ============================================================
// CREDENTIAL MANAGER — Cache TTL
// ============================================================
describe('Credential Manager Cache TTL', () => {
const CredentialManager = require('../credential-manager').constructor;
test('cache entries should have expiration timestamps', () => {
const cm = new CredentialManager();
cm.cache.set('test.key', { value: 'secret', exp: Date.now() + 300000 });
const cached = cm.cache.get('test.key');
expect(cached).toHaveProperty('exp');
expect(cached.exp).toBeGreaterThan(Date.now());
});
test('expired cache entries should not be returned by retrieve', async () => {
const cm = new CredentialManager();
// Set an expired entry
cm.cache.set('expired.key', { value: 'old-secret', exp: Date.now() - 1000 });
// retrieve() checks cache TTL — expired entry should be deleted
// Since there's no file backing, it will return null
const result = await cm.retrieve('expired.key');
expect(result).toBeNull();
expect(cm.cache.has('expired.key')).toBe(false);
});
});
// ============================================================
// CRYPTO UTILS — Key Rotation
// ============================================================
describe('Crypto Utils — Key Rotation', () => {
const cryptoUtils = require('../crypto-utils');
test('rotateKey should be exported and callable', () => {
// rotateKey writes to disk so just verify the function exists and signature
expect(typeof cryptoUtils.rotateKey).toBe('function');
});
test('decryptWithKey should decrypt with specified key', () => {
const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update('test-data', 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
const encStr = `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
const result = cryptoUtils.decryptWithKey(encStr, key);
expect(result).toBe('test-data');
});
test('decryptWithKey should reject invalid format', () => {
const key = crypto.randomBytes(32);
expect(() => cryptoUtils.decryptWithKey('invalid-no-colons', key)).toThrow('Invalid encrypted data format');
});
});
// ============================================================
// TOTP — Disable requires code verification
// ============================================================
describe('TOTP Disable Security', () => {
test('POST /api/totp/disable should reject missing code when TOTP is active', async () => {
// This tests that disabling TOTP requires a valid code
// When TOTP is not set up, the endpoint just disables it
// But when it IS set up, code is mandatory
const res = await request(app)
.post('/api/totp/disable')
.send({});
// If TOTP isn't set up in test env, it will succeed (200)
// The important thing is it doesn't crash
expect([200, 400, 401]).toContain(res.statusCode);
});
test('POST /api/totp/disable should reject non-6-digit code', async () => {
const res = await request(app)
.post('/api/totp/disable')
.send({ code: 'abc' });
// If TOTP is active, should reject non-numeric codes
expect([200, 400, 401]).toContain(res.statusCode);
});
});
// ============================================================
// SITES — Caddy reload error leak prevention
// ============================================================
describe('Sites Route Security', () => {
test('POST /api/site should reject invalid domain format', async () => {
const res = await request(app)
.post('/api/site')
.send({ domain: '<script>alert(1)</script>', upstream: 'localhost:8080' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('DC-301');
});
test('POST /api/site should reject invalid upstream format', async () => {
const res = await request(app)
.post('/api/site')
.send({ domain: 'test.sami', upstream: 'not-valid' });
expect(res.statusCode).toBe(400);
expect(res.body.error).toContain('upstream');
});
test('POST /api/site/external should reject URLs with Caddyfile injection chars', async () => {
const res = await request(app)
.post('/api/site/external')
.send({
subdomain: 'test',
externalUrl: 'https://evil.com/path{inject}'
});
// Should be rejected — either 400 (our validation) or 500 (URL constructor throws on {})
expect([400, 500]).toContain(res.statusCode);
// Must never succeed
expect(res.statusCode).not.toBe(200);
});
test('POST /api/site/external should reject URLs with newlines', async () => {
const res = await request(app)
.post('/api/site/external')
.send({
subdomain: 'test',
externalUrl: 'https://evil.com/path\nreverse_proxy malicious:1234'
});
expect(res.statusCode).toBe(400);
});
test('POST /api/site/external should reject missing fields', async () => {
const res = await request(app)
.post('/api/site/external')
.send({});
expect(res.statusCode).toBe(400);
});
test('POST /api/site/external should reject invalid subdomain', async () => {
const res = await request(app)
.post('/api/site/external')
.send({
subdomain: '../etc/passwd',
externalUrl: 'https://example.com'
});
expect(res.statusCode).toBe(400);
});
});
// ============================================================
// ERROR LOGS — No stack trace leak
// ============================================================
describe('Error Logs — No Stack Trace Leak', () => {
beforeAll(async () => {
// Write a fake error log with stack traces
const logContent = [
'[2026-03-07 12:00:00] server: Something failed',
'Error: Internal failure',
' at Object.<anonymous> (/app/server.js:123:45)',
' at Module._compile (node:internal/modules/cjs/loader:1234:14)',
'================================================================================',
'[2026-03-07 12:01:00] dns: DNS timeout',
'Error: connect ECONNREFUSED 192.168.1.1:5380',
' at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1234:16)',
'================================================================================'
].join('\n');
// Write to the server's error log file location
// The server uses ctx.ERROR_LOG_FILE — we need to check what that resolves to
await fsp.writeFile(testErrorLogFile, logContent);
});
test('GET /api/error-logs should not include details/stack traces', async () => {
const res = await request(app).get('/api/error-logs');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
// If there are logs, verify none contain 'details' field
if (res.body.logs.length > 0) {
for (const log of res.body.logs) {
expect(log).not.toHaveProperty('details');
// Verify it has the safe fields
if (log.timestamp) {
expect(log).toHaveProperty('timestamp');
expect(log).toHaveProperty('context');
expect(log).toHaveProperty('error');
}
}
}
});
});
// ============================================================
// CONTAINERS — ID validation
// ============================================================
describe('Container ID Validation', () => {
test('GET /api/containers/:id/check-update should 404 for nonexistent container', async () => {
const res = await request(app).get('/api/containers/nonexistent123/check-update');
// Should return 404 (not found) not 500 (unhandled error)
expect([404]).toContain(res.statusCode);
});
test('POST /api/containers/:id/update should 404 for nonexistent container', async () => {
const res = await request(app).post('/api/containers/nonexistent123/update');
expect([404]).toContain(res.statusCode);
});
test('GET /api/logs/container/:id should 404 for nonexistent container', async () => {
const res = await request(app).get('/api/logs/container/nonexistent123');
expect([404]).toContain(res.statusCode);
});
test('GET /api/logs/stream/:id should 404 for nonexistent container', async () => {
const res = await request(app).get('/api/logs/stream/nonexistent123');
expect([404]).toContain(res.statusCode);
});
});
// ============================================================
// LOG FILE — Path traversal prevention
// ============================================================
describe('Log File Path Traversal', () => {
test('GET /api/logs/file should reject missing path', async () => {
const res = await request(app).get('/api/logs/file');
expect(res.statusCode).toBe(400);
});
test('GET /api/logs/file should reject traversal paths', async () => {
const res = await request(app)
.get('/api/logs/file')
.query({ path: '/etc/shadow' });
// Should be 403 (not allowed) or 404 (not found), never 200
expect([403, 404]).toContain(res.statusCode);
});
test('GET /api/logs/file should reject Windows system paths', async () => {
const res = await request(app)
.get('/api/logs/file')
.query({ path: 'C:\\Windows\\System32\\config\\SAM' });
expect([403, 404]).toContain(res.statusCode);
});
test('GET /api/logs/file should reject parent directory traversal', async () => {
const res = await request(app)
.get('/api/logs/file')
.query({ path: '/var/log/../../etc/passwd' });
expect([403, 404]).toContain(res.statusCode);
});
});
// ============================================================
// BACKUP — No encryption key in export, TOTP re-auth for restore
// ============================================================
describe('Backup Security', () => {
test('GET /api/backup/export should not include encryption key', async () => {
const res = await request(app).get('/api/backup/export');
if (res.statusCode === 200 && res.body.backup) {
const backup = res.body.backup;
// Verify encryptionKey is NOT in the backup files
expect(backup.files).not.toHaveProperty('encryptionKey');
// Verify TOTP backup doesn't include manualKey
if (backup.totp) {
expect(backup.totp).not.toHaveProperty('manualKey');
}
}
});
test('POST /api/backup/restore should reject invalid backup format', async () => {
const res = await request(app)
.post('/api/backup/restore')
.send({ backup: { invalid: true } });
expect(res.statusCode).toBe(400);
});
test('POST /api/backup/restore should not restore encryptionKey even if provided', async () => {
const res = await request(app)
.post('/api/backup/restore')
.send({
backup: {
version: '1.0',
files: {
encryptionKey: {
type: 'text',
content: 'malicious-key-data'
}
}
}
});
// The encryptionKey should be skipped (not in fileMapping)
if (res.statusCode === 200) {
// If it succeeded, verify encryptionKey was skipped
expect(res.body.results.restored).not.toContain('encryptionKey');
}
});
});
// ============================================================
// SESSION COOKIE — Secure flag
// ============================================================
describe('Session Cookie Security', () => {
test('session cookies should include Secure flag', async () => {
// TOTP verify would set a session cookie on success
// We can check the middleware by looking at any response that sets cookies
const res = await request(app)
.post('/api/totp/verify')
.send({ code: '123456' });
// Even though verify fails, check cookie format if any cookies are set
const cookies = res.headers['set-cookie'];
if (cookies) {
for (const cookie of Array.isArray(cookies) ? cookies : [cookies]) {
if (cookie.includes('dashcaddy_session')) {
expect(cookie.toLowerCase()).toContain('secure');
expect(cookie.toLowerCase()).toContain('httponly');
expect(cookie.toLowerCase()).toContain('samesite');
}
}
}
});
});
// ============================================================
// CUSTOM VOLUME — Host path validation
// ============================================================
describe('Custom Volume Path Validation', () => {
// This tests the processTemplateVariables function indirectly
// The helpers.js validates custom volume hostPath against allowed roots
test('should not allow arbitrary host paths in volume overrides', async () => {
// Deploy endpoint would use processTemplateVariables
// Sending a custom volume with a dangerous path
const res = await request(app)
.post('/api/apps/deploy')
.send({
appId: 'plex',
subdomain: 'test-plex',
ip: '192.168.1.100',
port: '32400',
customVolumes: [{
containerPath: '/config',
hostPath: '/etc/shadow'
}]
});
// The deploy will likely fail for other reasons (no Docker, etc.)
// But if it reaches volume processing, the dangerous path should be rejected
// The key check: it shouldn't return 200 with /etc/shadow mounted
if (res.statusCode === 200) {
// If somehow succeeded, verify the dangerous path wasn't used
expect(JSON.stringify(res.body)).not.toContain('/etc/shadow');
}
});
});
// ============================================================
// LOGO DELETE — Path traversal prevention
// ============================================================
describe('Logo Delete Path Traversal', () => {
test('DELETE /api/logo should safely handle config with traversal paths', async () => {
// Write config with a malicious logo path
const configWithMaliciousLogo = {
customLogo: '/assets/../../etc/passwd',
customLogoDark: '/assets/../../../root/.ssh/id_rsa'
};
await fsp.writeFile(testConfigFile, JSON.stringify(configWithMaliciousLogo), 'utf8');
const res = await request(app).delete('/api/logo');
// Should succeed (reset branding) without deleting files outside assets dir
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
// Reset config for other tests
await fsp.writeFile(testConfigFile, '{}', 'utf8');
});
});
// ============================================================
// DNS — SSRF prevention (server parameter validation)
// ============================================================
describe('DNS Server SSRF Prevention', () => {
test('DELETE /api/dns/record should not succeed with arbitrary server IPs', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({
domain: 'test.sami',
type: 'A',
server: '169.254.169.254' // AWS metadata endpoint
});
// Must never succeed — 400 (server rejected), 401 (no token), or 500 (dns not configured in test)
expect(res.statusCode).not.toBe(200);
});
test('POST /api/dns/record should not succeed with arbitrary server IPs', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({
domain: 'test.sami',
ipAddress: '192.168.1.1',
server: '10.0.0.1' // Not a configured DNS server
});
expect(res.statusCode).not.toBe(200);
});
test('GET /api/dns/resolve should not succeed with arbitrary server IPs', async () => {
const res = await request(app)
.get('/api/dns/resolve')
.query({
domain: 'test.sami',
server: '127.0.0.1'
});
expect(res.statusCode).not.toBe(200);
});
test('GET /api/dns/logs should reject arbitrary server IPs', async () => {
const res = await request(app)
.get('/api/dns/logs')
.query({ server: '192.168.1.1' });
expect([400]).toContain(res.statusCode);
});
test('GET /api/dns/check-update should reject arbitrary server IPs', async () => {
const res = await request(app)
.get('/api/dns/check-update')
.query({ server: '8.8.8.8' });
expect([400]).toContain(res.statusCode);
});
test('POST /api/dns/update should reject arbitrary server IPs', async () => {
const res = await request(app)
.post('/api/dns/update')
.query({ server: '1.1.1.1' });
expect([400]).toContain(res.statusCode);
});
});
// ============================================================
// _httpFetch — Response size limit
// ============================================================
describe('HTTP Fetch Response Size Limit', () => {
// This is tested indirectly — the _httpFetch function has a 10MB limit
// We can verify the constant exists by checking the server module
test('server should define MAX_RESPONSE_SIZE constant', () => {
// Read server.js and verify the limit is defined
const serverSource = fs.readFileSync(
path.join(__dirname, '..', 'server.js'), 'utf8'
);
expect(serverSource).toContain('MAX_RESPONSE_SIZE');
expect(serverSource).toContain('10 * 1024 * 1024');
});
});
// ============================================================
// MIDDLEWARE — Session cookie format
// ============================================================
describe('Middleware Security', () => {
test('middleware should set Secure flag on cookies', () => {
const middlewareSource = fs.readFileSync(
path.join(__dirname, '..', 'middleware.js'), 'utf8'
);
// Verify the Set-Cookie string includes Secure
expect(middlewareSource).toContain('; Secure;');
});
});
// ============================================================
// SAVECONFIG — Atomic operations
// ============================================================
describe('Config Save Atomicity', () => {
test('saveConfig should use state manager for locking', () => {
const serverSource = fs.readFileSync(
path.join(__dirname, '..', 'server.js'), 'utf8'
);
// Verify saveConfig uses configStateManager.update (not raw fs.writeFile)
expect(serverSource).toContain('configStateManager.update');
});
});
// ============================================================
// SITES — External URL validation
// ============================================================
describe('External URL Security', () => {
test('sites.js should validate URL components for unsafe chars', () => {
const sitesSource = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'sites.js'), 'utf8'
);
// Verify the unsafe character regex exists
expect(sitesSource).toContain('unsafeCaddyChars');
expect(sitesSource).toMatch(/[{}\\n\\r]/);
});
});
// ============================================================
// CREDENTIAL MANAGER — Locking
// ============================================================
describe('Credential Manager File Locking', () => {
test('credential-manager should use proper-lockfile', () => {
const cmSource = fs.readFileSync(
path.join(__dirname, '..', 'credential-manager.js'), 'utf8'
);
expect(cmSource).toContain('proper-lockfile');
expect(cmSource).toContain('_lockedUpdate');
});
});
// ============================================================
// TOTP CONFIG — No plaintext secret in file
// ============================================================
describe('TOTP Config File Security', () => {
test('loadTotpConfig should delete secret from file data', () => {
const serverSource = fs.readFileSync(
path.join(__dirname, '..', 'server.js'), 'utf8'
);
// Verify the secret deletion exists in loadTotpConfig
expect(serverSource).toContain('delete loaded.secret');
});
test('totp verify-setup should not write secret to config file', () => {
const totpSource = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'auth', 'totp.js'), 'utf8'
);
// Verify totpConfig.secret assignment is NOT present
expect(totpSource).not.toContain('totpConfig.secret = pendingSecret');
expect(totpSource).not.toContain('totpConfig.secret =');
});
});
// ============================================================
// HELPERS — Volume path validation
// ============================================================
describe('Helpers — Volume Security', () => {
test('helpers.js should validate hostPath against allowed roots', () => {
const helpersSource = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'apps', 'helpers.js'), 'utf8'
);
expect(helpersSource).toContain('allowedRoots');
expect(helpersSource).toContain('platformPaths.dockerData');
expect(helpersSource).toContain('Custom volume host path rejected');
});
});
// ============================================================
// ERROR LOGS — No details field
// ============================================================
describe('Error Logs — Response Format', () => {
test('errorlogs.js should not include details field', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'errorlogs.js'), 'utf8'
);
// The parsed log object should only have timestamp, context, error
// NOT details (which contains stack traces)
const returnBlock = source.match(/return \{[\s\S]*?\}/);
if (returnBlock) {
expect(returnBlock[0]).not.toContain('details');
}
});
});
// ============================================================
// ASSETS — path.basename for logo deletion
// ============================================================
describe('Assets — Logo Path Safety', () => {
test('assets.js should use path.basename for logo filename extraction', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'assets.js'), 'utf8'
);
expect(source).toContain('path.basename(logoPath)');
// Should NOT use string replace for path extraction
expect(source).not.toContain("logoPath.replace('/assets/', '')");
});
});
// ============================================================
// BACKUP — encryptionKey excluded
// ============================================================
describe('Backup — Encryption Key Exclusion', () => {
test('backup.js should not include encryptionKey in filesToBackup', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8'
);
// Should have a comment about deliberate exclusion
expect(source).toContain('encryptionKey deliberately excluded');
// Should NOT have encryptionKey as a key in filesToBackup array
expect(source).not.toMatch(/\{\s*key:\s*'encryptionKey'/);
});
test('backup.js restore fileMapping should not include encryptionKey', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8'
);
// The RESTORE route's fileMapping (after "encryptionKey excluded" comment) must not have it
// The preview route's fileMapping is allowed to have it (informational only)
const restoreSection = source.substring(source.indexOf('encryptionKey excluded'));
const restoreMapping = restoreSection.match(/const fileMapping = \{[\s\S]*?\};/);
if (restoreMapping) {
expect(restoreMapping[0]).not.toContain('encryptionKey:');
}
});
test('backup.js should require TOTP for sensitive restores', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'config', 'backup.js'), 'utf8'
);
expect(source).toContain('sensitiveKeys');
expect(source).toContain('totpCode');
expect(source).toContain('TOTP code required');
});
});
// ============================================================
// DNS — validateDnsServer function
// ============================================================
describe('DNS — Server Validation Function', () => {
test('dns.js should define validateDnsServer', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'dns.js'), 'utf8'
);
expect(source).toContain('function validateDnsServer');
expect(source).toContain('configuredIps');
expect(source).toContain('validatorLib.isIP');
});
});
// ============================================================
// CONTAINERS — getVerifiedContainer usage
// ============================================================
describe('Containers — Verified Container Access', () => {
test('containers.js update route should use getVerifiedContainer', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'containers.js'), 'utf8'
);
// update and check-update should both use getVerifiedContainer
const updateSection = source.substring(source.indexOf("'/:id/update'"));
expect(updateSection).toContain('getVerifiedContainer');
const checkUpdateSection = source.substring(source.indexOf("'/:id/check-update'"));
expect(checkUpdateSection).toContain('getVerifiedContainer');
});
});
// ============================================================
// LOGS — Symlink resolution
// ============================================================
describe('Logs — Symlink Resolution', () => {
test('logs.js should use realpath for symlink resolution', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8'
);
expect(source).toContain('fsp.realpath');
expect(source).toContain('path.sep');
});
test('logs.js container routes should verify container exists', () => {
const source = fs.readFileSync(
path.join(__dirname, '..', 'routes', 'logs.js'), 'utf8'
);
// Both container/:id and stream/:id should have inspect + NotFoundError
expect(source).toContain('container.inspect()');
expect(source).toContain('NotFoundError');
});
});

View File

@@ -1,417 +0,0 @@
/**
* Integration tests for server.js input validation
* Tests that routes properly reject invalid input before reaching business logic
*/
const request = require('supertest');
const app = require('../server');
describe('POST /api/assets/upload - directory traversal prevention', () => {
test('rejects filename with path separators', async () => {
const res = await request(app)
.post('/api/assets/upload')
.send({ filename: '../../../etc/passwd', data: 'data:image/png;base64,iVBOR' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/path separator/i);
});
test('rejects filename with backslash', async () => {
const res = await request(app)
.post('/api/assets/upload')
.send({ filename: '..\\..\\config.json', data: 'data:image/png;base64,iVBOR' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/path separator/i);
});
test('rejects filename with dot-dot', async () => {
const res = await request(app)
.post('/api/assets/upload')
.send({ filename: '..evil.png', data: 'data:image/png;base64,iVBOR' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/path separator/i);
});
test('rejects missing fields', async () => {
const res = await request(app)
.post('/api/assets/upload')
.send({ filename: 'test.png' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
});
describe('POST /api/site - Caddyfile injection prevention', () => {
test('rejects invalid domain format', async () => {
const res = await request(app)
.post('/api/site')
.send({ domain: 'evil;rm -rf /', upstream: '127.0.0.1:8080' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid domain/i);
});
test('rejects domain with spaces', async () => {
const res = await request(app)
.post('/api/site')
.send({ domain: 'evil domain', upstream: '127.0.0.1:8080' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid domain/i);
});
test('rejects invalid upstream format', async () => {
const res = await request(app)
.post('/api/site')
.send({ domain: 'test.sami', upstream: 'not a valid upstream' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid upstream/i);
});
test('rejects missing fields', async () => {
const res = await request(app)
.post('/api/site')
.send({ domain: 'test.sami' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
});
describe('POST /api/site/external - URL and subdomain validation', () => {
test('rejects invalid subdomain', async () => {
const res = await request(app)
.post('/api/site/external')
.send({ subdomain: '-invalid', externalUrl: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid subdomain/i);
});
test('rejects subdomain with special chars', async () => {
const res = await request(app)
.post('/api/site/external')
.send({ subdomain: 'test;evil', externalUrl: 'https://example.com' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid subdomain/i);
});
test('rejects invalid URL', async () => {
const res = await request(app)
.post('/api/site/external')
.send({ subdomain: 'myapp', externalUrl: 'not-a-url' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
test('rejects missing fields', async () => {
const res = await request(app)
.post('/api/site/external')
.send({ subdomain: 'myapp' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
});
// DNS routes require a token to bypass the 401 token check and reach validation
const FAKE_TOKEN = 'aaaa1111bbbb2222cccc3333dddd4444';
describe('POST /api/dns/record - DNS injection prevention', () => {
test('rejects invalid domain format', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'evil;command', ip: '10.0.0.1', token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid domain/i);
});
test('rejects invalid IP address', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', ip: 'not-an-ip', token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid ip/i);
});
test('rejects TTL out of range (too low)', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 5, token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/ttl/i);
});
test('rejects TTL out of range (too high)', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 100000, token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/ttl/i);
});
test('rejects invalid server IP', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', ip: '10.0.0.1', server: 'not-an-ip', token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid dns server/i);
});
test('rejects missing fields', async () => {
const res = await request(app)
.post('/api/dns/record')
.send({ domain: 'test.sami', token: FAKE_TOKEN });
expect(res.status).toBe(400);
});
});
describe('DELETE /api/dns/record - DNS injection prevention', () => {
test('rejects invalid domain', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: 'evil;drop table', token: 'abc123def456' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid domain/i);
});
test('rejects invalid record type', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: 'test.sami', type: 'INVALID', token: 'abc123def456' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid dns record type/i);
});
test('rejects invalid ipAddress', async () => {
const res = await request(app)
.delete('/api/dns/record')
.query({ domain: 'test.sami', ipAddress: 'not-ip', token: 'abc123def456' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid ip/i);
});
});
describe('GET /api/dns/resolve - DNS injection prevention', () => {
test('rejects invalid domain', async () => {
const res = await request(app)
.get('/api/dns/resolve')
.query({ domain: 'evil;command', token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid domain/i);
});
test('rejects invalid server IP', async () => {
const res = await request(app)
.get('/api/dns/resolve')
.query({ domain: 'test.sami', server: 'not-an-ip', token: FAKE_TOKEN });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid dns server/i);
});
});
describe('POST /api/apps/deploy - deployment validation', () => {
test('rejects invalid subdomain', async () => {
const res = await request(app)
.post('/api/apps/deploy')
.send({ appId: 'plex', config: { subdomain: '-bad-sub' } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid subdomain/i);
});
test('rejects invalid port', async () => {
const res = await request(app)
.post('/api/apps/deploy')
.send({ appId: 'plex', config: { subdomain: 'test', port: 99999 } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid port/i);
});
test('rejects invalid IP', async () => {
const res = await request(app)
.post('/api/apps/deploy')
.send({ appId: 'plex', config: { subdomain: 'test', ip: 'not-ip' } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid ip/i);
});
test('rejects unknown template', async () => {
const res = await request(app)
.post('/api/apps/deploy')
.send({ appId: 'nonexistent-app-xyz', config: { subdomain: 'test' } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid app template/i);
});
});
describe('POST /api/dns/credentials - credential validation', () => {
test('rejects missing fields', async () => {
const res = await request(app)
.post('/api/dns/credentials')
.send({ username: 'admin' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
test('rejects username exceeding max length', async () => {
const res = await request(app)
.post('/api/dns/credentials')
.send({ username: 'a'.repeat(101), password: 'secret' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/maximum length/i);
});
test('rejects username with injection chars', async () => {
const res = await request(app)
.post('/api/dns/credentials')
.send({ username: 'admin;rm -rf /', password: 'secret' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid characters/i);
});
test('rejects username with pipe', async () => {
const res = await request(app)
.post('/api/dns/credentials')
.send({ username: 'admin|evil', password: 'secret' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid characters/i);
});
test('rejects invalid server IP', async () => {
const res = await request(app)
.post('/api/dns/credentials')
.send({ username: 'admin', password: 'secret', server: 'not-ip' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid dns server/i);
});
});
describe('POST /api/services - service config validation', () => {
test('rejects missing fields', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'test' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
test('rejects invalid service id format', async () => {
const res = await request(app)
.post('/api/services')
.send({ id: 'invalid id with spaces!', name: 'Test' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
describe('PUT /api/services - bulk import validation', () => {
test('rejects non-array body', async () => {
const res = await request(app)
.put('/api/services')
.send({ id: 'test', name: 'Test' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/array/i);
});
test('rejects service with invalid id', async () => {
const res = await request(app)
.put('/api/services')
.send([{ id: 'invalid id!', name: 'Test' }]);
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
describe('POST /api/services/update - service update validation', () => {
test('rejects missing subdomains', async () => {
const res = await request(app)
.post('/api/services/update')
.send({ oldSubdomain: 'test' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/required/i);
});
test('rejects invalid subdomain format', async () => {
const res = await request(app)
.post('/api/services/update')
.send({ oldSubdomain: '-bad', newSubdomain: 'good' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid subdomain/i);
});
test('rejects invalid port', async () => {
const res = await request(app)
.post('/api/services/update')
.send({ oldSubdomain: 'old', newSubdomain: 'new', port: 70000 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid port/i);
});
test('rejects invalid IP', async () => {
const res = await request(app)
.post('/api/services/update')
.send({ oldSubdomain: 'old', newSubdomain: 'new', ip: 'not-ip' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/invalid ip/i);
});
});
describe('POST /api/arr/test-connection - SSRF prevention', () => {
test('rejects invalid URL', async () => {
const res = await request(app)
.post('/api/arr/test-connection')
.send({ service: 'radarr', url: 'not-a-url', apiKey: 'abc123def456' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
test('rejects invalid API key format', async () => {
const res = await request(app)
.post('/api/arr/test-connection')
.send({ service: 'radarr', url: 'http://localhost:7878', apiKey: 'a;b' });
expect(res.status).toBe(400);
expect(res.body.success).toBe(false);
});
});
describe('POST /api/notifications/config - notification provider validation', () => {
test('rejects invalid Discord webhook URL', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({ providers: { discord: { webhookUrl: 'not-a-url' } } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/discord webhook/i);
});
test('rejects invalid ntfy server URL', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({ providers: { ntfy: { serverUrl: 'ftp://bad' } } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/ntfy server/i);
});
test('rejects invalid ntfy topic', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({ providers: { ntfy: { topic: 'has spaces and $pecial!' } } });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/ntfy topic/i);
});
test('accepts valid config', async () => {
const res = await request(app)
.post('/api/notifications/config')
.send({ enabled: true });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
});
});
describe('Rate limiting headers', () => {
test('returns rate limit headers on API responses', async () => {
const res = await request(app).get('/api/health');
// Health endpoint is skipped by rate limiter, but general endpoints should have headers
expect(res.status).toBe(200);
});
test('general API endpoint has rate limiting configured', async () => {
const res = await request(app).get('/api/services');
// Rate limiting is skipped in test env, so verify the endpoint is accessible
expect(res.status).toBe(200);
});
});

View File

@@ -1,104 +0,0 @@
/**
* Sites Route Tests
*
* Tests Caddyfile management, site configuration, and external site endpoints
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `sites-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `sites-config-${Date.now()}.json`);
const testCaddyfile = path.join(os.tmpdir(), `sites-Caddyfile-${Date.now()}`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.CADDYFILE_PATH = testCaddyfile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
fs.writeFileSync(testCaddyfile, '# Test Caddyfile', 'utf8');
const app = require('../server');
describe('Sites Routes', () => {
afterAll(() => {
for (const f of [testServicesFile, testConfigFile, testCaddyfile]) {
try { fs.unlinkSync(f); } catch (e) { /* ignore */ }
}
});
describe('GET /api/caddyfile', () => {
test('should return Caddyfile contents', async () => {
const res = await request(app).get('/api/caddyfile');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.content).toContain('Test Caddyfile');
});
});
describe('GET /api/apps/templates', () => {
test('should return all templates with categories', async () => {
const res = await request(app).get('/api/apps/templates');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('templates');
expect(res.body).toHaveProperty('categories');
expect(Object.keys(res.body.templates).length).toBeGreaterThan(50);
});
});
describe('GET /api/apps/templates/:appId', () => {
test('should return specific template', async () => {
const res = await request(app).get('/api/apps/templates/plex');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.template.name).toBe('Plex');
expect(res.body.template.docker).toBeDefined();
});
test('should return 404 for unknown template', async () => {
const res = await request(app).get('/api/apps/templates/nonexistent');
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/site/external', () => {
test('should reject missing required fields', async () => {
const res = await request(app)
.post('/api/site/external')
.send({});
expect(res.statusCode).toBe(400);
});
test('should reject invalid subdomain', async () => {
const res = await request(app)
.post('/api/site/external')
.send({
subdomain: 'INVALID SUBDOMAIN!',
targetUrl: 'https://example.com',
name: 'Test'
});
expect(res.statusCode).toBe(400);
});
});
describe('GET /api/caddy/cas', () => {
test('should return CA list from Caddyfile', async () => {
const res = await request(app).get('/api/caddy/cas');
expect(res.statusCode).toBe(200);
expect(res.body.status).toBe('success');
expect(Array.isArray(res.body.data.cas)).toBe(true);
});
});
});

View File

@@ -1,249 +0,0 @@
/**
* State Manager Tests
*
* Tests the thread-safe state management with file locking
*/
const StateManager = require('../state-manager');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
// Dedicated temp subdirectory avoids cross-test file collisions
const testDir = path.join(os.tmpdir(), `state-manager-test-${Date.now()}`);
const testFile = path.join(testDir, 'test-state.json');
describe('StateManager', () => {
let stateManager;
beforeAll(async () => {
await fs.mkdir(testDir, { recursive: true });
});
beforeEach(async () => {
// Clean up test file + stale lockfiles
for (const f of [testFile, `${testFile}.lock`]) {
try { await fs.unlink(f); } catch (e) { /* ignore */ }
}
stateManager = new StateManager(testFile, {
lockRetries: 20,
lockRetryInterval: 50,
lockTimeout: 15000
});
});
afterEach(async () => {
for (const f of [testFile, `${testFile}.lock`]) {
try { await fs.unlink(f); } catch (e) { /* ignore */ }
}
});
afterAll(async () => {
try { await fs.rm(testDir, { recursive: true }); } catch (e) { /* ignore */ }
});
describe('Basic Operations', () => {
test('creates file with empty array if not exists', async () => {
const data = await stateManager.read();
expect(Array.isArray(data)).toBe(true);
expect(data.length).toBe(0);
});
test('write and read roundtrip', async () => {
const testData = [
{ id: '1', name: 'Test Service 1' },
{ id: '2', name: 'Test Service 2' }
];
await stateManager.write(testData);
const data = await stateManager.read();
expect(data).toEqual(testData);
});
test('update with callback function', async () => {
await stateManager.write([{ id: '1', name: 'Service 1' }]);
const updated = await stateManager.update(items => {
items.push({ id: '2', name: 'Service 2' });
return items;
});
expect(updated.length).toBe(2);
expect(updated[1].name).toBe('Service 2');
});
});
describe('Convenience Methods', () => {
test('addItem adds to array', async () => {
await stateManager.addItem({ id: '1', name: 'Service 1' });
await stateManager.addItem({ id: '2', name: 'Service 2' });
const items = await stateManager.read();
expect(items.length).toBe(2);
});
test('removeItem removes by ID', async () => {
await stateManager.write([
{ id: '1', name: 'Service 1' },
{ id: '2', name: 'Service 2' },
{ id: '3', name: 'Service 3' }
]);
await stateManager.removeItem('2');
const items = await stateManager.read();
expect(items.length).toBe(2);
expect(items.find(i => i.id === '2')).toBeUndefined();
});
test('updateItem updates by ID', async () => {
await stateManager.write([
{ id: '1', name: 'Service 1', status: 'offline' }
]);
await stateManager.updateItem('1', { status: 'online' });
const item = await stateManager.findItem('1');
expect(item.status).toBe('online');
expect(item.name).toBe('Service 1'); // Unchanged
});
test('findItem returns null for non-existent ID', async () => {
await stateManager.write([{ id: '1', name: 'Service 1' }]);
const item = await stateManager.findItem('999');
expect(item).toBeNull();
});
});
describe('Concurrent Access', () => {
test('concurrent writes do not corrupt data', async () => {
// Start with empty array
await stateManager.write([]);
// Simulate 10 concurrent writes
const promises = [];
for (let i = 0; i < 10; i++) {
promises.push(
stateManager.update(items => {
items.push({ id: `service-${i}`, name: `Service ${i}` });
return items;
})
);
}
await Promise.all(promises);
// Verify all items were added
const items = await stateManager.read();
expect(items.length).toBe(10);
// Verify JSON is valid (not corrupted)
const fileContent = await fs.readFile(testFile, 'utf8');
expect(() => JSON.parse(fileContent)).not.toThrow();
});
test('concurrent reads while writing', async () => {
await stateManager.write([{ id: '1', name: 'Initial' }]);
const writePromise = stateManager.update(async items => {
// Simulate slow operation
await new Promise(resolve => setTimeout(resolve, 100));
items.push({ id: '2', name: 'New' });
return items;
});
const readPromises = [];
for (let i = 0; i < 5; i++) {
readPromises.push(stateManager.read());
}
await Promise.all([writePromise, ...readPromises]);
// Should complete without errors
const final = await stateManager.read();
expect(final.length).toBe(2);
});
});
describe('Error Handling', () => {
test('throws error on invalid JSON', async () => {
// Write invalid JSON directly
await fs.writeFile(testFile, '{invalid json', 'utf8');
await expect(stateManager.read()).rejects.toThrow();
});
test('handles missing file gracefully', async () => {
await fs.unlink(testFile);
const data = await stateManager.read();
expect(Array.isArray(data)).toBe(true);
});
test('update callback errors are caught', async () => {
await expect(
stateManager.update(() => {
throw new Error('Test error');
})
).rejects.toThrow('Test error');
});
});
describe('Lock Management', () => {
test('isLocked detects locked state', async () => {
const lockfile = require('proper-lockfile');
// Manually lock the file
const release = await lockfile.lock(testFile);
const locked = await stateManager.isLocked();
expect(locked).toBe(true);
await release();
const unlocked = await stateManager.isLocked();
expect(unlocked).toBe(false);
});
test('forceUnlock removes stuck lock', async () => {
const lockfile = require('proper-lockfile');
// Create a stuck lock
await lockfile.lock(testFile);
await stateManager.forceUnlock();
// Should be able to write now
await expect(stateManager.write([])).resolves.not.toThrow();
});
});
describe('Performance', () => {
test('handles large datasets efficiently', async () => {
const largeDataset = [];
for (let i = 0; i < 1000; i++) {
largeDataset.push({
id: `service-${i}`,
name: `Service ${i}`,
url: `https://service-${i}.example.com`,
status: 'online'
});
}
const startTime = Date.now();
await stateManager.write(largeDataset);
const writeTime = Date.now() - startTime;
const readStart = Date.now();
const data = await stateManager.read();
const readTime = Date.now() - readStart;
expect(data.length).toBe(1000);
expect(writeTime).toBeLessThan(1000); // Should write in <1s
expect(readTime).toBeLessThan(100); // Should read in <100ms
});
});
});

View File

@@ -1,138 +0,0 @@
/**
* Tailscale Route Tests
*
* Tests Tailscale status, configuration, and connection-checking endpoints.
* The Tailscale routes are mounted at /api/tailscale/ on the API router:
* - GET /api/tailscale/status — Tailscale status
* - POST /api/tailscale/config — Update Tailscale configuration
* - GET /api/tailscale/check-connection — Check if request comes from Tailscale IP
* - GET /api/tailscale/devices — List Tailscale devices
* - POST /api/tailscale/protect-service — Toggle Tailscale-only for a service
* - POST /api/tailscale/oauth-config — OAuth credential setup (requires live API)
* - GET /api/tailscale/api-devices — Enriched device list from API
* - POST /api/tailscale/sync — Trigger API sync
* - GET /api/tailscale/acl — Fetch ACL policy
*/
const request = require('supertest');
const fs = require('fs');
const path = require('path');
const os = require('os');
const testServicesFile = path.join(os.tmpdir(), `tailscale-services-${Date.now()}.json`);
const testConfigFile = path.join(os.tmpdir(), `tailscale-config-${Date.now()}.json`);
process.env.SERVICES_FILE = testServicesFile;
process.env.CONFIG_FILE = testConfigFile;
process.env.ENABLE_HEALTH_CHECKER = 'false';
process.env.NODE_ENV = 'test';
fs.writeFileSync(testServicesFile, '[]', 'utf8');
fs.writeFileSync(testConfigFile, '{}', 'utf8');
const app = require('../server');
describe('Tailscale Routes', () => {
afterAll(() => {
try { fs.unlinkSync(testServicesFile); } catch (e) { /* ignore */ }
try { fs.unlinkSync(testConfigFile); } catch (e) { /* ignore */ }
});
describe('GET /api/tailscale/status', () => {
test('should return 200 with status data', async () => {
const res = await request(app).get('/api/tailscale/status');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
// If Tailscale is not installed in test env, expect installed: false
if (!res.body.installed) {
expect(res.body.installed).toBe(false);
expect(res.body.connected).toBe(false);
expect(res.body.message).toBeDefined();
} else {
// If installed, expect richer data
expect(res.body).toHaveProperty('connected');
expect(res.body).toHaveProperty('self');
expect(res.body).toHaveProperty('config');
expect(res.body).toHaveProperty('devices');
expect(res.body).toHaveProperty('deviceCount');
}
});
});
describe('GET /api/tailscale/check-connection', () => {
test('should return 200 with connection info', async () => {
const res = await request(app).get('/api/tailscale/check-connection');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('isTailscale');
expect(typeof res.body.isTailscale).toBe('boolean');
expect(res.body).toHaveProperty('clientIP');
});
test('should detect non-Tailscale IP for localhost requests', async () => {
const res = await request(app).get('/api/tailscale/check-connection');
expect(res.statusCode).toBe(200);
// Supertest connects via loopback, not a 100.x.x.x address
expect(res.body.isTailscale).toBe(false);
});
});
describe('GET /api/tailscale/devices', () => {
test('should return 200 with devices array', async () => {
const res = await request(app).get('/api/tailscale/devices');
expect(res.statusCode).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body).toHaveProperty('devices');
expect(Array.isArray(res.body.devices)).toBe(true);
});
});
describe('POST /api/tailscale/oauth-config', () => {
test('should reject missing required fields', async () => {
const res = await request(app)
.post('/api/tailscale/oauth-config')
.send({});
expect(res.statusCode).toBe(400);
});
test('should reject partial credentials', async () => {
const res = await request(app)
.post('/api/tailscale/oauth-config')
.send({ clientId: 'test-id' });
expect(res.statusCode).toBe(400);
});
});
describe('GET /api/tailscale/api-devices', () => {
test('should return 400 when OAuth is not configured', async () => {
const res = await request(app).get('/api/tailscale/api-devices');
expect(res.statusCode).toBe(400);
});
});
describe('POST /api/tailscale/sync', () => {
test('should return 400 when OAuth is not configured', async () => {
const res = await request(app).post('/api/tailscale/sync');
expect(res.statusCode).toBe(400);
});
});
describe('POST /api/tailscale/protect-service', () => {
test('should reject missing subdomain', async () => {
const res = await request(app)
.post('/api/tailscale/protect-service')
.send({});
expect(res.statusCode).toBe(400);
});
});
});

View File

@@ -1,192 +0,0 @@
// update-manager.js creates a Docker instance at module level.
// On test machines without Docker, this is fine — Docker methods are only called
// in async methods that we won't invoke in unit tests.
const updateManager = require('../update-manager');
beforeEach(() => {
// Reset singleton state
updateManager.history = [];
updateManager.availableUpdates = new Map();
updateManager.config = { autoUpdate: {} };
updateManager.checking = false;
if (updateManager.checkInterval) {
clearInterval(updateManager.checkInterval);
updateManager.checkInterval = null;
}
});
afterAll(() => {
updateManager.stop();
});
describe('extractTag', () => {
test('extracts tag from "nginx:latest"', () => {
expect(updateManager.extractTag('nginx:latest')).toBe('latest');
});
test('returns "latest" when no tag specified', () => {
expect(updateManager.extractTag('nginx')).toBe('latest');
});
test('extracts tag from registry/repo:tag format', () => {
expect(updateManager.extractTag('docker.io/library/nginx:1.21')).toBe('1.21');
});
test('handles tags with dots and hyphens', () => {
expect(updateManager.extractTag('myapp:v1.2.3-rc1')).toBe('v1.2.3-rc1');
});
});
describe('parseAuthHeader', () => {
test('returns null for null header', () => {
expect(updateManager.parseAuthHeader(null)).toBeNull();
});
test('returns null for non-Bearer header', () => {
expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull();
});
test('parses Bearer realm URL', () => {
const header = 'Bearer realm="https://auth.docker.io/token"';
const result = updateManager.parseAuthHeader(header);
expect(result).toContain('https://auth.docker.io/token');
});
test('includes service parameter', () => {
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io"';
const result = updateManager.parseAuthHeader(header);
expect(result).toContain('service=registry.docker.io');
});
test('includes scope parameter', () => {
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
const result = updateManager.parseAuthHeader(header);
expect(result).toContain('scope=');
});
});
describe('getAvailableUpdates', () => {
test('returns empty array initially', () => {
expect(updateManager.getAvailableUpdates()).toEqual([]);
});
test('returns array from availableUpdates map', () => {
updateManager.availableUpdates.set('c1', { containerId: 'c1', imageName: 'nginx' });
const updates = updateManager.getAvailableUpdates();
expect(updates).toHaveLength(1);
expect(updates[0].containerId).toBe('c1');
});
});
describe('getHistory', () => {
test('returns entries in reverse order', () => {
updateManager.addToHistory({ containerId: 'c1', status: 'success' });
updateManager.addToHistory({ containerId: 'c2', status: 'success' });
const history = updateManager.getHistory();
expect(history[0].containerId).toBe('c2');
});
test('returns empty array when no history', () => {
expect(updateManager.getHistory()).toEqual([]);
});
test('respects limit parameter', () => {
for (let i = 0; i < 10; i++) {
updateManager.addToHistory({ containerId: `c${i}` });
}
expect(updateManager.getHistory(3)).toHaveLength(3);
});
});
describe('addToHistory', () => {
test('appends entry', () => {
updateManager.addToHistory({ containerId: 'c1' });
expect(updateManager.history).toHaveLength(1);
});
test('trims to 100 entries', () => {
for (let i = 0; i < 105; i++) {
updateManager.addToHistory({ containerId: `c${i}` });
}
expect(updateManager.history.length).toBeLessThanOrEqual(100);
});
});
describe('configureAutoUpdate', () => {
test('creates autoUpdate config section', () => {
updateManager.configureAutoUpdate('c1', { enabled: true });
expect(updateManager.config.autoUpdate['c1']).toBeDefined();
});
test('stores container-specific config', () => {
updateManager.configureAutoUpdate('c1', {
enabled: true,
schedule: 'daily',
securityOnly: true
});
expect(updateManager.config.autoUpdate['c1'].schedule).toBe('daily');
expect(updateManager.config.autoUpdate['c1'].securityOnly).toBe(true);
});
test('defaults autoRollback to true', () => {
updateManager.configureAutoUpdate('c1', { enabled: true });
expect(updateManager.config.autoUpdate['c1'].autoRollback).toBe(true);
});
test('defaults schedule to weekly', () => {
updateManager.configureAutoUpdate('c1', {});
expect(updateManager.config.autoUpdate['c1'].schedule).toBe('weekly');
});
});
describe('scheduleUpdate', () => {
test('throws for past scheduled time', () => {
const past = new Date(Date.now() - 60000).toISOString();
expect(() => updateManager.scheduleUpdate('c1', past)).toThrow('Scheduled time must be in the future');
});
test('accepts future scheduled time', () => {
jest.useFakeTimers();
const future = new Date(Date.now() + 60000).toISOString();
expect(() => updateManager.scheduleUpdate('c1', future)).not.toThrow();
jest.useRealTimers();
});
});
describe('getChangelog', () => {
test('returns placeholder response', async () => {
const result = await updateManager.getChangelog('nginx:latest');
expect(result.imageName).toBe('nginx:latest');
expect(result.changelog).toBeDefined();
});
});
describe('start / stop', () => {
test('start sets checking flag', () => {
jest.useFakeTimers();
updateManager.start();
expect(updateManager.checking).toBe(true);
updateManager.stop();
jest.useRealTimers();
});
test('stop clears interval', () => {
jest.useFakeTimers();
updateManager.start();
updateManager.stop();
expect(updateManager.checking).toBe(false);
expect(updateManager.checkInterval).toBeNull();
jest.useRealTimers();
});
test('start is idempotent', () => {
jest.useFakeTimers();
updateManager.start();
const first = updateManager.checkInterval;
updateManager.start();
expect(updateManager.checkInterval).toBe(first);
updateManager.stop();
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,21 @@
# Font file headers to prevent sanitizer issues
<FilesMatch "\.(woff2|woff|ttf|eot)$">
Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Methods "GET, POST, OPTIONS"
Header set Access-Control-Allow-Headers "Content-Type"
Header set Cache-Control "public, max-age=31536000"
# Proper MIME types
<IfModule mod_mime.c>
AddType font/woff2 .woff2
AddType font/woff .woff
AddType font/ttf .ttf
AddType application/vnd.ms-fontobject .eot
</IfModule>
</FilesMatch>
# Prevent direct access to font conversion scripts
<FilesMatch "\.(py|bat)$">
Order allow,deny
Deny from all
</FilesMatch>

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 972 KiB

View File

@@ -0,0 +1,321 @@
/**
* DNS Template Selector
* Presents DNS server template options when user chooses to set up DNS
*/
(function(window) {
'use strict';
class DnsTemplateSelector {
constructor(progressTracker) {
this.progressTracker = progressTracker;
this.modal = null;
this.onTemplateSelected = null;
console.log('[DnsTemplateSelector] Module loaded');
}
/**
* Get available DNS server templates from app templates
* @returns {Array} Array of DNS template objects
*/
getDnsTemplates() {
// In a real implementation, this would fetch from app-templates.js
// For now, return hardcoded templates matching what we added
return [
{
id: 'technitium',
name: 'Technitium DNS Server',
description: 'Modern DNS server with web UI for managing private zones',
icon: '🌐',
difficulty: 'Easy',
features: [
'Web-based management interface',
'Private zone management for .sami domain',
'DHCP server integration',
'DNS-over-HTTPS and DNS-over-TLS support'
],
recommended: true
},
{
id: 'bind9',
name: 'BIND9 DNS Server',
description: 'Industry-standard DNS server - powerful and flexible',
icon: '🔧',
difficulty: 'Advanced',
features: [
'Industry standard DNS server',
'Full RFC compliance',
'Advanced zone management',
'DNSSEC support'
],
recommended: false
},
{
id: 'pihole',
name: 'Pi-hole',
description: 'Network-wide ad blocker with DNS capabilities',
icon: '🛡️',
difficulty: 'Intermediate',
features: [
'Ad blocking at DNS level',
'Web interface for management',
'DHCP server included',
'Query logging and statistics'
],
recommended: false
},
{
id: 'powerdns',
name: 'PowerDNS',
description: 'High-performance DNS server with SQL backend',
icon: '⚡',
difficulty: 'Intermediate',
features: [
'SQL database backend',
'RESTful API for automation',
'Geographic load balancing',
'DNSSEC support'
],
recommended: false
},
{
id: 'coredns',
name: 'CoreDNS',
description: 'Cloud-native DNS server - lightweight and flexible',
icon: '☁️',
difficulty: 'Intermediate',
features: [
'Plugin-based architecture',
'Kubernetes-native',
'Lightweight and fast',
'Prometheus metrics'
],
recommended: false
}
];
}
/**
* Show DNS template selection modal
*/
showTemplateSelector() {
// Create modal if it doesn't exist
if (!this.modal) {
this.createModal();
}
// Populate with templates
this.populateTemplates();
// Show modal
this.modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
/**
* Create the modal HTML structure
* @private
*/
createModal() {
const modal = document.createElement('div');
modal.id = 'dns-template-modal';
modal.className = 'dns-template-modal';
modal.innerHTML = `
<div class="dns-template-modal-content">
<div class="dns-template-header">
<h2>🌐 Choose a DNS Server</h2>
<p>Setting up a DNS server is essential for managing your private .sami domain</p>
<button class="dns-template-close" aria-label="Close">&times;</button>
</div>
<div class="dns-template-grid" id="dns-template-grid">
<!-- Templates will be inserted here -->
</div>
<div class="dns-template-footer">
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
</div>
</div>
`;
document.body.appendChild(modal);
this.modal = modal;
// Add event listeners
modal.querySelector('.dns-template-close').addEventListener('click', () => this.close());
modal.querySelector('#dns-setup-later').addEventListener('click', () => this.handleSetupLater());
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.close();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display === 'flex') {
this.close();
}
});
}
/**
* Populate modal with DNS templates
* @private
*/
populateTemplates() {
const grid = document.getElementById('dns-template-grid');
if (!grid) return;
const templates = this.getDnsTemplates();
grid.innerHTML = '';
templates.forEach(template => {
const card = this.createTemplateCard(template);
grid.appendChild(card);
});
}
/**
* Create a template card element
* @private
*/
createTemplateCard(template) {
const card = document.createElement('div');
card.className = 'dns-template-card';
if (template.recommended) {
card.classList.add('recommended');
}
const difficultyClass = template.difficulty.toLowerCase();
card.innerHTML = `
${template.recommended ? '<div class="recommended-badge">Recommended</div>' : ''}
<div class="dns-template-icon">${template.icon}</div>
<h3>${template.name}</h3>
<p class="dns-template-description">${template.description}</p>
<div class="dns-template-difficulty difficulty-${difficultyClass}">
${template.difficulty}
</div>
<ul class="dns-template-features">
${template.features.slice(0, 3).map(f => `<li>${f}</li>`).join('')}
</ul>
<button class="dns-template-select-btn" data-template-id="${template.id}">
Select ${template.name}
</button>
`;
// Add click handler to select button
const selectBtn = card.querySelector('.dns-template-select-btn');
selectBtn.addEventListener('click', () => this.handleTemplateSelection(template));
return card;
}
/**
* Handle template selection
* @private
*/
handleTemplateSelection(template) {
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`);
// Close modal
this.close();
// Trigger callback if set
if (this.onTemplateSelected) {
this.onTemplateSelected(template);
} else {
// Default behavior: open app selector with DNS filter
this.openAppSelector(template.id);
}
}
/**
* Handle "Set up later" button
* @private
*/
handleSetupLater() {
console.log('[DnsTemplateSelector] DNS setup deferred');
// Mark as deferred in progress tracker
if (this.progressTracker) {
this.progressTracker.markDnsSetupDeferred();
}
// Close modal
this.close();
// Show notification
this.showNotification('DNS setup deferred. You can set it up later from the App Selector.');
}
/**
* Open app selector with specific template
* @private
*/
openAppSelector(templateId) {
// Try to open the app selector modal if it exists
const appSelectorBtn = document.querySelector('[onclick*="showAppSelector"]');
if (appSelectorBtn) {
appSelectorBtn.click();
// Wait a bit then filter to the selected template
setTimeout(() => {
const searchInput = document.querySelector('#app-search');
if (searchInput) {
searchInput.value = templateId;
searchInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}, 300);
} else {
// Fallback: show instructions
this.showNotification(`To deploy ${templateId}, use the App Selector and search for "${templateId}"`);
}
}
/**
* Show notification message
* @private
*/
showNotification(message) {
// Simple notification - could be enhanced
const notification = document.createElement('div');
notification.className = 'dns-template-notification';
notification.textContent = message;
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: var(--card-base);
color: var(--fg);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.3s';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
/**
* Close the modal
*/
close() {
if (this.modal) {
this.modal.style.display = 'none';
document.body.style.overflow = '';
}
}
}
window.DnsTemplateSelector = DnsTemplateSelector;
console.log('[DnsTemplateSelector] Module loaded');
})(window);

1
dashcaddy-api/assets/driver.min.css vendored Normal file
View File

@@ -0,0 +1 @@
.driver-active .driver-overlay,.driver-active *{pointer-events:none}.driver-active .driver-active-element,.driver-active .driver-active-element *,.driver-popover,.driver-popover *{pointer-events:auto}@keyframes animate-fade-in{0%{opacity:0}to{opacity:1}}.driver-fade .driver-overlay{animation:animate-fade-in .2s ease-in-out}.driver-fade .driver-popover{animation:animate-fade-in .2s}.driver-popover{all:unset;box-sizing:border-box;color:#2d2d2d;margin:0;padding:15px;border-radius:5px;min-width:250px;max-width:300px;box-shadow:0 1px 10px #0006;z-index:1000000000;position:fixed;top:0;right:0;background-color:#fff}.driver-popover *{font-family:Helvetica Neue,Inter,ui-sans-serif,"Apple Color Emoji",Helvetica,Arial,sans-serif}.driver-popover-title{font:19px/normal sans-serif;font-weight:700;display:block;position:relative;line-height:1.5;zoom:1;margin:0}.driver-popover-close-btn{all:unset;position:absolute;top:0;right:0;width:32px;height:28px;cursor:pointer;font-size:18px;font-weight:500;color:#d2d2d2;z-index:1;text-align:center;transition:color;transition-duration:.2s}.driver-popover-close-btn:hover,.driver-popover-close-btn:focus{color:#2d2d2d}.driver-popover-title[style*=block]+.driver-popover-description{margin-top:5px}.driver-popover-description{margin-bottom:0;font:14px/normal sans-serif;line-height:1.5;font-weight:400;zoom:1}.driver-popover-footer{margin-top:15px;text-align:right;zoom:1;display:flex;align-items:center;justify-content:space-between}.driver-popover-progress-text{font-size:13px;font-weight:400;color:#727272;zoom:1}.driver-popover-footer button{all:unset;display:inline-block;box-sizing:border-box;padding:3px 7px;text-decoration:none;text-shadow:1px 1px 0 #fff;background-color:#fff;color:#2d2d2d;font:12px/normal sans-serif;cursor:pointer;outline:0;zoom:1;line-height:1.3;border:1px solid #ccc;border-radius:3px}.driver-popover-footer .driver-popover-btn-disabled{opacity:.5;pointer-events:none}:not(body):has(>.driver-active-element){overflow:hidden!important}.driver-no-interaction,.driver-no-interaction *{pointer-events:none!important}.driver-popover-footer button:hover,.driver-popover-footer button:focus{background-color:#f7f7f7}.driver-popover-navigation-btns{display:flex;flex-grow:1;justify-content:flex-end}.driver-popover-navigation-btns button+button{margin-left:4px}.driver-popover-arrow{content:"";position:absolute;border:5px solid #fff}.driver-popover-arrow-side-over{display:none}.driver-popover-arrow-side-left{left:100%;border-right-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-right{right:100%;border-left-color:transparent;border-bottom-color:transparent;border-top-color:transparent}.driver-popover-arrow-side-top{top:100%;border-right-color:transparent;border-bottom-color:transparent;border-left-color:transparent}.driver-popover-arrow-side-bottom{bottom:100%;border-left-color:transparent;border-top-color:transparent;border-right-color:transparent}.driver-popover-arrow-side-center{display:none}.driver-popover-arrow-side-left.driver-popover-arrow-align-start,.driver-popover-arrow-side-right.driver-popover-arrow-align-start{top:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-start,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-start{left:15px}.driver-popover-arrow-align-end.driver-popover-arrow-side-left,.driver-popover-arrow-align-end.driver-popover-arrow-side-right{bottom:15px}.driver-popover-arrow-side-top.driver-popover-arrow-align-end,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-end{right:15px}.driver-popover-arrow-side-left.driver-popover-arrow-align-center,.driver-popover-arrow-side-right.driver-popover-arrow-align-center{top:50%;margin-top:-5px}.driver-popover-arrow-side-top.driver-popover-arrow-align-center,.driver-popover-arrow-side-bottom.driver-popover-arrow-align-center{left:50%;margin-left:-5px}.driver-popover-arrow-none{display:none}

2
dashcaddy-api/assets/driver.min.js vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

@@ -0,0 +1,259 @@
/**
* Error Handler
* Handles errors gracefully without breaking the onboarding tour
*/
(function(window) {
'use strict';
class ErrorHandler {
constructor() {
this.errors = [];
this.maxErrors = 50; // Keep last 50 errors
}
/**
* Log an error without breaking the tour
* @param {string} context - Context where error occurred
* @param {Error|string} error - The error object or message
* @param {Object} metadata - Additional metadata
*/
logError(context, error, metadata = {}) {
const errorEntry = {
timestamp: new Date().toISOString(),
context,
message: error instanceof Error ? error.message : error,
stack: error instanceof Error ? error.stack : null,
metadata
};
// Add to errors array
this.errors.push(errorEntry);
// Keep only last maxErrors
if (this.errors.length > this.maxErrors) {
this.errors.shift();
}
// Log to console
console.error(`[Onboarding Error] ${context}:`, error, metadata);
// Optionally send to error tracking service
// this.sendToErrorTracking(errorEntry);
}
/**
* Attempt to recover from an error and continue tour
* @param {Error} error - The error object
* @param {number} currentStep - Current step index
* @returns {Object} Recovery action
*/
recoverFromError(error, currentStep) {
const errorType = this.classifyError(error);
switch (errorType) {
case 'ELEMENT_NOT_FOUND':
this.logError('Element Not Found', error, { currentStep });
return {
action: 'SKIP_STEP',
nextStep: currentStep + 1,
message: 'Target element not found, skipping to next step'
};
case 'STORAGE_UNAVAILABLE':
this.logError('Storage Unavailable', error);
return {
action: 'USE_MEMORY_STORAGE',
message: 'Local storage unavailable, using in-memory storage'
};
case 'DRIVER_NOT_LOADED':
this.logError('Driver.js Not Loaded', error);
return {
action: 'ABORT_TOUR',
message: 'Driver.js library not loaded, cannot start tour'
};
case 'INVALID_TOOLTIP':
this.logError('Invalid Tooltip Configuration', error, { currentStep });
return {
action: 'SKIP_STEP',
nextStep: currentStep + 1,
message: 'Invalid tooltip configuration, skipping'
};
case 'THEME_DETECTION_FAILED':
this.logError('Theme Detection Failed', error);
return {
action: 'USE_DEFAULT_THEME',
message: 'Using default dark theme'
};
default:
this.logError('Unknown Error', error, { currentStep });
return {
action: 'ABORT_TOUR',
message: 'Unexpected error occurred, aborting tour'
};
}
}
/**
* Classify error type
* @private
* @param {Error} error - The error object
* @returns {string} Error type
*/
classifyError(error) {
const message = error.message || error.toString();
if (message.includes('element') && message.includes('not found')) {
return 'ELEMENT_NOT_FOUND';
}
if (message.includes('storage') || message.includes('quota')) {
return 'STORAGE_UNAVAILABLE';
}
if (message.includes('driver') || message.includes('undefined')) {
return 'DRIVER_NOT_LOADED';
}
if (message.includes('invalid') || message.includes('validation')) {
return 'INVALID_TOOLTIP';
}
if (message.includes('theme')) {
return 'THEME_DETECTION_FAILED';
}
return 'UNKNOWN';
}
/**
* Get all logged errors
* @returns {Array} Array of error entries
*/
getErrors() {
return [...this.errors];
}
/**
* Clear all logged errors
*/
clearErrors() {
this.errors = [];
}
/**
* Get error statistics
* @returns {Object} Error statistics
*/
getStatistics() {
const stats = {
total: this.errors.length,
byContext: {},
byType: {},
recent: this.errors.slice(-10)
};
this.errors.forEach(error => {
// Count by context
stats.byContext[error.context] = (stats.byContext[error.context] || 0) + 1;
// Count by type
const type = this.classifyError({ message: error.message });
stats.byType[type] = (stats.byType[type] || 0) + 1;
});
return stats;
}
/**
* Handle graceful degradation when Driver.js fails to load
* @returns {boolean} Whether fallback was successful
*/
handleDriverLoadFailure() {
this.logError('Driver.js Load Failure', 'Driver.js library failed to load');
// Show fallback message
const fallbackMessage = document.createElement('div');
fallbackMessage.id = 'onboarding-fallback';
fallbackMessage.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--card-base, #2a2a2a);
color: var(--fg, #ffffff);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 9999;
max-width: 300px;
font-size: 14px;
`;
fallbackMessage.innerHTML = `
<strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started.
</p>
`;
document.body.appendChild(fallbackMessage);
// Auto-remove after 10 seconds
setTimeout(() => {
if (fallbackMessage.parentNode) {
fallbackMessage.parentNode.removeChild(fallbackMessage);
}
}, 10000);
return true;
}
/**
* Handle storage unavailable scenario
* @returns {Object} In-memory storage fallback
*/
handleStorageUnavailable() {
this.logError('Storage Unavailable', 'Local storage is not available');
// Create in-memory storage
const memoryStorage = {
data: {},
getItem(key) {
return this.data[key] || null;
},
setItem(key, value) {
this.data[key] = value;
},
removeItem(key) {
delete this.data[key];
},
clear() {
this.data = {};
}
};
console.warn('[ErrorHandler] Using in-memory storage - progress will not persist');
return memoryStorage;
}
/**
* Send error to tracking service (placeholder)
* @private
* @param {Object} errorEntry - Error entry to send
*/
sendToErrorTracking(errorEntry) {
// Placeholder for error tracking integration
// Could integrate with Sentry, LogRocket, etc.
// Example:
// if (window.Sentry) {
// Sentry.captureException(new Error(errorEntry.message), {
// extra: errorEntry.metadata
// });
// }
}
}
window.ErrorHandler = ErrorHandler;
console.log('[ErrorHandler] Module loaded');
})(window);

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#0e1116"/><path d="M16 38h20a8 8 0 1 0-1.8-15.7A9.5 9.5 0 0 0 12 30c0 4.4 3.6 8 8 8z" fill="#8FD6FF"/></svg>

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,91 @@
/* Sami Sans Font Family - External CSS */
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Regular.woff2') format('woff2'),
url('fonts/SamiSans-Italic.ttf') format('truetype');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Medium.woff2') format('woff2'),
url('fonts/SamiSans-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-SemiBold.woff2') format('woff2'),
url('fonts/SamiSans-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Bold.woff2') format('woff2'),
url('fonts/SamiSans-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraBold.woff2') format('woff2'),
url('fonts/SamiSans-ExtraBold.ttf') format('truetype');
font-weight: 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Black.woff2') format('woff2'),
url('fonts/SamiSans-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Light.woff2') format('woff2'),
url('fonts/SamiSans-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-ExtraLight.woff2') format('woff2'),
url('fonts/SamiSans-ExtraLight.ttf') format('truetype');
font-weight: 200;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Sami Sans';
src: url('fonts/SamiSans-Thin.woff2') format('woff2'),
url('fonts/SamiSans-Thin.ttf') format('truetype');
font-weight: 100;
font-style: normal;
font-display: swap;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More