Files
dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js
Sami ea5acfa9a2 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>
2026-04-06 21:36:46 -07:00

538 lines
19 KiB
JavaScript

// Container Routes Tests
// Validates container lifecycle operations (start/stop/restart/update/delete/discover)
const express = require('express');
const request = require('supertest');
// Build a test app with the containers route
function buildApp(mockDeps) {
const app = express();
app.use(express.json());
const { errorMiddleware } = require('../../error-handler');
const containersRouteFactory = require('../../routes/containers');
app.use('/api/containers', containersRouteFactory(mockDeps));
app.use(errorMiddleware);
return app;
}
// Mock container factory
function mockContainer(overrides = {}) {
return {
inspect: jest.fn().mockResolvedValue({
Id: 'abc123def456',
Name: '/plex',
Config: {
Image: 'lscr.io/linuxserver/plex:latest',
Env: ['TZ=America/New_York', 'PLEX_CLAIM='],
ExposedPorts: { '32400/tcp': {} },
Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' }
},
Image: 'sha256:abc123',
HostConfig: {
Binds: ['E:/dockerdata/plex:/config'],
PortBindings: { '32400/tcp': [{ HostPort: '32400' }] },
RestartPolicy: { Name: 'unless-stopped' },
NetworkMode: 'bridge',
ExtraHosts: [],
Privileged: false,
CapAdd: null,
CapDrop: null,
Devices: [],
LogConfig: { Type: 'json-file', Config: { 'max-size': '10m', 'max-file': '3' } },
Memory: 2147483648, // 2GB
MemoryReservation: 1073741824, // 1GB
NanoCpus: 2000000000, // 2 cores
},
NetworkSettings: { Networks: { bridge: {} } }
}),
start: jest.fn().mockResolvedValue(),
stop: jest.fn().mockResolvedValue(),
restart: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue(),
update: jest.fn().mockResolvedValue(),
logs: jest.fn().mockResolvedValue(Buffer.from('2026-04-05T10:00:00Z Plex server started')),
...overrides
};
}
function createMockDeps(containerInstance) {
const container = containerInstance || mockContainer();
return {
docker: {
client: {
getContainer: jest.fn().mockReturnValue(container),
createContainer: jest.fn().mockResolvedValue({
start: jest.fn().mockResolvedValue(),
inspect: jest.fn().mockResolvedValue({ Id: 'new123' }),
remove: jest.fn().mockResolvedValue(),
}),
getImage: jest.fn().mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:olddigest'] })
}),
listContainers: jest.fn().mockResolvedValue([]),
pruneImages: jest.fn().mockResolvedValue({ SpaceReclaimed: 0 }),
},
pull: jest.fn().mockResolvedValue([]),
},
log: {
info: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
asyncHandler: (fn, name) => async (req, res, next) => {
try { await fn(req, res, next); } catch (err) { next(err); }
},
};
}
describe('Container Routes — DashCaddy container lifecycle', () => {
describe('POST /:id/start', () => {
it('starts a stopped container', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/start');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.message).toContain('started');
});
it('returns 404 for missing container', async () => {
const container = mockContainer();
const notFound = new Error('no such container');
notFound.statusCode = 404;
container.inspect.mockRejectedValue(notFound);
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/missing123/start');
expect(res.status).toBe(404);
});
});
describe('POST /:id/stop', () => {
it('stops a running container', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/stop');
expect(res.status).toBe(200);
expect(res.body.message).toContain('stopped');
});
});
describe('POST /:id/restart', () => {
it('restarts a container', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/restart');
expect(res.status).toBe(200);
expect(res.body.message).toContain('restarted');
});
});
describe('GET /:id/logs', () => {
it('returns last 100 log lines', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/logs');
expect(res.status).toBe(200);
expect(res.body.logs).toContain('Plex server started');
});
});
describe('PUT /:id/resources', () => {
it('updates memory and CPU limits', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app)
.put('/api/containers/abc123/resources')
.send({ memory: 4096, cpus: 4 });
expect(res.status).toBe(200);
expect(container.update).toHaveBeenCalledWith(
expect.objectContaining({
Memory: 4096 * 1024 * 1024,
NanoCpus: 4 * 1e9,
})
);
});
it('sets 0 for unlimited', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app)
.put('/api/containers/abc123/resources')
.send({ memory: 0, cpus: 0 });
expect(res.status).toBe(200);
expect(container.update).toHaveBeenCalledWith(
expect.objectContaining({
Memory: 0,
NanoCpus: 0,
})
);
});
});
describe('GET /:id/resources', () => {
it('returns current resource limits in human units', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/resources');
expect(res.status).toBe(200);
expect(res.body.memory).toBe(2048); // 2GB in MB
expect(res.body.memoryReservation).toBe(1024); // 1GB in MB
expect(res.body.cpus).toBe(2); // 2 cores
});
});
describe('DELETE /:id', () => {
it('force-removes a container', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app).delete('/api/containers/abc123');
expect(res.status).toBe(200);
expect(container.remove).toHaveBeenCalledWith({ force: true });
});
});
describe('GET /discover', () => {
it('returns only sami.managed containers', async () => {
const deps = createMockDeps();
deps.docker.client.listContainers.mockResolvedValue([
{
Id: 'abc123', Names: ['/plex'], Image: 'linuxserver/plex',
State: 'running', Status: 'Up 3 days',
Labels: { 'sami.managed': 'true', 'sami.app': 'plex', 'sami.subdomain': 'plex' },
Ports: [{ PrivatePort: 32400, PublicPort: 32400 }]
},
{
Id: 'xyz789', Names: ['/random-container'], Image: 'nginx',
State: 'running', Status: 'Up 1 hour',
Labels: {},
Ports: [{ PrivatePort: 80, PublicPort: 80 }]
}
]);
const app = buildApp(deps);
const res = await request(app).get('/api/containers/discover');
expect(res.status).toBe(200);
expect(res.body.containers).toHaveLength(1);
expect(res.body.containers[0].appTemplate).toBe('plex');
});
it('returns empty array when no managed containers', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).get('/api/containers/discover');
expect(res.body.containers).toEqual([]);
});
});
describe('POST /:id/update — error and edge cases', () => {
it('preserves custom network mode (non-bridge/host/none)', async () => {
const container = mockContainer();
container.inspect.mockResolvedValue({
Id: 'abc123', Name: '/plex',
Config: { Image: 'plex:latest', Env: [], ExposedPorts: {}, Labels: {} },
Image: 'sha256:abc',
HostConfig: {
Binds: [], PortBindings: {}, RestartPolicy: { Name: 'unless-stopped' },
NetworkMode: 'my-custom-network',
ExtraHosts: [], Privileged: false, CapAdd: null, CapDrop: null, Devices: []
},
NetworkSettings: { Networks: { 'my-custom-network': { IPAddress: '172.20.0.5' } } }
});
const newContainer = {
start: jest.fn().mockResolvedValue(),
inspect: jest.fn().mockResolvedValue({ Id: 'new123' })
};
const deps = createMockDeps(container);
deps.docker.client.createContainer.mockResolvedValue(newContainer);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBe(200);
const createCall = deps.docker.client.createContainer.mock.calls[0][0];
expect(createCall.NetworkingConfig.EndpointsConfig['my-custom-network'])
.toEqual({ IPAddress: '172.20.0.5' });
});
it('cleans up failed new container when start fails', async () => {
const container = mockContainer();
const newContainer = {
start: jest.fn().mockRejectedValue(new Error('port already allocated')),
remove: jest.fn().mockResolvedValue()
};
const deps = createMockDeps(container);
deps.docker.client.createContainer.mockResolvedValue(newContainer);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBeGreaterThanOrEqual(500);
expect(newContainer.remove).toHaveBeenCalledWith({ force: true });
});
it('handles new container remove cleanup failure gracefully', async () => {
const container = mockContainer();
const newContainer = {
start: jest.fn().mockRejectedValue(new Error('start failed')),
remove: jest.fn().mockRejectedValue(new Error('already gone'))
};
const deps = createMockDeps(container);
deps.docker.client.createContainer.mockResolvedValue(newContainer);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBeGreaterThanOrEqual(500);
});
it('logs space reclaimed when image prune frees disk', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
deps.docker.client.pruneImages.mockResolvedValue({ SpaceReclaimed: 50 * 1024 * 1024 }); // 50MB
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBe(200);
expect(deps.log.info).toHaveBeenCalledWith(
'docker',
'Pruned dangling images after update',
expect.objectContaining({ spaceReclaimed: '50MB' })
);
});
it('continues if image prune fails', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
deps.docker.client.pruneImages.mockRejectedValue(new Error('prune failed'));
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBe(200);
expect(deps.log.debug).toHaveBeenCalledWith(
'docker',
'Image prune after update failed',
expect.any(Object)
);
});
it('ignores already-stopped error when stopping container', async () => {
const container = mockContainer();
container.stop.mockRejectedValue(new Error('container already stopped'));
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBe(200);
});
});
describe('GET /:id/check-update', () => {
it('reports no updates when local and new digests match', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
deps.docker.client.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: ['sha256:samedigest'] })
});
deps.docker.pull.mockResolvedValue([]);
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/check-update');
expect(res.status).toBe(200);
expect(res.body.updateAvailable).toBe(false);
});
it('reports update available when downloads occur', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
deps.docker.pull.mockResolvedValue([
{ status: 'Downloading', id: 'layer1' },
{ status: 'Download complete', id: 'layer2' }
]);
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/check-update');
expect(res.body.updateAvailable).toBe(true);
});
it('reports update available when digests differ', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
let callCount = 0;
deps.docker.client.getImage.mockImplementation(() => {
callCount++;
return {
inspect: jest.fn().mockResolvedValue({
RepoDigests: callCount === 1
? ['sha256:olddigest']
: ['sha256:newdigest']
})
};
});
deps.docker.pull.mockResolvedValue([]);
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/check-update');
expect(res.body.updateAvailable).toBe(true);
});
it('returns false when pull throws (registry unreachable)', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
deps.docker.pull.mockRejectedValue(new Error('registry timeout'));
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/check-update');
expect(res.status).toBe(200);
expect(res.body.updateAvailable).toBe(false);
});
it('handles missing local repo digests gracefully', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
deps.docker.client.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: null })
});
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/check-update');
expect(res.status).toBe(200);
expect(res.body.currentDigest).toBeNull();
});
});
describe('getVerifiedContainer error paths', () => {
it('returns 404 when error message includes "no such container"', async () => {
const container = mockContainer();
container.inspect.mockRejectedValue(new Error('Error: no such container: missing'));
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/missing/start');
expect(res.status).toBe(404);
});
it('rethrows non-404 errors from inspect', async () => {
const container = mockContainer();
container.inspect.mockRejectedValue(new Error('docker daemon not running'));
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/start');
expect(res.status).toBeGreaterThanOrEqual(500);
});
});
describe('PUT /:id/resources — partial updates', () => {
it('updates only memory when cpus omitted', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app)
.put('/api/containers/abc123/resources')
.send({ memory: 2048 });
expect(res.status).toBe(200);
const call = container.update.mock.calls[0][0];
expect(call.Memory).toBe(2048 * 1024 * 1024);
expect(call.NanoCpus).toBeUndefined();
});
it('updates only cpus when memory omitted', async () => {
const container = mockContainer();
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app)
.put('/api/containers/abc123/resources')
.send({ cpus: 1.5 });
expect(res.status).toBe(200);
const call = container.update.mock.calls[0][0];
expect(call.NanoCpus).toBe(1.5 * 1e9);
expect(call.Memory).toBeUndefined();
});
});
describe('GET /:id/resources — zero values', () => {
it('returns 0 when no limits set', async () => {
const container = mockContainer();
container.inspect.mockResolvedValue({
Id: 'abc', Name: '/test', Config: { Image: 'test:latest' },
HostConfig: { Memory: 0, MemoryReservation: 0, NanoCpus: 0 }
});
const deps = createMockDeps(container);
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/resources');
expect(res.body.memory).toBe(0);
expect(res.body.memoryReservation).toBe(0);
expect(res.body.cpus).toBe(0);
});
});
describe('GET /discover — pagination', () => {
it('paginates results when paginate query params provided', async () => {
const containers = Array.from({ length: 25 }, (_, i) => ({
Id: `id${i}`,
Names: [`/svc${i}`],
Image: 'test:latest',
State: 'running',
Status: 'Up',
Labels: { 'sami.managed': 'true', 'sami.app': 'test', 'sami.subdomain': `svc${i}` },
Ports: []
}));
const deps = createMockDeps();
deps.docker.client.listContainers.mockResolvedValue(containers);
const app = buildApp(deps);
const res = await request(app).get('/api/containers/discover?page=1&limit=10');
expect(res.status).toBe(200);
expect(res.body.containers.length).toBeLessThanOrEqual(10);
});
});
describe('DashCaddy-specific scenarios', () => {
it('Plex container: verifies correct resource read (2GB, 2 cores)', async () => {
const deps = createMockDeps();
const app = buildApp(deps);
const res = await request(app).get('/api/containers/abc123/resources');
expect(res.body.memory).toBe(2048);
expect(res.body.cpus).toBe(2);
});
it('container update: preserves Env, PortBindings, RestartPolicy', async () => {
const container = mockContainer();
const newContainer = {
start: jest.fn().mockResolvedValue(),
inspect: jest.fn().mockResolvedValue({ Id: 'new456' }),
remove: jest.fn().mockResolvedValue(),
};
const deps = createMockDeps(container);
deps.docker.client.createContainer.mockResolvedValue(newContainer);
const app = buildApp(deps);
const res = await request(app).post('/api/containers/abc123/update');
expect(res.status).toBe(200);
const createCall = deps.docker.client.createContainer.mock.calls[0][0];
expect(createCall.Env).toContain('TZ=America/New_York');
expect(createCall.HostConfig.PortBindings['32400/tcp']).toEqual([{ HostPort: '32400' }]);
expect(createCall.HostConfig.RestartPolicy).toEqual({ Name: 'unless-stopped' });
});
});
});