test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core infrastructure, API routes, and error handling. Final coverage: 86.73% statements / 80.57% branches / 85.57% functions / 87.42% lines, all above the 80% threshold enforced by jest.config.js. Highlights: - Unit tests for crypto-utils, credential-manager, auth-manager, csrf, input-validator, state-manager, health-checker, backup-manager, update-manager, resource-monitor, app-templates, platform-paths, port-lock-manager, errors, error-handler, pagination, url-resolver - Route tests for health, services, and containers (supertest + mocked deps) - Shared test-utils helper for mock factories and Express app builder - npm scripts for CI: test:ci, test:unit, test:routes, test:security, test:changed, test:debug - jest.config.js: expand coverage targets, add 80% threshold gate - routes/services.js: import ValidationError and NotFoundError from errors - .gitignore: exclude coverage/, *.bak, *.log Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
272
dashcaddy-api/__tests__/port-lock-manager.test.js
Normal file
272
dashcaddy-api/__tests__/port-lock-manager.test.js
Normal file
@@ -0,0 +1,272 @@
|
||||
// Port Lock Manager Tests
|
||||
// Validates atomic port allocation for concurrent Docker deployments
|
||||
|
||||
jest.mock('proper-lockfile');
|
||||
jest.mock('fs');
|
||||
|
||||
const fs = require('fs');
|
||||
const lockfile = require('proper-lockfile');
|
||||
|
||||
// Setup defaults BEFORE requiring singleton (constructor calls ensureLockDirectory)
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.readdirSync.mockReturnValue([]);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
|
||||
const portLockManager = require('../port-lock-manager');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
portLockManager.activeLocks.clear();
|
||||
|
||||
// Restore defaults
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.readdirSync.mockReturnValue([]);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
describe('PortLockManager — concurrent deploy safety', () => {
|
||||
|
||||
describe('acquirePorts', () => {
|
||||
it('rejects empty array', async () => {
|
||||
await expect(portLockManager.acquirePorts([])).rejects.toThrow('non-empty array');
|
||||
});
|
||||
|
||||
it('rejects non-array', async () => {
|
||||
await expect(portLockManager.acquirePorts('8080')).rejects.toThrow('non-empty array');
|
||||
});
|
||||
|
||||
it('acquires lock for a single port', async () => {
|
||||
const mockRelease = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(mockRelease);
|
||||
|
||||
const lockId = await portLockManager.acquirePorts(['8080']);
|
||||
expect(lockId).toMatch(/^lock-/);
|
||||
expect(lockfile.lock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('acquires locks for multiple ports in sorted order (deadlock prevention)', async () => {
|
||||
const callOrder = [];
|
||||
lockfile.lock.mockImplementation((filePath) => {
|
||||
callOrder.push(filePath);
|
||||
return Promise.resolve(jest.fn().mockResolvedValue());
|
||||
});
|
||||
|
||||
await portLockManager.acquirePorts(['9090', '3001', '8080']);
|
||||
|
||||
// Ports sorted numerically: 3001, 8080, 9090
|
||||
expect(callOrder[0]).toContain('port-3001.lock');
|
||||
expect(callOrder[1]).toContain('port-8080.lock');
|
||||
expect(callOrder[2]).toContain('port-9090.lock');
|
||||
});
|
||||
|
||||
it('deduplicates ports', async () => {
|
||||
await portLockManager.acquirePorts(['8080', '8080', '8080']);
|
||||
expect(lockfile.lock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates lock file for new ports', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
await portLockManager.acquirePorts(['7878']);
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('port-7878.lock'),
|
||||
expect.stringContaining('"port"')
|
||||
);
|
||||
});
|
||||
|
||||
it('stores lock in activeLocks map', async () => {
|
||||
const lockId = await portLockManager.acquirePorts(['8080']);
|
||||
const status = portLockManager.getStatus();
|
||||
expect(status.activeLocks).toBe(1);
|
||||
expect(status.locks[0].lockId).toBe(lockId);
|
||||
expect(status.locks[0].ports).toEqual(['8080']);
|
||||
});
|
||||
|
||||
it('rolls back on partial failure — releases acquired locks', async () => {
|
||||
const released = [];
|
||||
let callCount = 0;
|
||||
lockfile.lock.mockImplementation(() => {
|
||||
callCount++;
|
||||
if (callCount === 2) return Promise.reject(new Error('Port in use'));
|
||||
const release = jest.fn().mockImplementation(() => {
|
||||
released.push(callCount);
|
||||
return Promise.resolve();
|
||||
});
|
||||
return Promise.resolve(release);
|
||||
});
|
||||
|
||||
await expect(portLockManager.acquirePorts(['3001', '8080']))
|
||||
.rejects.toThrow('Failed to acquire port locks');
|
||||
|
||||
// First lock should have been released during rollback
|
||||
expect(released.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releasePorts', () => {
|
||||
it('releases all locks for a lock ID', async () => {
|
||||
const mockRelease = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValue(mockRelease);
|
||||
|
||||
const lockId = await portLockManager.acquirePorts(['8080', '9090']);
|
||||
await portLockManager.releasePorts(lockId);
|
||||
|
||||
expect(mockRelease).toHaveBeenCalledTimes(2);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(0);
|
||||
});
|
||||
|
||||
it('handles already-released lock ID gracefully', async () => {
|
||||
// Should not throw
|
||||
await portLockManager.releasePorts('nonexistent-lock-id');
|
||||
});
|
||||
|
||||
it('continues releasing remaining locks if one fails', async () => {
|
||||
const releases = [
|
||||
jest.fn().mockRejectedValue(new Error('release error')),
|
||||
jest.fn().mockResolvedValue(),
|
||||
];
|
||||
let callIdx = 0;
|
||||
lockfile.lock.mockImplementation(() => {
|
||||
return Promise.resolve(releases[callIdx++]);
|
||||
});
|
||||
|
||||
const lockId = await portLockManager.acquirePorts(['3001', '8080']);
|
||||
await portLockManager.releasePorts(lockId);
|
||||
|
||||
// Both should have been called despite first failure
|
||||
expect(releases[0]).toHaveBeenCalled();
|
||||
expect(releases[1]).toHaveBeenCalled();
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPortLocked', () => {
|
||||
it('returns false when lock file does not exist', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when port is actively locked', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
lockfile.check.mockResolvedValue(true);
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when port lock is stale', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false on check error (fail-open for deployments)', async () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
lockfile.check.mockRejectedValue(new Error('check error'));
|
||||
const result = await portLockManager.isPortLocked('8080');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatus', () => {
|
||||
it('returns empty state when no locks active', () => {
|
||||
const status = portLockManager.getStatus();
|
||||
expect(status.activeLocks).toBe(0);
|
||||
expect(status.locks).toEqual([]);
|
||||
expect(status.lockDirectory).toContain('.port-locks');
|
||||
});
|
||||
|
||||
it('includes age and timestamp for active locks', async () => {
|
||||
await portLockManager.acquirePorts(['8080']);
|
||||
const status = portLockManager.getStatus();
|
||||
expect(status.activeLocks).toBe(1);
|
||||
expect(status.locks[0].age).toBeGreaterThanOrEqual(0);
|
||||
expect(status.locks[0].timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupStaleLocks', () => {
|
||||
it('removes stale lock files (not actively locked)', async () => {
|
||||
fs.readdirSync.mockReturnValue(['port-8080.lock', 'port-9090.lock']);
|
||||
lockfile.check.mockResolvedValue(false); // not locked = stale
|
||||
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('skips actively locked files', async () => {
|
||||
fs.readdirSync.mockReturnValue(['port-8080.lock']);
|
||||
lockfile.check.mockResolvedValue(true); // actively locked
|
||||
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-.lock files', async () => {
|
||||
fs.readdirSync.mockReturnValue(['readme.txt', 'port-8080.lock']);
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
|
||||
expect(fs.unlinkSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles ENOENT errors gracefully', async () => {
|
||||
fs.readdirSync.mockReturnValue(['port-8080.lock']);
|
||||
const enoent = new Error('ENOENT');
|
||||
enoent.code = 'ENOENT';
|
||||
lockfile.check.mockRejectedValue(enoent);
|
||||
|
||||
// Should not throw
|
||||
await portLockManager.cleanupStaleLocks();
|
||||
expect(fs.unlinkSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashCaddy deployment scenarios', () => {
|
||||
it('Radarr deploy: locks host port 7878', async () => {
|
||||
await portLockManager.acquirePorts(['7878']);
|
||||
expect(lockfile.lock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('port-7878.lock'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('Plex deploy: locks multiple ports (32400, 1900, 8324, 32469)', async () => {
|
||||
const plexPorts = ['32400', '1900', '8324', '32469'];
|
||||
await portLockManager.acquirePorts(plexPorts);
|
||||
expect(lockfile.lock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it('concurrent deploys: second deploy gets separate lock ID', async () => {
|
||||
const release1 = jest.fn().mockResolvedValue();
|
||||
const release2 = jest.fn().mockResolvedValue();
|
||||
lockfile.lock.mockResolvedValueOnce(release1).mockResolvedValueOnce(release2);
|
||||
|
||||
const lockId1 = await portLockManager.acquirePorts(['8080']);
|
||||
const lockId2 = await portLockManager.acquirePorts(['9090']);
|
||||
|
||||
expect(lockId1).not.toBe(lockId2);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(2);
|
||||
});
|
||||
|
||||
it('deploy cleanup: release after container start', async () => {
|
||||
const lockId = await portLockManager.acquirePorts(['7878']);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(1);
|
||||
|
||||
// Simulate container started successfully
|
||||
await portLockManager.releasePorts(lockId);
|
||||
expect(portLockManager.getStatus().activeLocks).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user