Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
423
dashcaddy-api/__tests__/api-endpoints.test.js
Normal file
423
dashcaddy-api/__tests__/api-endpoints.test.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user