Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
605 lines
19 KiB
JavaScript
605 lines
19 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|