Files
dashcaddy/dashcaddy-api/__tests__/edge-cases.test.js

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);
});
});
});