Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).
Conflict resolutions:
- src/utils/logging.js: took ours (consumers depend on logError/
safeErrorMessage/createLogger exports)
- src/config/site.js: merged (her factored validateAndLogConfig +
applyConfigFields helpers)
- src/context/dns.js: took hers (admin/readonly role iteration for
write operations)
- src/utilities/backup-
manager.js: took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
sw.js: took hers (minified bundles + newer SW cache)
Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
'require(./platform-paths)' → 'require(../../platform-paths)'
Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../src/docker/app-templates');
|
||||
|
||||
describe('App Templates', () => {
|
||||
const templates = Object.values(APP_TEMPLATES);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Must mock crypto-utils BEFORE auth-manager is required,
|
||||
// because auth-manager.js line 13: const JWT_SECRET = cryptoUtils.loadOrCreateKey()
|
||||
const mockFixedKey = Buffer.alloc(32, 'jwt-test-key-pad');
|
||||
jest.mock('../crypto-utils', () => ({
|
||||
jest.mock('../src/security/crypto-utils', () => ({
|
||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||
}));
|
||||
|
||||
jest.mock('../credential-manager', () => ({
|
||||
jest.mock('../src/managers/credential-manager', () => ({
|
||||
store: jest.fn().mockResolvedValue(true),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
@@ -13,8 +13,8 @@ jest.mock('../credential-manager', () => ({
|
||||
}));
|
||||
|
||||
const crypto = require('crypto');
|
||||
const authManager = require('../auth-manager');
|
||||
const credentialManager = require('../credential-manager');
|
||||
const authManager = require('../src/managers/auth-manager');
|
||||
const credentialManager = require('../src/managers/credential-manager');
|
||||
|
||||
describe('AuthManager', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Smoke tests for auto-restart-manager.js
|
||||
* Verifies the AutoRestartManager class:
|
||||
* - Policy CRUD (set/get/list/remove)
|
||||
* - handleContainerDown: cooldown, max-retries, restart attempt, failure
|
||||
* - handleContainerUp: retry counter reset
|
||||
* - _handleStatusCheck: healthy→unhealthy and unhealthy→healthy transitions
|
||||
* - _resolveContainerId: lookup precedence
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const { AutoRestartManager, DEFAULT_POLICY } = require('../src/managers/auto-restart-manager');
|
||||
|
||||
jest.mock('../src/utilities/fs-helpers', () => ({
|
||||
readJsonFile: jest.fn().mockResolvedValue({}),
|
||||
writeJsonFile: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const fsHelpers = require('../src/utilities/fs-helpers');
|
||||
|
||||
function makeManager(overrides = {}) {
|
||||
const servicesStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
...(overrides.servicesStateManager || {}),
|
||||
};
|
||||
|
||||
const docker = {
|
||||
client: {
|
||||
getContainer: jest.fn(),
|
||||
...(overrides.dockerClient || {}),
|
||||
},
|
||||
};
|
||||
|
||||
const healthChecker = new EventEmitter();
|
||||
if (overrides.healthChecker) {
|
||||
Object.assign(healthChecker, overrides.healthChecker);
|
||||
}
|
||||
|
||||
const notification = {
|
||||
send: jest.fn().mockResolvedValue({ success: true }),
|
||||
...(overrides.notification || {}),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
docker,
|
||||
healthChecker,
|
||||
notification,
|
||||
servicesStateManager,
|
||||
SERVICES_FILE: '/tmp/dc-test/services.json',
|
||||
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn(), debug: jest.fn() },
|
||||
logError: jest.fn(),
|
||||
};
|
||||
|
||||
const manager = new AutoRestartManager(ctx);
|
||||
return { manager, ctx, docker, healthChecker, notification, servicesStateManager };
|
||||
}
|
||||
|
||||
describe('AutoRestartManager', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fsHelpers.readJsonFile.mockResolvedValue({});
|
||||
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('constants & construction', () => {
|
||||
test('DEFAULT_POLICY has the documented fields and sensible defaults', () => {
|
||||
expect(DEFAULT_POLICY).toEqual({
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
retryIntervalMs: 5000,
|
||||
windowMinutes: 10,
|
||||
currentRetries: 0,
|
||||
lastRestartAt: null,
|
||||
cooldownUntil: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('manager extends EventEmitter and stores ctx deps', () => {
|
||||
const { manager, ctx } = makeManager();
|
||||
expect(manager).toBeInstanceOf(EventEmitter);
|
||||
expect(manager.docker).toBe(ctx.docker);
|
||||
expect(manager.healthChecker).toBe(ctx.healthChecker);
|
||||
expect(manager.notification).toBe(ctx.notification);
|
||||
expect(manager.policies).toBeInstanceOf(Map);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
test('start() loads persisted policies from fs-helpers', async () => {
|
||||
fsHelpers.readJsonFile.mockResolvedValue({
|
||||
'svc-1': { enabled: false, maxRetries: 7 },
|
||||
});
|
||||
const { manager } = makeManager();
|
||||
await manager.start();
|
||||
expect(manager.policies.has('svc-1')).toBe(true);
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.maxRetries).toBe(7);
|
||||
expect(policy.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('start() is idempotent (second call does nothing new)', async () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
await manager.start();
|
||||
const listenerCount = healthChecker.listenerCount('status-check');
|
||||
await manager.start();
|
||||
expect(healthChecker.listenerCount('status-check')).toBe(listenerCount);
|
||||
});
|
||||
|
||||
test('stop() removes the status-check listener', async () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
await manager.start();
|
||||
expect(healthChecker.listenerCount('status-check')).toBe(1);
|
||||
manager.stop();
|
||||
expect(healthChecker.listenerCount('status-check')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('policy CRUD', () => {
|
||||
test('setPolicy throws on missing serviceId', async () => {
|
||||
const { manager } = makeManager();
|
||||
await expect(manager.setPolicy('', { enabled: true })).rejects.toThrow(/serviceId/);
|
||||
await expect(manager.setPolicy(null, {})).rejects.toThrow(/serviceId/);
|
||||
});
|
||||
|
||||
test('setPolicy merges fields with existing policy', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 5 });
|
||||
await manager.setPolicy('svc-1', { enabled: false });
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.maxRetries).toBe(5); // preserved from earlier
|
||||
expect(policy.enabled).toBe(false); // updated by second call
|
||||
});
|
||||
|
||||
test('setPolicy persists via fs-helpers.writeJsonFile', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 4 });
|
||||
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
|
||||
const [filePath, payload] = fsHelpers.writeJsonFile.mock.calls[0];
|
||||
expect(filePath).toMatch(/auto-restart-policies\.json$/);
|
||||
expect(payload['svc-1'].maxRetries).toBe(4);
|
||||
});
|
||||
|
||||
test('getPolicy returns a copy, not the internal reference', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 2 });
|
||||
const a = manager.getPolicy('svc-1');
|
||||
a.maxRetries = 999;
|
||||
const b = manager.getPolicy('svc-1');
|
||||
expect(b.maxRetries).toBe(2);
|
||||
});
|
||||
|
||||
test('getPolicy returns null for unknown service', () => {
|
||||
const { manager } = makeManager();
|
||||
expect(manager.getPolicy('does-not-exist')).toBeNull();
|
||||
});
|
||||
|
||||
test('listPolicies returns array of all policies', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 1 });
|
||||
await manager.setPolicy('svc-2', { maxRetries: 2 });
|
||||
const list = manager.listPolicies();
|
||||
expect(Array.isArray(list)).toBe(true);
|
||||
expect(list).toHaveLength(2);
|
||||
const ids = list.map(p => p.serviceId);
|
||||
expect(ids).toEqual(expect.arrayContaining(['svc-1', 'svc-2']));
|
||||
});
|
||||
|
||||
test('removePolicy returns true and deletes the policy', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 1 });
|
||||
expect(await manager.removePolicy('svc-1')).toBe(true);
|
||||
expect(manager.getPolicy('svc-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('removePolicy returns false for unknown service', async () => {
|
||||
const { manager } = makeManager();
|
||||
expect(await manager.removePolicy('does-not-exist')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleContainerDown', () => {
|
||||
test('returns ignored/no-policy when no policy exists', async () => {
|
||||
const { manager } = makeManager();
|
||||
const result = await manager.handleContainerDown('unknown', 'cid');
|
||||
expect(result.action).toBe('ignored');
|
||||
expect(result.reason).toBe('no-policy');
|
||||
});
|
||||
|
||||
test('returns ignored/disabled when policy.enabled is false', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { enabled: false });
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||
expect(result.action).toBe('ignored');
|
||||
expect(result.reason).toBe('disabled');
|
||||
});
|
||||
|
||||
test('returns skipped/cooldown when cooldownUntil is in the future', async () => {
|
||||
const { manager } = makeManager();
|
||||
// setPolicy() intentionally guards runtime fields; we have to set
|
||||
// cooldownUntil via the internal map to simulate an in-progress cooldown
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3 });
|
||||
manager.policies.get('svc-1').cooldownUntil = Date.now() + 60_000;
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||
expect(result.action).toBe('skipped');
|
||||
expect(result.reason).toBe('cooldown');
|
||||
});
|
||||
|
||||
test('increments currentRetries and calls docker.start on a successful restart', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
|
||||
const onAttempt = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
manager.on('auto-restart-attempt', onAttempt);
|
||||
manager.on('auto-restart-success', onSuccess);
|
||||
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
|
||||
expect(result.action).toBe('restarted');
|
||||
expect(result.attempt).toBe(1);
|
||||
expect(result.serviceId).toBe('svc-1');
|
||||
expect(docker.client.getContainer).toHaveBeenCalledWith('cid-abc');
|
||||
expect(onAttempt).toHaveBeenCalledTimes(1);
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
|
||||
});
|
||||
|
||||
test('emits auto-restart-failed and increments currentRetries when docker.start throws', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockRejectedValue(new Error('docker daemon down')),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
|
||||
const onFailed = jest.fn();
|
||||
manager.on('auto-restart-failed', onFailed);
|
||||
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid-abc');
|
||||
expect(result.action).toBe('failed');
|
||||
expect(result.error).toMatch(/docker daemon down/);
|
||||
expect(onFailed).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getPolicy('svc-1').currentRetries).toBe(1);
|
||||
});
|
||||
|
||||
test('emits auto-restart-max-reached and sets cooldown when maxRetries exceeded', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 2, retryIntervalMs: 0 });
|
||||
|
||||
const onMax = jest.fn();
|
||||
manager.on('auto-restart-max-reached', onMax);
|
||||
|
||||
// First attempt: currentRetries=0 -> succeeds, increments to 1
|
||||
await manager.handleContainerDown('svc-1', 'cid');
|
||||
// Second: 1 -> succeeds, increments to 2
|
||||
await manager.handleContainerDown('svc-1', 'cid');
|
||||
// Third: 2 >= maxRetries(2) -> max-reached, currentRetries reset to 0
|
||||
const result = await manager.handleContainerDown('svc-1', 'cid');
|
||||
|
||||
expect(result.action).toBe('max-reached');
|
||||
expect(onMax).toHaveBeenCalledTimes(1);
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.currentRetries).toBe(0);
|
||||
expect(policy.cooldownUntil).toBeGreaterThan(Date.now());
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleContainerUp', () => {
|
||||
test('resets currentRetries and cooldownUntil when service is tracked', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { currentRetries: 2, cooldownUntil: Date.now() + 10000 });
|
||||
// Mutate via internal map (bypassing the setter guard)
|
||||
manager.policies.get('svc-1').currentRetries = 2;
|
||||
manager.policies.get('svc-1').cooldownUntil = Date.now() + 10000;
|
||||
|
||||
await manager.handleContainerUp('svc-1');
|
||||
const policy = manager.getPolicy('svc-1');
|
||||
expect(policy.currentRetries).toBe(0);
|
||||
expect(policy.cooldownUntil).toBeNull();
|
||||
});
|
||||
|
||||
test('is a no-op when service is not tracked', async () => {
|
||||
const { manager } = makeManager();
|
||||
await expect(manager.handleContainerUp('unknown')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_handleStatusCheck', () => {
|
||||
test('triggers handleContainerDown on healthy→unhealthy transition', async () => {
|
||||
const { manager, docker } = makeManager();
|
||||
docker.client.getContainer.mockReturnValue({
|
||||
start: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
// Pre-set previous health
|
||||
manager._previousHealth.set('svc-1', 'up');
|
||||
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
await manager._handleStatusCheck({
|
||||
serviceId: 'svc-1',
|
||||
status: 'down',
|
||||
details: { containerId: 'cid-1' },
|
||||
});
|
||||
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-1');
|
||||
});
|
||||
|
||||
test('triggers handleContainerUp on unhealthy→healthy transition', async () => {
|
||||
const { manager } = makeManager();
|
||||
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||
manager._previousHealth.set('svc-1', 'down');
|
||||
|
||||
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
|
||||
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'up' });
|
||||
expect(handleUpSpy).toHaveBeenCalledWith('svc-1');
|
||||
});
|
||||
|
||||
test('does nothing for services without a policy', async () => {
|
||||
const { manager } = makeManager();
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
const handleUpSpy = jest.spyOn(manager, 'handleContainerUp');
|
||||
await manager._handleStatusCheck({ serviceId: 'untracked', status: 'down' });
|
||||
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||
expect(handleUpSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores status with no serviceId', async () => {
|
||||
const { manager } = makeManager();
|
||||
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||
await manager._handleStatusCheck({ status: 'down' });
|
||||
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_resolveContainerId', () => {
|
||||
test('returns containerId from status.details when present', () => {
|
||||
const { manager } = makeManager();
|
||||
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||
expect(cid).toBe('cid-details');
|
||||
});
|
||||
|
||||
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
|
||||
const { manager, healthChecker } = makeManager();
|
||||
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBe('cid-hc');
|
||||
});
|
||||
|
||||
test('falls back to servicesStateManager.read when sync list is returned', () => {
|
||||
const { manager, servicesStateManager } = makeManager();
|
||||
servicesStateManager.read.mockReturnValue([
|
||||
{ id: 'svc-1', containerId: 'cid-state' },
|
||||
]);
|
||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
||||
expect(cid).toBe('cid-state');
|
||||
});
|
||||
|
||||
test('returns null when no source has a containerId', () => {
|
||||
const { manager } = makeManager();
|
||||
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
|
||||
expect(cid).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,19 +3,19 @@
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('child_process');
|
||||
jest.mock('../credential-manager', () => ({
|
||||
jest.mock('../src/managers/credential-manager', () => ({
|
||||
exportBackup: jest.fn().mockReturnValue({ encrypted: 'cred-data' }),
|
||||
importBackup: jest.fn()
|
||||
}));
|
||||
jest.mock('../resource-monitor', () => ({
|
||||
jest.mock('../src/managers/resource-monitor', () => ({
|
||||
exportStats: jest.fn().mockReturnValue({ stats: [{ cpu: 10 }] }),
|
||||
importStats: jest.fn()
|
||||
}));
|
||||
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const credentialManager = require('../credential-manager');
|
||||
const resourceMonitor = require('../resource-monitor');
|
||||
const credentialManager = require('../src/managers/credential-manager');
|
||||
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||
|
||||
// Setup defaults BEFORE requiring singleton (constructor calls loadConfig/loadHistory)
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
@@ -24,7 +24,7 @@ fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
fs.unlinkSync.mockReturnValue(undefined);
|
||||
|
||||
const backupManager = require('../backup-manager');
|
||||
const backupManager = require('../src/utilities/backup-manager');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Smoke tests for config-drift-detector.js
|
||||
* Verifies the ConfigDriftDetector class detects drift across all categories,
|
||||
* exposes polling control, extracts container ports, and dispatches
|
||||
* drift notifications.
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const { ConfigDriftDetector } = require('../src/managers/config-drift-detector');
|
||||
|
||||
function makeContainer(overrides = {}) {
|
||||
return {
|
||||
Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
Names: ['/dashcaddy-test'],
|
||||
Image: 'nginx:latest',
|
||||
State: 'running',
|
||||
Status: 'Up 5 minutes',
|
||||
Ports: [],
|
||||
Labels: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDetector(overrides = {}) {
|
||||
const servicesStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockImplementation(async (updater) => {
|
||||
const data = await servicesStateManager.read();
|
||||
const list = Array.isArray(data) ? data : (data?.services || []);
|
||||
const next = updater(list);
|
||||
return next;
|
||||
}),
|
||||
...(overrides.servicesStateManager || {}),
|
||||
};
|
||||
|
||||
const docker = {
|
||||
client: {
|
||||
listContainers: jest.fn().mockResolvedValue([]),
|
||||
...(overrides.dockerClient || {}),
|
||||
},
|
||||
};
|
||||
|
||||
const notification = {
|
||||
send: jest.fn().mockResolvedValue({ success: true }),
|
||||
...(overrides.notification || {}),
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
docker,
|
||||
servicesStateManager,
|
||||
notification,
|
||||
log: {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
logError: jest.fn(),
|
||||
};
|
||||
|
||||
const detector = new ConfigDriftDetector(ctx);
|
||||
return { detector, ctx, docker, servicesStateManager, notification };
|
||||
}
|
||||
|
||||
describe('ConfigDriftDetector', () => {
|
||||
describe('constructor', () => {
|
||||
test('extends EventEmitter and stores ctx dependencies', () => {
|
||||
const { detector, ctx } = makeDetector();
|
||||
expect(detector).toBeInstanceOf(EventEmitter);
|
||||
expect(detector.ctx).toBe(ctx);
|
||||
expect(detector.docker).toBe(ctx.docker);
|
||||
expect(detector.servicesStateManager).toBe(ctx.servicesStateManager);
|
||||
expect(detector.notification).toBe(ctx.notification);
|
||||
expect(detector.lastReport).toBeNull();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
test('returns a clean report when services and containers are empty', async () => {
|
||||
const { detector } = makeDetector();
|
||||
const report = await detector.detect();
|
||||
expect(report).toHaveProperty('checkedAt');
|
||||
expect(report.missingContainers).toEqual([]);
|
||||
expect(report.unknownContainers).toEqual([]);
|
||||
expect(report.portMismatch).toEqual([]);
|
||||
expect(report.stateMismatch).toEqual([]);
|
||||
expect(report.staleRecords).toEqual([]);
|
||||
expect(report.hasDrift).toBe(false);
|
||||
});
|
||||
|
||||
test('flags missing containers when service containerId is not in Docker', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef0000000000000000',
|
||||
}];
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue([]);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.staleRecords).toHaveLength(1);
|
||||
expect(report.staleRecords[0].serviceId).toBe('svc-1');
|
||||
expect(report.hasDrift).toBe(true);
|
||||
});
|
||||
|
||||
test('flags port mismatches between service config and container', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
port: 8080,
|
||||
containerId: 'abcdef012345',
|
||||
}];
|
||||
const containers = [makeContainer({
|
||||
Id: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789',
|
||||
Ports: [{ PublicPort: 9090, PrivatePort: 80, Type: 'tcp' }],
|
||||
})];
|
||||
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.portMismatch).toHaveLength(1);
|
||||
expect(report.portMismatch[0].configuredPort).toBe(8080);
|
||||
expect(report.portMismatch[0].actualPorts).toEqual([9090]);
|
||||
});
|
||||
|
||||
test('flags state mismatch when service is not running', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
containerId: 'abcdef012345',
|
||||
}];
|
||||
const containers = [makeContainer({ State: 'exited', Status: 'Exited (1) 5 minutes ago' })];
|
||||
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.missingContainers).toHaveLength(1);
|
||||
expect(report.stateMismatch).toHaveLength(1);
|
||||
expect(report.stateMismatch[0].actualState).toBe('exited');
|
||||
});
|
||||
|
||||
test('flags unknown managed containers not in services.json', async () => {
|
||||
const containers = [makeContainer({
|
||||
Labels: { 'sami.managed': 'true', 'sami.app': 'whoami' },
|
||||
})];
|
||||
|
||||
const { detector, docker, servicesStateManager } = makeDetector();
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
servicesStateManager.read.mockResolvedValue([]);
|
||||
|
||||
const report = await detector.detect();
|
||||
expect(report.unknownContainers).toHaveLength(1);
|
||||
expect(report.unknownContainers[0].name).toBe('dashcaddy-test');
|
||||
expect(report.unknownContainers[0].app).toBe('whoami');
|
||||
});
|
||||
|
||||
test('emits drift-detected and sends notification when drift exists', async () => {
|
||||
const services = [{
|
||||
id: 'svc-1',
|
||||
name: 'svc-1',
|
||||
containerId: 'missingcontainer00',
|
||||
}];
|
||||
const { detector, servicesStateManager, docker, notification } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
docker.client.listContainers.mockResolvedValue([]);
|
||||
|
||||
const onDrift = jest.fn();
|
||||
detector.on('drift-detected', onDrift);
|
||||
await detector.detect();
|
||||
|
||||
expect(onDrift).toHaveBeenCalledTimes(1);
|
||||
expect(notification.send).toHaveBeenCalledTimes(1);
|
||||
expect(notification.send.mock.calls[0][0]).toBe('drift-detected');
|
||||
const payload = notification.send.mock.calls[0][1];
|
||||
expect(payload.text).toMatch(/drift/i);
|
||||
expect(payload.report).toBeDefined();
|
||||
});
|
||||
|
||||
test('caches the report on the instance', async () => {
|
||||
const { detector } = makeDetector();
|
||||
const report = await detector.detect();
|
||||
expect(detector.lastReport).toBe(report);
|
||||
});
|
||||
|
||||
test('handles services as a wrapper object with .services field', async () => {
|
||||
const { detector, servicesStateManager } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue({ services: [] });
|
||||
const report = await detector.detect();
|
||||
expect(report).toBeDefined();
|
||||
expect(report.hasDrift).toBe(false);
|
||||
});
|
||||
|
||||
test('tolerates Docker listContainers failure (logs and continues)', async () => {
|
||||
const { detector, docker, ctx } = makeDetector();
|
||||
docker.client.listContainers.mockRejectedValue(new Error('docker daemon down'));
|
||||
const report = await detector.detect();
|
||||
expect(report).toBeDefined();
|
||||
expect(report.hasDrift).toBe(false);
|
||||
expect(ctx.log.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('autoFix()', () => {
|
||||
test('removes stale records via servicesStateManager.update', async () => {
|
||||
const services = [
|
||||
{ id: 'svc-good', name: 'svc-good', containerId: 'liveid0000000000000000000000000000' },
|
||||
{ id: 'svc-stale', name: 'svc-stale', containerId: 'deadbeef00000000deadbeef0000000000000000deadbeef00000000' },
|
||||
];
|
||||
const containers = [makeContainer({
|
||||
Id: 'liveid0000000000000000000000000000000000000000000000000000000000',
|
||||
})];
|
||||
|
||||
const { detector, servicesStateManager, docker } = makeDetector();
|
||||
servicesStateManager.read.mockResolvedValue(services);
|
||||
servicesStateManager.update.mockImplementation(async (updater) => {
|
||||
const next = updater(services);
|
||||
return next;
|
||||
});
|
||||
docker.client.listContainers.mockResolvedValue(containers);
|
||||
|
||||
const result = await detector.autoFix();
|
||||
expect(result.staleRemoved).toBe(1);
|
||||
expect(result.unknownFlagged).toBe(0);
|
||||
expect(servicesStateManager.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('polling', () => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('startPolling/stopPolling toggles isPolling', () => {
|
||||
const { detector } = makeDetector();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
detector.startPolling(60000);
|
||||
expect(detector.isPolling()).toBe(true);
|
||||
detector.stopPolling();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
});
|
||||
|
||||
test('startPolling clears any existing timer before starting a new one', () => {
|
||||
const { detector } = makeDetector();
|
||||
detector.startPolling(60000);
|
||||
const firstTimer = detector._pollTimer;
|
||||
detector.startPolling(120000);
|
||||
expect(detector._pollTimer).not.toBe(firstTimer);
|
||||
detector.stopPolling();
|
||||
});
|
||||
|
||||
test('stopPolling is a safe no-op when not started', () => {
|
||||
const { detector } = makeDetector();
|
||||
expect(() => detector.stopPolling()).not.toThrow();
|
||||
expect(detector.isPolling()).toBe(false);
|
||||
});
|
||||
|
||||
test('runs detect on the polling interval', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { detector } = makeDetector();
|
||||
const detectSpy = jest.spyOn(detector, 'detect').mockResolvedValue({
|
||||
checkedAt: new Date().toISOString(),
|
||||
missingContainers: [],
|
||||
unknownContainers: [],
|
||||
portMismatch: [],
|
||||
stateMismatch: [],
|
||||
staleRecords: [],
|
||||
hasDrift: false,
|
||||
});
|
||||
|
||||
detector.startPolling(1000);
|
||||
jest.advanceTimersByTime(3500);
|
||||
// 3 intervals should have fired (1000, 2000, 3000)
|
||||
expect(detectSpy.mock.calls.length).toBeGreaterThanOrEqual(3);
|
||||
detector.stopPolling();
|
||||
detectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('_extractContainerPorts', () => {
|
||||
test('returns mapped public ports', () => {
|
||||
const { detector } = makeDetector();
|
||||
const ports = detector._extractContainerPorts({
|
||||
Ports: [
|
||||
{ PublicPort: 8080, PrivatePort: 80, Type: 'tcp' },
|
||||
{ PublicPort: 8443, PrivatePort: 443, Type: 'tcp' },
|
||||
{ PrivatePort: 53, Type: 'udp' }, // No PublicPort → not exposed
|
||||
],
|
||||
});
|
||||
expect(ports).toEqual([8080, 8443]);
|
||||
});
|
||||
|
||||
test('returns [] when container has no Ports field', () => {
|
||||
const { detector } = makeDetector();
|
||||
expect(detector._extractContainerPorts({})).toEqual([]);
|
||||
expect(detector._extractContainerPorts({ Ports: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('_sendDriftNotification', () => {
|
||||
test('returns early when no notification manager is present', async () => {
|
||||
const { detector } = makeDetector({ notification: null });
|
||||
// Replace the field with null/undefined to simulate missing
|
||||
detector.notification = null;
|
||||
const result = await detector._sendDriftNotification({ hasDrift: true });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toMatch(/no-notification-manager/i);
|
||||
});
|
||||
|
||||
test('formats message with one line per drift category', async () => {
|
||||
const { detector, notification } = makeDetector();
|
||||
const report = {
|
||||
missingContainers: [{ name: 'app-a' }],
|
||||
unknownContainers: [{ name: 'app-b' }],
|
||||
portMismatch: [{ name: 'app-c' }],
|
||||
stateMismatch: [],
|
||||
staleRecords: [{ name: 'app-d' }],
|
||||
hasDrift: true,
|
||||
};
|
||||
await detector._sendDriftNotification(report);
|
||||
expect(notification.send).toHaveBeenCalledTimes(1);
|
||||
const payload = notification.send.mock.calls[0][1];
|
||||
expect(payload.text).toMatch(/Missing containers: app-a/);
|
||||
expect(payload.text).toMatch(/Unknown managed containers: app-b/);
|
||||
expect(payload.text).toMatch(/Port mismatches: app-c/);
|
||||
expect(payload.text).toMatch(/Stale records: app-d/);
|
||||
expect(payload.report).toBe(report);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Config migration tests
|
||||
*
|
||||
* These tests verify that a config file from any older version of DashCaddy
|
||||
* gets correctly migrated to the current version. Migration MUST be:
|
||||
* - Deterministic (same input always produces same output)
|
||||
* - Idempotent (running migration on already-migrated config is a no-op)
|
||||
* - Safe (no data loss; only adds fields, never removes user values)
|
||||
* - Silent (no exceptions thrown for any version from 0 to CURRENT)
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
CURRENT_VERSION,
|
||||
migrations,
|
||||
migrate,
|
||||
loadAndMigrate
|
||||
} = require('../src/config/migrations');
|
||||
|
||||
describe('config/migrations', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-mig-test-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('migrate()', () => {
|
||||
test('null/empty config returns fresh v_current', () => {
|
||||
const result = migrate(null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('undefined config returns fresh v_current', () => {
|
||||
const result = migrate(undefined);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('v0 (no _version) migrates all the way to current', () => {
|
||||
const v0 = { tld: '.home', customValue: 'preserved' };
|
||||
const result = migrate(v0);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data must be preserved
|
||||
expect(result.tld).toBe('.home');
|
||||
expect(result.customValue).toBe('preserved');
|
||||
});
|
||||
|
||||
test('each intermediate version migrates forward to current', () => {
|
||||
for (let v = 0; v < CURRENT_VERSION; v++) {
|
||||
const config = { _version: v, tld: '.test' };
|
||||
const result = migrate(config);
|
||||
// Final version is always CURRENT_VERSION after running all migrations
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// User data preserved
|
||||
expect(result.tld).toBe('.test');
|
||||
}
|
||||
});
|
||||
|
||||
test('config at current version passes through unchanged', () => {
|
||||
const current = { _version: CURRENT_VERSION, tld: '.home', customField: 'kept' };
|
||||
const result = migrate(current);
|
||||
expect(result).toEqual(current);
|
||||
});
|
||||
|
||||
test('config from FUTURE version is left alone (forward compat)', () => {
|
||||
const future = { _version: 999, tld: '.home', newField: 'unknown' };
|
||||
const result = migrate(future);
|
||||
// We don't touch future configs — let validation catch issues
|
||||
expect(result._version).toBe(999);
|
||||
expect(result.newField).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0 → v1 migration: dns normalization', () => {
|
||||
test('string dns gets converted to object', () => {
|
||||
const result = migrations[1]({ dns: '192.168.1.1' });
|
||||
expect(result.dns).toEqual({ ip: '192.168.1.1', port: 5380 });
|
||||
});
|
||||
|
||||
test('missing dns gets default object', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result.dns).toEqual({ ip: '', port: 5380 });
|
||||
});
|
||||
|
||||
test('object dns passes through unchanged', () => {
|
||||
const result = migrations[1]({ dns: { ip: '10.0.0.1', port: 5380, custom: 'kept' } });
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.custom).toBe('kept');
|
||||
});
|
||||
|
||||
test('_version is set to 1', () => {
|
||||
const result = migrations[1]({ tld: '.home' });
|
||||
expect(result._version).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('v1 → v2 migration: dns.provider field', () => {
|
||||
test('adds provider: technitium default', () => {
|
||||
const result = migrations[2]({ dns: { ip: '10.0.0.1', port: 5380 }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
});
|
||||
|
||||
test('respects existing provider if set', () => {
|
||||
const result = migrations[2]({ dns: { provider: 'cloudflare', ip: 'cf' }, _version: 1 });
|
||||
expect(result.dns.provider).toBe('cloudflare');
|
||||
});
|
||||
|
||||
test('_version is set to 2', () => {
|
||||
const result = migrations[2]({ _version: 1 });
|
||||
expect(result._version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadAndMigrate()', () => {
|
||||
test('creates fresh config when file does not exist', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// Should NOT write a file when there was nothing to migrate
|
||||
expect(fs.existsSync(configFile)).toBe(false);
|
||||
});
|
||||
|
||||
test('migrates old config and writes back to disk', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
// Write an unversioned config (v0)
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.sami', customField: 'preserve-me' }));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
|
||||
// Returned value is migrated
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
expect(result.tld).toBe('.sami');
|
||||
expect(result.customField).toBe('preserve-me');
|
||||
|
||||
// File on disk is updated
|
||||
const written = JSON.parse(fs.readFileSync(configFile, 'utf8'));
|
||||
expect(written._version).toBe(CURRENT_VERSION);
|
||||
expect(written.tld).toBe('.sami');
|
||||
});
|
||||
|
||||
test('does not rewrite file when already at current version', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
const original = JSON.stringify({ _version: CURRENT_VERSION, tld: '.home' }, null, 2);
|
||||
fs.writeFileSync(configFile, original);
|
||||
|
||||
// Record mtime before
|
||||
const mtimeBefore = fs.statSync(configFile).mtimeMs;
|
||||
// Wait a tick
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 50) {} // 50ms busy-wait
|
||||
|
||||
loadAndMigrate(configFile, null);
|
||||
|
||||
// File should not have been rewritten (mtime unchanged)
|
||||
const mtimeAfter = fs.statSync(configFile).mtimeMs;
|
||||
expect(mtimeAfter).toBe(mtimeBefore);
|
||||
});
|
||||
|
||||
test('handles corrupt JSON gracefully (returns defaults, no crash)', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, '{ this is not valid json');
|
||||
|
||||
// Should not throw
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('creates parent directory if missing', () => {
|
||||
const nested = path.join(tmpDir, 'nested', 'subdir', 'config.json');
|
||||
// Pre-create parent dirs (test setup)
|
||||
fs.mkdirSync(path.dirname(nested), { recursive: true });
|
||||
fs.writeFileSync(nested, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const result = loadAndMigrate(nested, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
});
|
||||
|
||||
test('full chain: v0 file with string dns becomes v2 with provider', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({
|
||||
tld: '.sami',
|
||||
dns: '10.0.0.1'
|
||||
}));
|
||||
|
||||
const result = loadAndMigrate(configFile, null);
|
||||
expect(result._version).toBe(CURRENT_VERSION);
|
||||
// After full chain, dns is normalized to object AND has provider
|
||||
expect(result.dns.ip).toBe('10.0.0.1');
|
||||
expect(result.dns.port).toBe(5380);
|
||||
expect(result.dns.provider).toBe('technitium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('idempotency', () => {
|
||||
test('running migration twice produces same result', () => {
|
||||
const v0 = { tld: '.home', customField: 'x' };
|
||||
const first = migrate(v0);
|
||||
const second = migrate(first);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
|
||||
test('loadAndMigrate is idempotent across reloads', () => {
|
||||
const configFile = path.join(tmpDir, 'config.json');
|
||||
fs.writeFileSync(configFile, JSON.stringify({ tld: '.home' }));
|
||||
|
||||
const first = loadAndMigrate(configFile, null);
|
||||
const second = loadAndMigrate(configFile, null);
|
||||
expect(second).toEqual(first);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
// Mock dependencies before requiring the module
|
||||
jest.mock('../keychain-manager', () => ({
|
||||
jest.mock('../src/security/keychain-manager', () => ({
|
||||
available: false,
|
||||
store: jest.fn().mockResolvedValue(false),
|
||||
retrieve: jest.fn().mockResolvedValue(null),
|
||||
delete: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../crypto-utils', () => ({
|
||||
jest.mock('../src/security/crypto-utils', () => ({
|
||||
encrypt: jest.fn(data => `enc:tag:${Buffer.from(String(data)).toString('base64')}`),
|
||||
decrypt: jest.fn(data => {
|
||||
const parts = data.split(':');
|
||||
@@ -40,8 +40,8 @@ describe('CredentialManager', () => {
|
||||
// Re-get mocked modules
|
||||
fs = require('fs');
|
||||
lockfile = require('proper-lockfile');
|
||||
keychainManager = require('../keychain-manager');
|
||||
cryptoUtils = require('../crypto-utils');
|
||||
keychainManager = require('../src/security/keychain-manager');
|
||||
cryptoUtils = require('../src/security/crypto-utils');
|
||||
|
||||
// Reset mock implementations
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
@@ -50,7 +50,7 @@ describe('CredentialManager', () => {
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager.available = false;
|
||||
|
||||
credentialManager = require('../credential-manager');
|
||||
credentialManager = require('../src/managers/credential-manager');
|
||||
credentialManager.cache.clear();
|
||||
});
|
||||
|
||||
@@ -72,10 +72,10 @@ describe('CredentialManager', () => {
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile = require('proper-lockfile');
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager = require('../keychain-manager');
|
||||
keychainManager = require('../src/security/keychain-manager');
|
||||
keychainManager.available = true;
|
||||
keychainManager.store.mockResolvedValue(true);
|
||||
credentialManager = require('../credential-manager');
|
||||
credentialManager = require('../src/managers/credential-manager');
|
||||
|
||||
const result = await credentialManager.store('test.key', 'value');
|
||||
expect(result).toBe(true);
|
||||
@@ -91,11 +91,11 @@ describe('CredentialManager', () => {
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
lockfile = require('proper-lockfile');
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
keychainManager = require('../keychain-manager');
|
||||
keychainManager = require('../src/security/keychain-manager');
|
||||
keychainManager.available = true;
|
||||
keychainManager.store.mockResolvedValue(false);
|
||||
cryptoUtils = require('../crypto-utils');
|
||||
credentialManager = require('../credential-manager');
|
||||
cryptoUtils = require('../src/security/crypto-utils');
|
||||
credentialManager = require('../src/managers/credential-manager');
|
||||
|
||||
const result = await credentialManager.store('test.key', 'value');
|
||||
expect(result).toBe(true);
|
||||
|
||||
@@ -11,7 +11,7 @@ const TEST_KEY_HEX = TEST_KEY.toString('hex');
|
||||
// Load the module once — no jest.resetModules() needed
|
||||
// We control key state via clearCachedKey() + env vars
|
||||
process.env.DASHCADDY_ENCRYPTION_KEY = TEST_KEY_HEX;
|
||||
const cryptoUtils = require('../crypto-utils');
|
||||
const cryptoUtils = require('../src/security/crypto-utils');
|
||||
|
||||
describe('Crypto Utils', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ const crypto = require('crypto');
|
||||
|
||||
// Mock crypto-utils to provide a predictable signing key
|
||||
const mockFixedKey = Buffer.alloc(32, 'test-key-material');
|
||||
jest.mock('../crypto-utils', () => ({
|
||||
jest.mock('../src/security/crypto-utils', () => ({
|
||||
loadOrCreateKey: jest.fn(() => mockFixedKey),
|
||||
}));
|
||||
|
||||
@@ -16,7 +16,7 @@ const {
|
||||
csrfCookieMiddleware,
|
||||
csrfValidationMiddleware,
|
||||
renewCSRFToken
|
||||
} = require('../csrf-protection');
|
||||
} = require('../src/security/csrf-protection');
|
||||
const { createMockReqRes } = require('./helpers/test-utils');
|
||||
|
||||
describe('CSRF Protection', () => {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Smoke tests for dns-propagation.js
|
||||
* Verifies DNS propagation checker module loads, exposes the expected
|
||||
* interface, and basic methods (verifyRecord, startVerification,
|
||||
* getVerificationStatus, getAllVerifications, cleanup) work without throwing.
|
||||
*/
|
||||
|
||||
// The module does `const dns = require('dns').promises;` then `new dns.Resolver()`.
|
||||
// We mock the dns module so that .promises exposes our Resolver class.
|
||||
jest.mock('dns', () => {
|
||||
class MockResolver {
|
||||
setServers() { return this; }
|
||||
setTimeout() { return this; }
|
||||
resolve4(domain) {
|
||||
if (domain === 'propagated.sami') {
|
||||
return Promise.resolve(['1.2.3.4']);
|
||||
}
|
||||
return Promise.resolve(['9.9.9.9']);
|
||||
}
|
||||
}
|
||||
return {
|
||||
promises: { Resolver: MockResolver },
|
||||
Resolver: MockResolver,
|
||||
};
|
||||
});
|
||||
|
||||
const DNSPropagationChecker = require('../src/dns/dns-propagation');
|
||||
|
||||
describe('DNSPropagationChecker', () => {
|
||||
let checker;
|
||||
|
||||
beforeEach(() => {
|
||||
const ctx = {
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
notification: { send: jest.fn().mockResolvedValue({ success: true }) },
|
||||
};
|
||||
checker = new DNSPropagationChecker(ctx);
|
||||
});
|
||||
|
||||
test('is an EventEmitter', () => {
|
||||
expect(typeof checker.on).toBe('function');
|
||||
expect(typeof checker.emit).toBe('function');
|
||||
});
|
||||
|
||||
test('starts with an empty verifications map', () => {
|
||||
expect(checker.verifications).toBeInstanceOf(Map);
|
||||
expect(checker.verifications.size).toBe(0);
|
||||
});
|
||||
|
||||
test('verifyRecord returns expected shape and detects propagated domain', async () => {
|
||||
const result = await checker.verifyRecord('propagated.sami', '1.2.3.4', {
|
||||
timeout: 5000,
|
||||
interval: 100,
|
||||
resolvers: ['1.1.1.1'],
|
||||
});
|
||||
expect(result).toHaveProperty('domain', 'propagated.sami');
|
||||
expect(result).toHaveProperty('expectedIp', '1.2.3.4');
|
||||
expect(result).toHaveProperty('propagated', true);
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(typeof result.totalTime).toBe('number');
|
||||
expect(typeof result.checkedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('verifyRecord reports not-propagated when IP does not match', async () => {
|
||||
const result = await checker.verifyRecord('notpropagated.sami', '5.6.7.8', {
|
||||
timeout: 200,
|
||||
interval: 50,
|
||||
resolvers: ['1.1.1.1'],
|
||||
});
|
||||
expect(result.propagated).toBe(false);
|
||||
});
|
||||
|
||||
test('startVerification returns a job object with running status', () => {
|
||||
const job = checker.startVerification('job.sami', '1.1.1.1', {
|
||||
timeout: 100,
|
||||
interval: 50,
|
||||
resolvers: ['1.1.1.1'],
|
||||
});
|
||||
expect(job).toMatchObject({
|
||||
domain: 'job.sami',
|
||||
expectedIp: '1.1.1.1',
|
||||
status: 'running',
|
||||
});
|
||||
expect(job.startedAt).toBeDefined();
|
||||
});
|
||||
|
||||
test('startVerification returns the same job when called twice for one domain', () => {
|
||||
const a = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
|
||||
const b = checker.startVerification('dup.sami', '1.1.1.1', { timeout: 5000, interval: 1000 });
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test('getVerificationStatus returns null for unknown domain', () => {
|
||||
expect(checker.getVerificationStatus('nope.sami')).toBeNull();
|
||||
});
|
||||
|
||||
test('getAllVerifications returns an array', () => {
|
||||
expect(Array.isArray(checker.getAllVerifications())).toBe(true);
|
||||
});
|
||||
|
||||
test('cleanup is a no-op on empty verifications', () => {
|
||||
expect(() => checker.cleanup()).not.toThrow();
|
||||
expect(checker.verifications.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ describe('DockerSecurity Module', () => {
|
||||
|
||||
// Reset modules to get fresh instance
|
||||
jest.resetModules();
|
||||
dockerSecurity = require('../docker-security');
|
||||
dockerSecurity = require('../src/security/docker-security');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -58,7 +58,7 @@ describe('DockerSecurity Module', () => {
|
||||
|
||||
// Force module reload
|
||||
jest.resetModules();
|
||||
const freshInstance = require('../docker-security');
|
||||
const freshInstance = require('../src/security/docker-security');
|
||||
const status = freshInstance.getStatus();
|
||||
|
||||
expect(status.trustedImagesCount).toBe(1);
|
||||
@@ -77,7 +77,7 @@ describe('DockerSecurity Module', () => {
|
||||
fs.writeFileSync(TEST_CONFIG_FILE, 'INVALID JSON{{{');
|
||||
|
||||
jest.resetModules();
|
||||
const freshInstance = require('../docker-security');
|
||||
const freshInstance = require('../src/security/docker-security');
|
||||
const status = freshInstance.getStatus();
|
||||
|
||||
// Should fall back to default config
|
||||
@@ -89,7 +89,7 @@ describe('DockerSecurity Module', () => {
|
||||
process.env.DOCKER_SECURITY_CONFIG = '/nonexistent/path/config.json';
|
||||
|
||||
jest.resetModules();
|
||||
const freshInstance = require('../docker-security');
|
||||
const freshInstance = require('../src/security/docker-security');
|
||||
const status = freshInstance.getStatus();
|
||||
|
||||
// Should fall back to default config
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
jest.mock('../error-logger', () => ({
|
||||
logError: jest.fn(),
|
||||
// Mock the unified logging module so we can verify logError is called
|
||||
// without writing to the actual error.log file
|
||||
jest.mock('../src/utils/logging', () => ({
|
||||
logError: jest.fn().mockResolvedValue(),
|
||||
safeErrorMessage: jest.fn((err) => {
|
||||
if (!err) return 'An internal error occurred';
|
||||
return err.message || String(err);
|
||||
}),
|
||||
createLogger: jest.fn(() => ({
|
||||
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn()
|
||||
})),
|
||||
LOG_LEVELS: { debug: 0, info: 1, warn: 2, error: 3 }
|
||||
}));
|
||||
|
||||
const { asyncHandler, errorMiddleware, notFoundHandler } = require('../error-handler');
|
||||
const { errorMiddleware, notFoundHandler } = require('../src/utilities/error-handler');
|
||||
const {
|
||||
AppError,
|
||||
ValidationError,
|
||||
@@ -10,7 +20,7 @@ const {
|
||||
NotFoundError,
|
||||
RateLimitError,
|
||||
DockerError,
|
||||
} = require('../errors');
|
||||
} = require('../src/utilities/errors');
|
||||
|
||||
describe('Error Handler', () => {
|
||||
let req, res, next;
|
||||
@@ -30,23 +40,6 @@ describe('Error Handler', () => {
|
||||
next = jest.fn();
|
||||
});
|
||||
|
||||
describe('asyncHandler', () => {
|
||||
it('calls the wrapped function', async () => {
|
||||
const fn = jest.fn().mockResolvedValue();
|
||||
const wrapped = asyncHandler(fn);
|
||||
await wrapped(req, res, next);
|
||||
expect(fn).toHaveBeenCalledWith(req, res, next);
|
||||
});
|
||||
|
||||
it('calls next(err) on rejected promise', async () => {
|
||||
const error = new Error('async fail');
|
||||
const fn = jest.fn().mockRejectedValue(error);
|
||||
const wrapped = asyncHandler(fn);
|
||||
await wrapped(req, res, next);
|
||||
expect(next).toHaveBeenCalledWith(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorMiddleware', () => {
|
||||
it('returns 400 for ValidationError', () => {
|
||||
const err = new ValidationError('bad input', 'email');
|
||||
|
||||
@@ -10,7 +10,7 @@ const {
|
||||
CaddyError,
|
||||
DNSError,
|
||||
ServiceUnavailableError
|
||||
} = require('../errors');
|
||||
} = require('../src/utilities/errors');
|
||||
|
||||
describe('Error Classes', () => {
|
||||
describe('AppError', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('HealthChecker', () => {
|
||||
fs.writeFileSync.mockImplementation(() => {});
|
||||
|
||||
// Fresh instance each test
|
||||
HealthChecker = require('../health-checker').constructor;
|
||||
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||
healthChecker = new HealthChecker();
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('HealthChecker', () => {
|
||||
services: { svc1: { url: 'http://test.local', enabled: true } }
|
||||
}));
|
||||
|
||||
HealthChecker = require('../health-checker').constructor;
|
||||
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||
const hc = new HealthChecker();
|
||||
expect(hc.config.services.svc1).toBeDefined();
|
||||
});
|
||||
@@ -52,7 +52,7 @@ describe('HealthChecker', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue('invalid json');
|
||||
|
||||
HealthChecker = require('../health-checker').constructor;
|
||||
HealthChecker = require('../src/monitoring/health-checker').constructor;
|
||||
const hc = new HealthChecker();
|
||||
expect(hc.config).toEqual({ services: {} });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Health endpoint tests
|
||||
*
|
||||
* Verifies:
|
||||
* - /health/live always returns 200
|
||||
* - /health/ready returns 200 with valid structure when all deps OK
|
||||
* - /health/ready returns 503 when a critical dep is down
|
||||
* - /health/ready does NOT crash with "res.status is not a function"
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// Mock dockerode BEFORE anything else
|
||||
jest.mock('dockerode', () => {
|
||||
return jest.fn().mockImplementation(() => ({
|
||||
ping: jest.fn().mockImplementation(() => {
|
||||
if (process.env.MOCK_DOCKER_DOWN === '1') {
|
||||
return Promise.reject(new Error('docker unreachable'));
|
||||
}
|
||||
return Promise.resolve('OK');
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
// Build a minimal Express app with the same health handlers as src/app.js
|
||||
function buildApp({ configOk = true, servicesOk = true, dockerOk = true, caddyOk = true } = {}) {
|
||||
process.env.MOCK_DOCKER_DOWN = dockerOk ? '0' : '1';
|
||||
|
||||
const app = express();
|
||||
const config = {
|
||||
CONFIG_FILE: '/tmp/dc-test-config.json',
|
||||
SERVICES_FILE: '/tmp/dc-test-services.json',
|
||||
CADDY_ADMIN_URL: 'http://localhost:2019'
|
||||
};
|
||||
|
||||
// Mock fs
|
||||
const fs = require('fs');
|
||||
const realExistsSync = fs.existsSync;
|
||||
const realReadFileSync = fs.readFileSync;
|
||||
fs.existsSync = (p) => {
|
||||
if (p === config.CONFIG_FILE) return configOk;
|
||||
if (p === config.SERVICES_FILE) return servicesOk;
|
||||
return realExistsSync(p);
|
||||
};
|
||||
fs.readFileSync = (p, ...args) => {
|
||||
if (p === config.CONFIG_FILE) {
|
||||
if (!configOk) throw new Error('config not found');
|
||||
return '{}';
|
||||
}
|
||||
if (p === config.SERVICES_FILE) {
|
||||
if (!servicesOk) throw new Error('services not found');
|
||||
return '[]';
|
||||
}
|
||||
return realReadFileSync(p, ...args);
|
||||
};
|
||||
|
||||
// /health/live (matches src/app.js exactly)
|
||||
app.get('/health/live', (req, res) => {
|
||||
res.json({ status: 'alive', uptime: process.uptime() });
|
||||
});
|
||||
|
||||
// /health/ready (matches src/app.js — uses the FIXED boundAsyncHandler pattern)
|
||||
const { asyncHandler } = require('../src/utils/async-handler');
|
||||
const logError = async () => {}; // noop logger
|
||||
const boundAsyncHandler = (fn) => asyncHandler(logError, fn, 'test');
|
||||
|
||||
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
|
||||
const checks = {};
|
||||
let allOk = true;
|
||||
|
||||
try {
|
||||
if (fs.existsSync(config.CONFIG_FILE)) {
|
||||
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
||||
checks.configFile = { ok: true };
|
||||
} else {
|
||||
checks.configFile = { ok: false, error: 'Config file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.configFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.existsSync(config.SERVICES_FILE)) {
|
||||
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
||||
checks.servicesFile = { ok: true };
|
||||
} else {
|
||||
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
||||
allOk = false;
|
||||
}
|
||||
} catch (e) {
|
||||
checks.servicesFile = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const docker = require('dockerode')();
|
||||
await docker.ping();
|
||||
checks.docker = { ok: true };
|
||||
} catch (e) {
|
||||
checks.docker = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const response = await fetch(`${caddyUrl}/config/`, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
checks.caddy = { ok: response.ok, status: response.status };
|
||||
if (!response.ok) allOk = false;
|
||||
} catch (e) {
|
||||
checks.caddy = { ok: false, error: e.message };
|
||||
allOk = false;
|
||||
}
|
||||
|
||||
const body = {
|
||||
status: allOk ? 'ready' : 'not-ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
checks
|
||||
};
|
||||
res.status(allOk ? 200 : 503).json(body);
|
||||
}));
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('Health Endpoints', () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MOCK_DOCKER_DOWN;
|
||||
});
|
||||
|
||||
describe('GET /health/live', () => {
|
||||
it('always returns 200 with status: alive', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/live');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('alive');
|
||||
expect(typeof res.body.uptime).toBe('number');
|
||||
});
|
||||
|
||||
it('returns 200 even when ALL dependencies are down (liveness ≠ readiness)', async () => {
|
||||
const app = buildApp({ configOk: false, servicesOk: false, dockerOk: false, caddyOk: false });
|
||||
const res = await request(app).get('/health/live');
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /health/ready', () => {
|
||||
it('returns 200 when all dependencies are OK (excluding caddy which may 403 in sandbox)', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/ready');
|
||||
// config + services + docker should all be OK
|
||||
expect(res.body.checks.configFile.ok).toBe(true);
|
||||
expect(res.body.checks.servicesFile.ok).toBe(true);
|
||||
expect(res.body.checks.docker.ok).toBe(true);
|
||||
// caddy is tested in sandbox — may be 403 or 200
|
||||
expect(res.body).toHaveProperty('checks');
|
||||
expect(res.body).toHaveProperty('status');
|
||||
});
|
||||
|
||||
it('returns 503 when config file is missing', async () => {
|
||||
const app = buildApp({ configOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.status).toBe('not-ready');
|
||||
expect(res.body.checks.configFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 503 when services file is missing', async () => {
|
||||
const app = buildApp({ servicesOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.checks.servicesFile.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('returns 503 when Docker is unreachable', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(res.status).toBe(503);
|
||||
expect(res.body.checks.docker.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('does NOT crash with "res.status is not a function" when dependencies fail', async () => {
|
||||
const app = buildApp({ dockerOk: false });
|
||||
const res = await request(app).get('/health/ready');
|
||||
const bodyStr = JSON.stringify(res.body);
|
||||
expect(bodyStr).not.toMatch(/res\.status is not a function/);
|
||||
// Should always be a valid response object
|
||||
expect(res.body).toHaveProperty('checks');
|
||||
});
|
||||
|
||||
it('responds with all 4 expected check keys', async () => {
|
||||
const app = buildApp();
|
||||
const res = await request(app).get('/health/ready');
|
||||
expect(Object.keys(res.body.checks).sort()).toEqual(['caddy', 'configFile', 'docker', 'servicesFile']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -90,7 +90,7 @@ function buildTestApp(routeFactory, deps, prefix = '/api') {
|
||||
const router = routeFactory(deps);
|
||||
app.use(prefix, router);
|
||||
// Error handler
|
||||
const { errorMiddleware } = require('../../error-handler');
|
||||
const { errorMiddleware } = require('../../../src/utilities/error-handler');
|
||||
app.use(errorMiddleware);
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const {
|
||||
isValidPort,
|
||||
isPrivateIP,
|
||||
validateSecurePath
|
||||
} = require('../input-validator');
|
||||
} = require('../src/security/input-validator');
|
||||
|
||||
describe('Input Validator', () => {
|
||||
function fail(message) {
|
||||
@@ -480,7 +480,7 @@ describe('Input Validator', () => {
|
||||
|
||||
// Re-require after mocking fs
|
||||
function getValidateSecurePath() {
|
||||
return require('../input-validator').validateSecurePath;
|
||||
return require('../src/security/input-validator').validateSecurePath;
|
||||
}
|
||||
|
||||
it('resolves valid path within allowed roots', async () => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Smoke tests for log-digest.js
|
||||
* Verifies the singleton LogDigest exposes the expected interface, parses
|
||||
* Docker multiplexed log streams, formats digests, and supports on-demand
|
||||
* daily digest generation with mocked Docker.
|
||||
*/
|
||||
|
||||
const fsReal = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
jest.mock('dockerode', () => {
|
||||
const listContainers = jest.fn().mockResolvedValue([]);
|
||||
const getContainer = jest.fn(() => ({
|
||||
logs: jest.fn().mockResolvedValue(Buffer.from([])),
|
||||
}));
|
||||
function Docker() {}
|
||||
Docker.prototype.listContainers = listContainers;
|
||||
Docker.prototype.getContainer = getContainer;
|
||||
return Docker;
|
||||
});
|
||||
|
||||
jest.mock('fs', () => {
|
||||
const actual = jest.requireActual('fs');
|
||||
return {
|
||||
...actual,
|
||||
existsSync: jest.fn().mockReturnValue(true),
|
||||
mkdirSync: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('../src/docker/docker-maintenance', () => ({
|
||||
getDiskUsage: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const Docker = require('dockerode');
|
||||
const fs = require('fs');
|
||||
const logDigest = require('../src/security/log-digest');
|
||||
|
||||
describe('LogDigest (singleton)', () => {
|
||||
let dockerInstance;
|
||||
let tempDir;
|
||||
|
||||
beforeEach(() => {
|
||||
// Each test gets a fresh Docker() mock instance
|
||||
jest.clearAllMocks();
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
// Use a real, writable temp directory so writeFile inside generateDailyDigest
|
||||
// does not blow up. Each test gets a fresh dir to avoid cross-test pollution.
|
||||
tempDir = fsReal.mkdtempSync(path.join(os.tmpdir(), 'dc-digest-test-'));
|
||||
logDigest.hourlySummaries = [];
|
||||
logDigest.lastCollect = null;
|
||||
logDigest.running = false;
|
||||
logDigest.digestDir = null;
|
||||
if (logDigest.collectInterval) {
|
||||
clearInterval(logDigest.collectInterval);
|
||||
logDigest.collectInterval = null;
|
||||
}
|
||||
if (logDigest.digestTimeout) {
|
||||
clearTimeout(logDigest.digestTimeout);
|
||||
logDigest.digestTimeout = null;
|
||||
}
|
||||
dockerInstance = new Docker();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logDigest.stop();
|
||||
if (tempDir && fsReal.existsSync(tempDir)) {
|
||||
fsReal.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('is an EventEmitter and exposes the documented API', () => {
|
||||
expect(typeof logDigest.on).toBe('function');
|
||||
expect(typeof logDigest.emit).toBe('function');
|
||||
expect(typeof logDigest.start).toBe('function');
|
||||
expect(typeof logDigest.stop).toBe('function');
|
||||
expect(typeof logDigest.generateDailyDigest).toBe('function');
|
||||
expect(typeof logDigest.getLatestDigest).toBe('function');
|
||||
expect(typeof logDigest.getDigestByDate).toBe('function');
|
||||
expect(typeof logDigest.getDigestText).toBe('function');
|
||||
expect(typeof logDigest.listDigests).toBe('function');
|
||||
expect(typeof logDigest.getLiveData).toBe('function');
|
||||
expect(typeof logDigest.getStatus).toBe('function');
|
||||
});
|
||||
|
||||
test('getStatus returns current state', () => {
|
||||
const status = logDigest.getStatus();
|
||||
expect(status).toEqual({
|
||||
running: false,
|
||||
lastCollect: null,
|
||||
hourlySummaries: 0,
|
||||
digestDir: null,
|
||||
});
|
||||
});
|
||||
|
||||
test('start sets running and digestDir', () => {
|
||||
logDigest.start(tempDir);
|
||||
expect(logDigest.running).toBe(true);
|
||||
expect(logDigest.digestDir).toBe(tempDir);
|
||||
});
|
||||
|
||||
test('start is idempotent — second call does nothing new', () => {
|
||||
logDigest.start(tempDir);
|
||||
const firstInterval = logDigest.collectInterval;
|
||||
logDigest.start(tempDir);
|
||||
expect(logDigest.collectInterval).toBe(firstInterval);
|
||||
});
|
||||
|
||||
test('_parseDockerLogs decodes multiplexed log frames into lines', () => {
|
||||
// Stream type byte: 0=stdin, 1=stdout, 2=stderr
|
||||
// Header: [type, 0, 0, 0, size-BE-uint32]
|
||||
function frame(streamType, text) {
|
||||
const buf = Buffer.from(text, 'utf8');
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = streamType;
|
||||
header.writeUInt32BE(buf.length, 4);
|
||||
return Buffer.concat([header, buf]);
|
||||
}
|
||||
|
||||
const multiplexed = Buffer.concat([
|
||||
frame(1, 'hello world\n'),
|
||||
frame(2, '2026-03-13T12:00:00.000Z an error happened\n'),
|
||||
]);
|
||||
|
||||
const lines = logDigest._parseDockerLogs(multiplexed);
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toEqual({
|
||||
stream: 'stdout',
|
||||
text: 'hello world',
|
||||
timestamp: null,
|
||||
});
|
||||
expect(lines[1].stream).toBe('stderr');
|
||||
expect(lines[1].text).toBe('an error happened');
|
||||
expect(lines[1].timestamp).toBe('2026-03-13T12:00:00');
|
||||
});
|
||||
|
||||
test('generateDailyDigest with empty summaries produces minimal digest', async () => {
|
||||
logDigest.start(tempDir);
|
||||
const digest = await logDigest.generateDailyDigest('2099-01-01');
|
||||
expect(digest.date).toBe('2099-01-01');
|
||||
expect(digest.services).toEqual({});
|
||||
expect(digest.summary.totalServices).toBe(0);
|
||||
expect(digest.summary.totalErrors).toBe(0);
|
||||
expect(Array.isArray(digest.notableEvents)).toBe(true);
|
||||
|
||||
// Confirm the file was actually written
|
||||
const writtenPath = path.join(tempDir, 'digest-2099-01-01.log');
|
||||
expect(fsReal.existsSync(writtenPath)).toBe(true);
|
||||
const jsonPath = path.join(tempDir, 'digest-2099-01-01.json');
|
||||
expect(fsReal.existsSync(jsonPath)).toBe(true);
|
||||
});
|
||||
|
||||
test('getLiveData returns shape with date, hoursCollected, services', () => {
|
||||
const data = logDigest.getLiveData();
|
||||
expect(data).toHaveProperty('date');
|
||||
expect(data).toHaveProperty('hoursCollected');
|
||||
expect(data).toHaveProperty('services');
|
||||
expect(data).toHaveProperty('lastCollect');
|
||||
});
|
||||
|
||||
test('getLatestDigest returns null when digestDir is null', async () => {
|
||||
logDigest.digestDir = null;
|
||||
const result = await logDigest.getLatestDigest();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('getDigestByDate returns null when no file exists', async () => {
|
||||
logDigest.digestDir = '/nonexistent/path';
|
||||
const result = await logDigest.getDigestByDate('2020-01-01');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('listDigests returns empty array when digestDir is null', async () => {
|
||||
logDigest.digestDir = null;
|
||||
const result = await logDigest.listDigests();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('stop clears intervals and timeouts', () => {
|
||||
logDigest.start(tempDir);
|
||||
logDigest.stop();
|
||||
expect(logDigest.running).toBe(false);
|
||||
expect(logDigest.collectInterval).toBeNull();
|
||||
expect(logDigest.digestTimeout).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Smoke tests for metrics.js
|
||||
* Verifies the Metrics singleton exposes the expected interface, accumulates
|
||||
* request/error/business counters, normalizes paths, formats uptime, and resets.
|
||||
*
|
||||
* The module exports a singleton instance, so we import it once and mutate its
|
||||
* state in beforeEach.
|
||||
*/
|
||||
|
||||
const metrics = require('../src/monitoring/metrics');
|
||||
|
||||
describe('Metrics (singleton)', () => {
|
||||
beforeEach(() => {
|
||||
metrics.reset();
|
||||
});
|
||||
|
||||
test('exposes the documented public API', () => {
|
||||
expect(typeof metrics.recordRequest).toBe('function');
|
||||
expect(typeof metrics.recordError).toBe('function');
|
||||
expect(typeof metrics.recordBusinessEvent).toBe('function');
|
||||
expect(typeof metrics.normalizePath).toBe('function');
|
||||
expect(typeof metrics.getSummary).toBe('function');
|
||||
expect(typeof metrics.formatUptime).toBe('function');
|
||||
expect(typeof metrics.reset).toBe('function');
|
||||
});
|
||||
|
||||
describe('recordRequest', () => {
|
||||
test('increments total request count', () => {
|
||||
metrics.recordRequest('GET', '/api/services', 200, 12);
|
||||
metrics.recordRequest('GET', '/api/services', 200, 8);
|
||||
expect(metrics.requests.total).toBe(2);
|
||||
});
|
||||
|
||||
test('aggregates by status code', () => {
|
||||
metrics.recordRequest('GET', '/a', 200, 5);
|
||||
metrics.recordRequest('GET', '/b', 200, 5);
|
||||
metrics.recordRequest('POST', '/c', 500, 5);
|
||||
expect(metrics.requests.byStatus[200]).toBe(2);
|
||||
expect(metrics.requests.byStatus[500]).toBe(1);
|
||||
});
|
||||
|
||||
test('aggregates by HTTP method', () => {
|
||||
metrics.recordRequest('GET', '/a', 200, 1);
|
||||
metrics.recordRequest('GET', '/b', 200, 1);
|
||||
metrics.recordRequest('DELETE', '/c', 200, 1);
|
||||
expect(metrics.requests.byMethod.GET).toBe(2);
|
||||
expect(metrics.requests.byMethod.DELETE).toBe(1);
|
||||
});
|
||||
|
||||
test('aggregates by normalized path with totalDuration', () => {
|
||||
// Real-looking UUID and long hex hash; both should normalize to /:id
|
||||
const id1 = '550e8400-e29b-41d4-a716-446655440000';
|
||||
const id2 = 'abcdef0123456789abcdef0123456789';
|
||||
metrics.recordRequest('GET', `/api/services/${id1}`, 200, 10);
|
||||
metrics.recordRequest('GET', `/api/services/${id2}`, 200, 20);
|
||||
const entry = metrics.requests.byPath['/api/services/:id'];
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.count).toBe(2);
|
||||
expect(entry.totalDuration).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordError', () => {
|
||||
test('increments total error count and per-type counts', () => {
|
||||
metrics.recordError('ValidationError');
|
||||
metrics.recordError('ValidationError');
|
||||
metrics.recordError('DockerError');
|
||||
expect(metrics.errors.total).toBe(3);
|
||||
expect(metrics.errors.byType.ValidationError).toBe(2);
|
||||
expect(metrics.errors.byType.DockerError).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordBusinessEvent', () => {
|
||||
test('increments known business counters', () => {
|
||||
metrics.recordBusinessEvent('containersDeployed');
|
||||
metrics.recordBusinessEvent('containersDeployed');
|
||||
metrics.recordBusinessEvent('dnsRecordsCreated');
|
||||
expect(metrics.business.containersDeployed).toBe(2);
|
||||
expect(metrics.business.dnsRecordsCreated).toBe(1);
|
||||
});
|
||||
|
||||
test('ignores unknown event types without throwing', () => {
|
||||
expect(() => metrics.recordBusinessEvent('not-a-real-event')).not.toThrow();
|
||||
expect(metrics.business.notARealEvent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePath', () => {
|
||||
test('replaces UUIDs with /:id', () => {
|
||||
const normalized = metrics.normalizePath('/api/services/550e8400-e29b-41d4-a716-446655440000');
|
||||
expect(normalized).toBe('/api/services/:id');
|
||||
});
|
||||
|
||||
test('replaces long hex segments with /:id', () => {
|
||||
expect(metrics.normalizePath('/api/containers/abc123def4567890'))
|
||||
.toBe('/api/containers/:id');
|
||||
});
|
||||
|
||||
test('replaces numeric path segments with /:n', () => {
|
||||
expect(metrics.normalizePath('/api/services/42/edit'))
|
||||
.toBe('/api/services/:n/edit');
|
||||
});
|
||||
|
||||
test('leaves static paths unchanged', () => {
|
||||
expect(metrics.normalizePath('/api/health')).toBe('/api/health');
|
||||
expect(metrics.normalizePath('/')).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSummary', () => {
|
||||
test('returns an object with the documented top-level shape', () => {
|
||||
const summary = metrics.getSummary();
|
||||
expect(summary).toHaveProperty('uptime');
|
||||
expect(summary.uptime).toHaveProperty('ms');
|
||||
expect(summary.uptime).toHaveProperty('human');
|
||||
expect(summary).toHaveProperty('requests');
|
||||
expect(summary.requests).toHaveProperty('total');
|
||||
expect(summary.requests).toHaveProperty('perSecond');
|
||||
expect(summary.requests).toHaveProperty('byStatus');
|
||||
expect(summary.requests).toHaveProperty('byMethod');
|
||||
expect(summary.requests).toHaveProperty('topEndpoints');
|
||||
expect(Array.isArray(summary.requests.topEndpoints)).toBe(true);
|
||||
expect(summary).toHaveProperty('errors');
|
||||
expect(summary.errors).toHaveProperty('total');
|
||||
expect(summary.errors).toHaveProperty('rate');
|
||||
expect(summary.errors).toHaveProperty('byType');
|
||||
expect(summary).toHaveProperty('business');
|
||||
expect(summary).toHaveProperty('process');
|
||||
expect(summary.process).toHaveProperty('pid');
|
||||
});
|
||||
|
||||
test('reflects recorded activity', () => {
|
||||
metrics.recordRequest('GET', '/api/foo', 200, 10);
|
||||
metrics.recordError('BoomError');
|
||||
const summary = metrics.getSummary();
|
||||
expect(summary.requests.total).toBe(1);
|
||||
expect(summary.requests.byStatus[200]).toBe(1);
|
||||
expect(summary.errors.total).toBe(1);
|
||||
expect(summary.errors.byType.BoomError).toBe(1);
|
||||
// 1 error / 1 request = 100% error rate
|
||||
expect(summary.errors.rate).toBe(100);
|
||||
});
|
||||
|
||||
test('topEndpoints is sorted by count descending and capped at 15', () => {
|
||||
// /a gets 3 hits, /b gets 1, /c gets 2
|
||||
metrics.recordRequest('GET', '/a', 200, 1);
|
||||
metrics.recordRequest('GET', '/a', 200, 2);
|
||||
metrics.recordRequest('GET', '/a', 200, 3);
|
||||
metrics.recordRequest('GET', '/b', 200, 1);
|
||||
metrics.recordRequest('GET', '/c', 200, 1);
|
||||
metrics.recordRequest('GET', '/c', 200, 2);
|
||||
const top = metrics.getSummary().requests.topEndpoints;
|
||||
expect(top[0].path).toBe('/a');
|
||||
expect(top[0].count).toBe(3);
|
||||
expect(top[0].avgMs).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUptime', () => {
|
||||
test('formats seconds-only when under a minute', () => {
|
||||
expect(metrics.formatUptime(0)).toBe('0s');
|
||||
expect(metrics.formatUptime(45)).toBe('45s');
|
||||
});
|
||||
|
||||
test('formats minutes and seconds when under an hour', () => {
|
||||
expect(metrics.formatUptime(60)).toBe('1m 0s');
|
||||
expect(metrics.formatUptime(125)).toBe('2m 5s');
|
||||
});
|
||||
|
||||
test('formats hours/minutes/seconds when under a day', () => {
|
||||
expect(metrics.formatUptime(3600)).toBe('1h 0m 0s');
|
||||
expect(metrics.formatUptime(3725)).toBe('1h 2m 5s');
|
||||
});
|
||||
|
||||
test('formats days/hours/minutes when over a day', () => {
|
||||
expect(metrics.formatUptime(86400)).toBe('1d 0h 0m');
|
||||
// 1 day, 2 hours, 5 minutes, 0 seconds
|
||||
expect(metrics.formatUptime(86400 + 2 * 3600 + 5 * 60)).toBe('1d 2h 5m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
test('clears request counters and error counters', () => {
|
||||
metrics.recordRequest('GET', '/x', 200, 1);
|
||||
metrics.recordError('E');
|
||||
metrics.reset();
|
||||
expect(metrics.requests.total).toBe(0);
|
||||
expect(metrics.errors.total).toBe(0);
|
||||
expect(metrics.requests.byStatus).toEqual({});
|
||||
expect(metrics.requests.byMethod).toEqual({});
|
||||
expect(metrics.requests.byPath).toEqual({});
|
||||
expect(metrics.errors.byType).toEqual({});
|
||||
});
|
||||
|
||||
test('resets startTime so uptime is small after reset', () => {
|
||||
const before = metrics.startTime;
|
||||
// Sleep a tick so Date.now() moves forward
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 5) {} // ~5ms busy-wait
|
||||
metrics.reset();
|
||||
expect(metrics.startTime).toBeGreaterThanOrEqual(before);
|
||||
const summary = metrics.getSummary();
|
||||
expect(summary.uptime.ms).toBeLessThan(5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Smoke tests for notification-manager.js
|
||||
* Verifies the NotificationManager loads, exposes the expected interface,
|
||||
* handles config loading/saving, sends notifications via providers, and
|
||||
* correctly tracks history.
|
||||
*/
|
||||
|
||||
jest.mock('fs', () => ({
|
||||
existsSync: jest.fn().mockReturnValue(false),
|
||||
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||
writeFileSync: jest.fn(),
|
||||
mkdirSync: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nodemailer', () => ({
|
||||
createTransport: jest.fn(() => ({
|
||||
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
const fs = require('fs');
|
||||
const nodemailer = require('nodemailer');
|
||||
const NotificationManager = require('../src/managers/notification-manager');
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
let nm;
|
||||
const NOTIF_FILE = '/tmp/dc-notif-test.json';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
fs.mkdirSync.mockReturnValue(undefined);
|
||||
|
||||
nm = new NotificationManager({
|
||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
fetchT: jest.fn(),
|
||||
docker: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
nm.stopHealthDaemon();
|
||||
});
|
||||
|
||||
test('initializes with default config', () => {
|
||||
const cfg = nm.getConfig();
|
||||
expect(cfg.enabled).toBe(true);
|
||||
expect(cfg.providers).toHaveProperty('discord');
|
||||
expect(cfg.providers).toHaveProperty('telegram');
|
||||
expect(cfg.providers).toHaveProperty('ntfy');
|
||||
expect(cfg.providers).toHaveProperty('email');
|
||||
});
|
||||
|
||||
test('starts with empty history and null lastSent', () => {
|
||||
expect(nm.getHistory()).toEqual([]);
|
||||
expect(nm.lastSent).toBeNull();
|
||||
});
|
||||
|
||||
test('saveConfig writes the config to disk and creates parent dir', async () => {
|
||||
fs.existsSync.mockReturnValue(false);
|
||||
await nm.saveConfig();
|
||||
expect(fs.mkdirSync).toHaveBeenCalled();
|
||||
expect(fs.writeFileSync).toHaveBeenCalled();
|
||||
const callArgs = fs.writeFileSync.mock.calls[0];
|
||||
expect(callArgs[0]).toBe(NOTIF_FILE);
|
||||
expect(callArgs[1]).toContain('enabled');
|
||||
});
|
||||
|
||||
test('loadConfig merges file content with defaults', () => {
|
||||
fs.existsSync.mockReturnValue(true);
|
||||
fs.readFileSync.mockReturnValue(JSON.stringify({ enabled: false }));
|
||||
const loaded = new NotificationManager({
|
||||
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
});
|
||||
expect(loaded.getConfig().enabled).toBe(false);
|
||||
});
|
||||
|
||||
test('clearHistory empties the history array', () => {
|
||||
nm.history.push({ event: 'test', timestamp: new Date().toISOString() });
|
||||
expect(nm.getHistory().length).toBe(1);
|
||||
nm.clearHistory();
|
||||
expect(nm.getHistory().length).toBe(0);
|
||||
});
|
||||
|
||||
test('send returns disabled when notifications are off', async () => {
|
||||
nm.config.enabled = false;
|
||||
const result = await nm.send('alert', { text: 'hi' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/disabled/i);
|
||||
});
|
||||
|
||||
test('send returns event-not-enabled for unknown events', async () => {
|
||||
nm.config.events['some-disabled-event'] = false;
|
||||
const result = await nm.send('some-disabled-event', { text: 'hi' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/not enabled/i);
|
||||
});
|
||||
|
||||
test('send with no providers enabled records history and returns success:false', async () => {
|
||||
const result = await nm.send('alert', { text: 'hello' });
|
||||
expect(result).toHaveProperty('results');
|
||||
expect(Array.isArray(result.results)).toBe(true);
|
||||
expect(nm.getHistory().length).toBe(1);
|
||||
expect(nm.getHistory()[0].event).toBe('alert');
|
||||
});
|
||||
|
||||
test('sendDiscord calls ctx.fetchT and returns success on 2xx', async () => {
|
||||
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||
const result = await nm.sendDiscord('msg', { title: 'T' });
|
||||
expect(result.success).toBe(true);
|
||||
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||
'https://hook.test/x',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
test('sendDiscord throws on non-2xx response', async () => {
|
||||
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: false, status: 500 });
|
||||
await expect(nm.sendDiscord('msg', null)).rejects.toThrow(/Discord/);
|
||||
});
|
||||
|
||||
test('sendTelegram calls Telegram API', async () => {
|
||||
nm.config.providers.telegram = { enabled: true, botToken: 'TOK', chatId: '123' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ json: () => Promise.resolve({ ok: true }) });
|
||||
const result = await nm.sendTelegram('hello');
|
||||
expect(result.success).toBe(true);
|
||||
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api.telegram.org'),
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
test('sendNtfy posts to the configured serverUrl + topic', async () => {
|
||||
nm.config.providers.ntfy = { enabled: true, topic: 'dashcaddy', serverUrl: 'https://ntfy.sh' };
|
||||
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||
const result = await nm.sendNtfy('body', 'title');
|
||||
expect(result.success).toBe(true);
|
||||
expect(nm.ctx.fetchT).toHaveBeenCalledWith(
|
||||
'https://ntfy.sh/dashcaddy',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
test('sendEmail uses nodemailer transporter', async () => {
|
||||
nm.config.providers.email = {
|
||||
enabled: true,
|
||||
host: 'smtp.test',
|
||||
port: 587,
|
||||
to: 'me@test',
|
||||
from: 'from@test',
|
||||
username: 'u',
|
||||
password: 'p',
|
||||
};
|
||||
const result = await nm.sendEmail('subject', 'body');
|
||||
expect(result.success).toBe(true);
|
||||
expect(nodemailer.createTransport).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('sendAlert, sendBackupComplete, sendServiceEvent do not throw', async () => {
|
||||
const alertResult = await nm.sendAlert({
|
||||
containerName: 'web',
|
||||
alerts: [{ type: 'cpu', severity: 'warning', message: 'high' }],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
expect(alertResult).toBeDefined();
|
||||
|
||||
const backupResult = await nm.sendBackupComplete({
|
||||
name: 'daily',
|
||||
status: 'success',
|
||||
});
|
||||
expect(backupResult).toBeDefined();
|
||||
|
||||
const serviceResult = await nm.sendServiceEvent('container-down', {
|
||||
name: 'web',
|
||||
containerName: 'sami-web',
|
||||
});
|
||||
expect(serviceResult).toBeDefined();
|
||||
});
|
||||
|
||||
test('checkHealth returns checked:false when no docker client', async () => {
|
||||
nm.ctx.docker = null;
|
||||
const r = await nm.checkHealth();
|
||||
expect(r.checked).toBe(false);
|
||||
});
|
||||
|
||||
test('checkHealth with mocked docker returns checked:true', async () => {
|
||||
nm.ctx.docker = {
|
||||
listContainers: jest.fn().mockResolvedValue([
|
||||
{ Id: 'aaaabbbbcccc', Names: ['/web'], State: 'running', Status: 'Up' },
|
||||
{ Id: 'ddddeeeeffff', Names: ['/api'], State: 'exited', Status: 'Exited' },
|
||||
]),
|
||||
};
|
||||
nm.config.healthCheck = { enabled: true, intervalMinutes: 5 };
|
||||
const r = await nm.checkHealth();
|
||||
expect(r.checked).toBe(true);
|
||||
expect(r.containersMonitored).toBe(2);
|
||||
});
|
||||
|
||||
test('formatTitle returns a string for known events', () => {
|
||||
expect(typeof nm._formatTitle('alert')).toBe('string');
|
||||
expect(typeof nm._formatTitle('unknown')).toBe('string');
|
||||
});
|
||||
|
||||
test('startHealthDaemon and stopHealthDaemon are idempotent', () => {
|
||||
nm.startHealthDaemon();
|
||||
nm.startHealthDaemon(); // should not double-schedule
|
||||
nm.stopHealthDaemon();
|
||||
nm.stopHealthDaemon();
|
||||
expect(nm.healthDaemonInterval).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../pagination');
|
||||
const { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT } = require('../src/utilities/pagination');
|
||||
|
||||
describe('Pagination — DashCaddy list endpoints', () => {
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ fs.unlinkSync.mockReturnValue(undefined);
|
||||
lockfile.lock.mockResolvedValue(jest.fn().mockResolvedValue());
|
||||
lockfile.check.mockResolvedValue(false);
|
||||
|
||||
const portLockManager = require('../port-lock-manager');
|
||||
const portLockManager = require('../src/managers/port-lock-manager');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -12,7 +12,7 @@ fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
|
||||
const resourceMonitor = require('../resource-monitor');
|
||||
const resourceMonitor = require('../src/managers/resource-monitor');
|
||||
|
||||
function makeStat(overrides = {}) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Integration tests for routes/auth/totp.js — the full TOTP auth flow.
|
||||
*
|
||||
* Covers the BACKLOG.md DC-006 acceptance criteria:
|
||||
* - no code → 400 (ValidationError)
|
||||
* - wrong code → 401 (AuthenticationError)
|
||||
* - valid TOTP → 200 + session cookie + CSRF token
|
||||
* - check-session with valid session → 200 { authenticated: true }
|
||||
* - check-session without session → 401 (AuthenticationError)
|
||||
*
|
||||
* Uses real otplib for code generation (so we exercise the actual TOTP math)
|
||||
* but mocks credentialManager, session, totpConfig, and saveTotpConfig —
|
||||
* because those modules own their own state machines (disk, cookies, file)
|
||||
* that don't belong in a routes-level test.
|
||||
*
|
||||
* NOTE: this test exercises the src/ refactored module layout (DC-005).
|
||||
* It depends on routes/auth/totp.js requiring ../../src/utilities/errors and
|
||||
* ../../src/utils/responses — fix the relative paths in totp.js if they
|
||||
* regress (see commit log for DC-006).
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const { authenticator } = require('otplib');
|
||||
|
||||
// Quiet otplib's "Unescaped left brace" warning on Node 20+
|
||||
const origWarn = console.warn;
|
||||
beforeAll(() => {
|
||||
console.warn = (...args) => {
|
||||
const msg = args.join(' ');
|
||||
if (msg.includes('Unescaped left brace')) return;
|
||||
origWarn.apply(console, args);
|
||||
};
|
||||
});
|
||||
afterAll(() => {
|
||||
console.warn = origWarn;
|
||||
});
|
||||
|
||||
// Minimal asyncHandler that catches errors into the express error chain
|
||||
function asyncHandler(fn) {
|
||||
return (req, res, _next) => Promise.resolve(fn(req, res, _next)).catch(_next);
|
||||
}
|
||||
|
||||
function createApp(depsOverride = {}) {
|
||||
// In-memory secret store so credentialManager stays deterministic
|
||||
const storedSecrets = new Map();
|
||||
const credentialManager = {
|
||||
store: jest.fn((key, value) => {
|
||||
storedSecrets.set(key, value);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
retrieve: jest.fn((key) => Promise.resolve(storedSecrets.has(key) ? storedSecrets.get(key) : null)),
|
||||
delete: jest.fn((key) => {
|
||||
storedSecrets.delete(key);
|
||||
return Promise.resolve(true);
|
||||
}),
|
||||
list: jest.fn(() => Promise.resolve(Array.from(storedSecrets.keys()))),
|
||||
};
|
||||
|
||||
// Mutable TOTP config — tests mutate this to model setup → enable → disable
|
||||
const totpConfig = {
|
||||
enabled: false,
|
||||
isSetUp: false,
|
||||
sessionDuration: '24h',
|
||||
secret: null, // matches main's optional backup-secret field
|
||||
};
|
||||
|
||||
// Mock session context mirroring src/context/session.js
|
||||
// isValid() is the knob — toggle it to test the auth-gate behavior
|
||||
const sessionStore = new Map(); // ip → { expiresAt }
|
||||
const session = {
|
||||
create: jest.fn((req, duration) => {
|
||||
const ip = session.getClientIP(req);
|
||||
sessionStore.set(ip, { expiresAt: Date.now() + (duration === 'never' ? Number.MAX_SAFE_INTEGER : 3600000) });
|
||||
}),
|
||||
setCookie: jest.fn(),
|
||||
clear: jest.fn((req) => {
|
||||
const ip = session.getClientIP(req);
|
||||
sessionStore.delete(ip);
|
||||
}),
|
||||
clearCookie: jest.fn(),
|
||||
isValid: jest.fn((req) => {
|
||||
const ip = session.getClientIP(req);
|
||||
const entry = sessionStore.get(ip);
|
||||
if (!entry) return false;
|
||||
return entry.expiresAt > Date.now();
|
||||
}),
|
||||
// Test helper — pretend an IP has a valid session, regardless of req.ip
|
||||
_grantSession: (ip = '127.0.0.1') => sessionStore.set(ip, { expiresAt: Date.now() + 3600000 }),
|
||||
getClientIP: jest.fn((req) => req.ip || req.connection?.remoteAddress || '127.0.0.1'),
|
||||
ipSessions: sessionStore,
|
||||
durations: { '1h': 3600000, '24h': 86400000, '7d': 604800000, 'never': 0 },
|
||||
};
|
||||
|
||||
const saveTotpConfig = jest.fn(() => Promise.resolve(true));
|
||||
const renewCSRFToken = jest.fn(() => 'mock-csrf-token');
|
||||
const log = {
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
};
|
||||
|
||||
const deps = {
|
||||
authManager: {}, // unused by totp.js but required by the factory signature
|
||||
credentialManager,
|
||||
totpConfig,
|
||||
saveTotpConfig,
|
||||
session,
|
||||
asyncHandler,
|
||||
errorResponse: jest.fn(),
|
||||
log,
|
||||
renewCSRFToken,
|
||||
...depsOverride,
|
||||
};
|
||||
|
||||
// Clear store between tests
|
||||
deps._resetStore = () => {
|
||||
storedSecrets.clear();
|
||||
sessionStore.clear();
|
||||
totpConfig.enabled = false;
|
||||
totpConfig.isSetUp = false;
|
||||
totpConfig.sessionDuration = '24h';
|
||||
delete totpConfig.secret;
|
||||
};
|
||||
|
||||
const totpRoutes = require('../../routes/auth/totp');
|
||||
const app = express();
|
||||
app.set('trust proxy', true); // so req.ip populates from X-Forwarded-For
|
||||
app.use(express.json());
|
||||
app.use('/api', totpRoutes(deps));
|
||||
// Express error handler — surface status from thrown AppError
|
||||
app.use((err, req, res, _next) => {
|
||||
const status = err.statusCode || 500;
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
});
|
||||
|
||||
return { app, deps };
|
||||
}
|
||||
|
||||
describe('TOTP Auth Routes — DC-006 Integration Test', () => {
|
||||
let app;
|
||||
let deps;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
({ app, deps } = createApp());
|
||||
authenticator.options = { window: 1 };
|
||||
});
|
||||
|
||||
// Helper: derive a fresh secret + a valid current TOTP code for it
|
||||
function freshSecret() {
|
||||
const secret = authenticator.generateSecret();
|
||||
const token = authenticator.generate(secret);
|
||||
return { secret, token };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// GET /api/totp/config
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('GET /api/totp/config', () => {
|
||||
it('returns current config (enabled=false, isSetUp=false by default)', async () => {
|
||||
const res = await request(app).get('/api/totp/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.config).toEqual({
|
||||
enabled: false,
|
||||
sessionDuration: '24h',
|
||||
isSetUp: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('reflects state changes after setup completes', async () => {
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.enabled = true;
|
||||
const res = await request(app).get('/api/totp/config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.isSetUp).toBe(true);
|
||||
expect(res.body.config.enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/setup
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/setup', () => {
|
||||
it('generates a fresh secret + QR code when none is provided', async () => {
|
||||
const res = await request(app).post('/api/totp/setup').send({});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.qrCode).toMatch(/^data:image\/png;base64,/);
|
||||
expect(res.body.manualKey).toMatch(/^[A-Z2-7]{16,}$/);
|
||||
expect(res.body.issuer).toBe('DashCaddy');
|
||||
expect(res.body.imported).toBe(false);
|
||||
// pending_secret should be stashed but totp.secret should NOT be active yet
|
||||
expect(deps.credentialManager.store).toHaveBeenCalledWith('totp.pending_secret', res.body.manualKey);
|
||||
expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts and normalizes a user-provided Base32 secret (0→O, 1→L, 8→B, lowercase→uppercase)', async () => {
|
||||
const raw = 'JBSWY3DPEHPK3PXP'; // canonical example
|
||||
const userInput = ' jbswy3dpehpk3pxp '; // spaces + lowercase
|
||||
const res = await request(app).post('/api/totp/setup').send({ secret: userInput });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.manualKey).toBe(raw);
|
||||
expect(res.body.imported).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an obviously invalid secret (wrong alphabet)', async () => {
|
||||
const res = await request(app).post('/api/totp/setup').send({ secret: 'NOT-VALID-BASE32!' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.success).toBe(false);
|
||||
expect(res.body.error).toMatch(/Invalid secret key format/);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/verify-setup (activates TOTP after setup)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/verify-setup', () => {
|
||||
it('returns 400 when code is missing or malformed', async () => {
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid code format/);
|
||||
});
|
||||
|
||||
it('returns 400 when no pending setup exists', async () => {
|
||||
const { token } = freshSecret();
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/No pending TOTP setup/);
|
||||
});
|
||||
|
||||
it('returns 401 when code is wrong', async () => {
|
||||
const { secret } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/DC-111/);
|
||||
});
|
||||
|
||||
it('returns 200 + activates TOTP + creates session on valid code', async () => {
|
||||
const { secret, token } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/TOTP enabled successfully/);
|
||||
|
||||
// TOTP config activated + persisted
|
||||
expect(deps.totpConfig.isSetUp).toBe(true);
|
||||
expect(deps.totpConfig.enabled).toBe(true);
|
||||
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||
|
||||
// pending_secret → totp.secret promotion, pending cleared
|
||||
expect(await deps.credentialManager.retrieve('totp.secret')).toBe(secret);
|
||||
expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
|
||||
|
||||
// Session established
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
// Note: renewCSRFToken is only called on /totp/verify (login), not /totp/verify-setup
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/verify (login flow — TOTP already configured)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/verify (login)', () => {
|
||||
async function setupTOTP() {
|
||||
const { secret, token } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
// Reset mocks but keep config/secret state for the test
|
||||
jest.clearAllMocks();
|
||||
return secret;
|
||||
}
|
||||
|
||||
it('returns 400 when code is missing', async () => {
|
||||
const res = await request(app).post('/api/totp/verify').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid code format/);
|
||||
});
|
||||
|
||||
it('returns 400 when TOTP is not enabled', async () => {
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: '123456' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/TOTP is not enabled/);
|
||||
});
|
||||
|
||||
it('returns 401 when code is wrong (TOTP active)', async () => {
|
||||
await setupTOTP();
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/DC-111/);
|
||||
});
|
||||
|
||||
it('returns 200 + creates new session + rotates CSRF on valid code (BACKLOG: "valid TOTP → session token → authenticated request succeeds")', async () => {
|
||||
const secret = await setupTOTP();
|
||||
const token = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/verify').send({ code: token });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.message).toMatch(/Authenticated successfully/);
|
||||
expect(res.body.csrfToken).toBe('mock-csrf-token');
|
||||
expect(deps.session.create).toHaveBeenCalled();
|
||||
expect(deps.session.setCookie).toHaveBeenCalled();
|
||||
expect(deps.renewCSRFToken).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// GET /api/totp/check-session (the auth gate Caddy calls)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('GET /api/totp/check-session', () => {
|
||||
it('always returns 200 when TOTP is not enabled (passthrough)', async () => {
|
||||
const res = await request(app).get('/api/totp/check-session');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it('always returns 200 when sessionDuration is "never" (passthrough)', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.sessionDuration = 'never';
|
||||
const res = await request(app).get('/api/totp/check-session');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it('returns 401 when no session exists (BACKLOG: "no token → 401")', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.sessionDuration = '24h';
|
||||
// session.isValid returns false because sessionStore is empty
|
||||
const res = await request(app).get('/api/totp/check-session');
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/Session expired or invalid/);
|
||||
// Cache-control headers must be set to avoid Caddy auth loops
|
||||
expect(res.headers['cache-control']).toMatch(/no-store/);
|
||||
});
|
||||
|
||||
it('returns 200 { authenticated: true } when session is valid (BACKLOG: "authenticated request succeeds")', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
deps.totpConfig.sessionDuration = '24h';
|
||||
// Pre-populate the session store as if verify already ran
|
||||
deps.session._grantSession('127.0.0.1');
|
||||
const res = await request(app).get('/api/totp/check-session').set('X-Forwarded-For', '127.0.0.1');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ authenticated: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/disable
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/disable', () => {
|
||||
async function setupTOTP() {
|
||||
const { secret, token } = freshSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
await request(app).post('/api/totp/verify-setup').send({ code: token });
|
||||
jest.clearAllMocks();
|
||||
return secret;
|
||||
}
|
||||
|
||||
it('returns 400 when TOTP is active but no code is provided', async () => {
|
||||
await setupTOTP();
|
||||
const res = await request(app).post('/api/totp/disable').send({});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/valid TOTP code is required/);
|
||||
});
|
||||
|
||||
it('returns 401 when code is wrong', async () => {
|
||||
await setupTOTP();
|
||||
const res = await request(app).post('/api/totp/disable').send({ code: '000000' });
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.error).toMatch(/DC-111/);
|
||||
});
|
||||
|
||||
it('returns 200 + clears TOTP state on valid code', async () => {
|
||||
const secret = await setupTOTP();
|
||||
const code = authenticator.generate(secret);
|
||||
const res = await request(app).post('/api/totp/disable').send({ code });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
// TOTP disabled, secrets cleared, session cleared
|
||||
expect(deps.totpConfig.enabled).toBe(false);
|
||||
expect(deps.totpConfig.isSetUp).toBe(false);
|
||||
expect(deps.totpConfig.sessionDuration).toBe('never');
|
||||
expect(await deps.credentialManager.retrieve('totp.secret')).toBeNull();
|
||||
expect(await deps.credentialManager.retrieve('totp.pending_secret')).toBeNull();
|
||||
expect(deps.session.clear).toHaveBeenCalled();
|
||||
expect(deps.session.clearCookie).toHaveBeenCalled();
|
||||
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// POST /api/totp/config (session duration change)
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('POST /api/totp/config (update settings)', () => {
|
||||
it('updates sessionDuration with a valid value', async () => {
|
||||
const res = await request(app).post('/api/totp/config').send({ sessionDuration: '7d' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.config.sessionDuration).toBe('7d');
|
||||
expect(deps.saveTotpConfig).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an invalid sessionDuration', async () => {
|
||||
const res = await request(app).post('/api/totp/config').send({ sessionDuration: '99y' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/Invalid session duration/);
|
||||
});
|
||||
|
||||
it('setting sessionDuration to "never" disables TOTP', async () => {
|
||||
deps.totpConfig.enabled = true;
|
||||
deps.totpConfig.isSetUp = true;
|
||||
const res = await request(app).post('/api/totp/config').send({ sessionDuration: 'never' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(deps.totpConfig.sessionDuration).toBe('never');
|
||||
expect(deps.totpConfig.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// End-to-end flow (BACKLOG: "Cover the full /api/auth/check → session → endpoint flow")
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
describe('End-to-end: setup → login → check-session → disable', () => {
|
||||
it('walks the full BACKLOG DC-006 flow', async () => {
|
||||
// 1. Setup — generate a fresh secret
|
||||
const setupRes = await request(app).post('/api/totp/setup').send({});
|
||||
expect(setupRes.status).toBe(200);
|
||||
const secret = setupRes.body.manualKey;
|
||||
const setupCode = authenticator.generate(secret);
|
||||
|
||||
// 2. Verify-setup — activate TOTP
|
||||
const verifySetupRes = await request(app).post('/api/totp/verify-setup').send({ code: setupCode });
|
||||
expect(verifySetupRes.status).toBe(200);
|
||||
expect(deps.totpConfig.isSetUp).toBe(true);
|
||||
|
||||
// 3. Simulate session expiry by clearing the store
|
||||
deps.session.ipSessions.clear();
|
||||
|
||||
// 4. Re-login via /totp/verify (the "login" path)
|
||||
const loginCode = authenticator.generate(secret);
|
||||
const loginRes = await request(app).post('/api/totp/verify').send({ code: loginCode });
|
||||
expect(loginRes.status).toBe(200);
|
||||
expect(loginRes.body.csrfToken).toBeDefined();
|
||||
|
||||
// 5. Check-session — should now be authenticated (the BACKLOG "→ endpoint succeeds" step)
|
||||
const checkRes = await request(app).get('/api/totp/check-session');
|
||||
expect(checkRes.status).toBe(200);
|
||||
expect(checkRes.body).toEqual({ authenticated: true });
|
||||
|
||||
// 6. Logout / disable
|
||||
const disableCode = authenticator.generate(secret);
|
||||
const disableRes = await request(app).post('/api/totp/disable').send({ code: disableCode });
|
||||
expect(disableRes.status).toBe(200);
|
||||
|
||||
// 7. After disable, check-session should be passthrough (TOTP off)
|
||||
const afterRes = await request(app).get('/api/totp/check-session');
|
||||
expect(afterRes.status).toBe(200);
|
||||
expect(afterRes.body).toEqual({ authenticated: true });
|
||||
});
|
||||
|
||||
it('proves otplib is real (not stubbed) by using a totally bogus code', async () => {
|
||||
// Sanity check that the test harness is using real otplib, not a stub.
|
||||
// otplib 12.0.1's authenticator.generate(secret) does not accept a {time} option
|
||||
// (the signature is fixed to current-time TOTP), so a "stale code" test isn't
|
||||
// reproducible across runs. Instead, we verify otplib rejects a code that is
|
||||
// syntactically valid (6 digits) but doesn't match the live TOTP slot.
|
||||
const secret = authenticator.generateSecret();
|
||||
await request(app).post('/api/totp/setup').send({ secret });
|
||||
// Generate the real current code, then mutate it — must be rejected
|
||||
const realCode = authenticator.generate(secret);
|
||||
const tampered = realCode === '000000' ? '111111' : '000000';
|
||||
const res = await request(app).post('/api/totp/verify-setup').send({ code: tampered });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ function buildApp(mockDeps) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
const { errorMiddleware } = require('../../error-handler');
|
||||
const { errorMiddleware } = require('../../src/utilities/error-handler');
|
||||
const containersRouteFactory = require('../../routes/containers');
|
||||
app.use('/api/containers', containersRouteFactory(mockDeps));
|
||||
app.use(errorMiddleware);
|
||||
|
||||
@@ -52,21 +52,21 @@ jest.mock('../../platform-paths', () => ({
|
||||
}));
|
||||
|
||||
// Mock fs-helpers.exists
|
||||
jest.mock('../../fs-helpers', () => ({
|
||||
jest.mock('../../src/utilities/fs-helpers', () => ({
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../url-resolver', () => ({
|
||||
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||
}));
|
||||
|
||||
jest.mock('../../pagination', () => ({
|
||||
jest.mock('../../src/utilities/pagination', () => ({
|
||||
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||
parsePaginationParams: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const { exists } = require('../../fs-helpers');
|
||||
const { resolveServiceUrl } = require('../../url-resolver');
|
||||
const { exists } = require('../../src/utilities/fs-helpers');
|
||||
const { resolveServiceUrl } = require('../../src/utilities/url-resolver');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
describe('Health Routes', () => {
|
||||
@@ -538,7 +538,7 @@ describe('Health Routes', () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('healthy');
|
||||
expect(res.body.caStatus).toBe('healthy');
|
||||
expect(res.body.daysUntilExpiration).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
@@ -551,7 +551,7 @@ describe('Health Routes', () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('warning');
|
||||
expect(res.body.caStatus).toBe('warning');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(90);
|
||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(30);
|
||||
});
|
||||
@@ -565,7 +565,7 @@ describe('Health Routes', () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('critical');
|
||||
expect(res.body.caStatus).toBe('critical');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(30);
|
||||
expect(res.body.daysUntilExpiration).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
@@ -579,7 +579,7 @@ describe('Health Routes', () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('critical');
|
||||
expect(res.body.caStatus).toBe('critical');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(7);
|
||||
});
|
||||
|
||||
@@ -592,7 +592,7 @@ describe('Health Routes', () => {
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('critical');
|
||||
expect(res.body.caStatus).toBe('critical');
|
||||
expect(res.body.daysUntilExpiration).toBeLessThan(0);
|
||||
expect(res.body.message).toMatch(/EXPIRED/);
|
||||
});
|
||||
@@ -601,9 +601,9 @@ describe('Health Routes', () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('error');
|
||||
expect(res.body.message).toMatch(/not found/);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body.caStatus).toBe('error');
|
||||
expect(res.body.error).toMatch(/not found/);
|
||||
expect(res.body.daysUntilExpiration).toBeNull();
|
||||
});
|
||||
|
||||
@@ -612,9 +612,9 @@ describe('Health Routes', () => {
|
||||
execSync.mockImplementation(() => { throw new Error('openssl not found'); });
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/health/ca');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('error');
|
||||
expect(res.body.message).toBe('openssl not found');
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.caStatus).toBe('error');
|
||||
expect(res.body.error).toBe('openssl not found');
|
||||
expect(res.body.daysUntilExpiration).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,32 +9,32 @@ function asyncHandler(fn) {
|
||||
}
|
||||
|
||||
// Mock modules that services.js requires at top-level
|
||||
jest.mock('../../constants', () => ({
|
||||
jest.mock('../../src/utilities/constants', () => ({
|
||||
APP: { USER_AGENTS: { PROBE: 'DashCaddy/1.0' } },
|
||||
REGEX: { SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/ },
|
||||
TIMEOUTS: { DEFAULT: 10000 },
|
||||
HTTP_STATUS: { OK: 200, CREATED: 201, NO_CONTENT: 204, BAD_REQUEST: 400, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, CONFLICT: 409, INTERNAL_ERROR: 500 }
|
||||
}));
|
||||
|
||||
jest.mock('../../input-validator', () => ({
|
||||
jest.mock('../../src/security/input-validator', () => ({
|
||||
validateServiceConfig: jest.fn(),
|
||||
isValidPort: jest.fn(p => p >= 1 && p <= 65535),
|
||||
}));
|
||||
|
||||
jest.mock('../../fs-helpers', () => ({
|
||||
jest.mock('../../src/utilities/fs-helpers', () => ({
|
||||
exists: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('../../url-resolver', () => ({
|
||||
jest.mock('../../src/utilities/url-resolver', () => ({
|
||||
resolveServiceUrl: jest.fn((id) => `https://${id}.test`),
|
||||
}));
|
||||
|
||||
jest.mock('../../pagination', () => ({
|
||||
jest.mock('../../src/utilities/pagination', () => ({
|
||||
paginate: jest.fn((data, params) => ({ data, pagination: null })),
|
||||
parsePaginationParams: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
jest.mock('../../response-helpers', () => ({
|
||||
jest.mock('../../src/utils/responses', () => ({
|
||||
success: jest.fn((res, data, statusCode = 200) => {
|
||||
return res.status(statusCode).json({ success: true, ...data });
|
||||
}),
|
||||
@@ -45,8 +45,8 @@ jest.mock('../../response-helpers', () => ({
|
||||
|
||||
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
||||
|
||||
const { exists } = require('../../fs-helpers');
|
||||
const { validateServiceConfig } = require('../../input-validator');
|
||||
const { exists } = require('../../src/utilities/fs-helpers');
|
||||
const { validateServiceConfig } = require('../../src/security/input-validator');
|
||||
|
||||
function createApp(depsOverride = {}) {
|
||||
const defaultDeps = {
|
||||
@@ -103,12 +103,12 @@ describe('Services Routes', () => {
|
||||
});
|
||||
|
||||
describe('GET /api/services', () => {
|
||||
it('returns empty array when no services file', async () => {
|
||||
it('returns empty services array (enveloped) when no services file', async () => {
|
||||
exists.mockResolvedValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app).get('/api/services');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
expect(res.body).toEqual({ success: true, services: [] });
|
||||
});
|
||||
|
||||
it('returns services list', async () => {
|
||||
@@ -450,7 +450,7 @@ describe('Services Routes', () => {
|
||||
});
|
||||
|
||||
it('rejects invalid port', async () => {
|
||||
const { isValidPort } = require('../../input-validator');
|
||||
const { isValidPort } = require('../../src/security/input-validator');
|
||||
isValidPort.mockReturnValue(false);
|
||||
const { app } = createApp();
|
||||
const res = await request(app)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Smoke tests for ssl-monitor.js
|
||||
* Verifies SSLMonitor loads, exposes the expected interface, can check
|
||||
* certificates via mocked TLS, manage state, and persist cache.
|
||||
*/
|
||||
|
||||
jest.mock('tls', () => ({
|
||||
connect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../src/utilities/fs-helpers', () => ({
|
||||
readJsonFile: jest.fn().mockResolvedValue(null),
|
||||
writeJsonFile: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const tls = require('tls');
|
||||
const fsHelpers = require('../src/utilities/fs-helpers');
|
||||
const SSLMonitor = require('../src/monitoring/ssl-monitor');
|
||||
|
||||
function makeSocket({ cert = null, error = null } = {}) {
|
||||
const { EventEmitter } = require('events');
|
||||
const socket = new EventEmitter();
|
||||
socket.destroy = jest.fn();
|
||||
socket.getPeerCertificate = jest.fn(() => cert);
|
||||
socket.setTimeout = jest.fn();
|
||||
|
||||
// Simulate 'connect' on next tick (or 'error')
|
||||
process.nextTick(() => {
|
||||
if (error) socket.emit('error', error);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
describe('SSLMonitor', () => {
|
||||
let monitor;
|
||||
const fakeStateManager = {
|
||||
read: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
fsHelpers.readJsonFile.mockResolvedValue(null);
|
||||
fsHelpers.writeJsonFile.mockResolvedValue(undefined);
|
||||
fakeStateManager.read.mockResolvedValue([]);
|
||||
|
||||
monitor = new SSLMonitor({
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
servicesStateManager: fakeStateManager,
|
||||
siteConfig: {},
|
||||
buildServiceUrl: id => `https://${id}.sami`,
|
||||
notification: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
test('initializes with empty maps and default config', () => {
|
||||
expect(monitor.certStatus).toBeInstanceOf(Map);
|
||||
expect(monitor.notifiedThresholds).toBeInstanceOf(Map);
|
||||
expect(monitor.hostnameToServiceId).toBeInstanceOf(Map);
|
||||
expect(monitor.intervalHandle).toBeNull();
|
||||
expect(monitor.config.enabled).toBe(true);
|
||||
expect(typeof monitor.config.intervalMs).toBe('number');
|
||||
});
|
||||
|
||||
test('getConfig returns a copy of the current config', () => {
|
||||
const cfg = monitor.getConfig();
|
||||
expect(cfg).toEqual(monitor.config);
|
||||
cfg.enabled = false;
|
||||
// The internal config must not be mutated
|
||||
expect(monitor.config.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test('updateConfig updates enabled and intervalMs', () => {
|
||||
monitor.updateConfig({ enabled: false, intervalMs: 60000 });
|
||||
expect(monitor.config.enabled).toBe(false);
|
||||
expect(monitor.config.intervalMs).toBe(60000);
|
||||
});
|
||||
|
||||
test('updateConfig rejects intervalMs below 60000', () => {
|
||||
const original = monitor.config.intervalMs;
|
||||
monitor.updateConfig({ intervalMs: 1000 });
|
||||
expect(monitor.config.intervalMs).toBe(original);
|
||||
});
|
||||
|
||||
test('getStatus returns an empty object when no checks have run', () => {
|
||||
expect(monitor.getStatus()).toEqual({});
|
||||
});
|
||||
|
||||
test('getServiceCertStatus returns null for unknown service', () => {
|
||||
expect(monitor.getServiceCertStatus('unknown-svc')).toBeNull();
|
||||
});
|
||||
|
||||
test('checkCert rejects when peer cert is empty', async () => {
|
||||
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||
const sock = makeSocket({ cert: {} });
|
||||
// Simulate immediate 'connect'
|
||||
setImmediate(() => onConnect && onConnect());
|
||||
return sock;
|
||||
});
|
||||
|
||||
await expect(monitor.checkCert('empty.sami')).rejects.toThrow(/No certificate/);
|
||||
});
|
||||
|
||||
test('checkCert resolves with cert details on success', async () => {
|
||||
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000); // +60d
|
||||
const validTo = futureDate.toUTCString();
|
||||
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||
const sock = makeSocket({
|
||||
cert: {
|
||||
subject: { CN: 'test.sami' },
|
||||
issuer: { O: "Sami's CA" },
|
||||
valid_from: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toUTCString(),
|
||||
valid_to: validTo,
|
||||
fingerprint: 'AA:BB:CC',
|
||||
},
|
||||
});
|
||||
setImmediate(() => onConnect && onConnect());
|
||||
return sock;
|
||||
});
|
||||
|
||||
const result = await monitor.checkCert('test.sami', 443);
|
||||
expect(result.hostname).toBe('test.sami');
|
||||
expect(result.port).toBe(443);
|
||||
expect(result.subject).toBe('test.sami');
|
||||
expect(result.daysRemaining).toBeGreaterThan(0);
|
||||
expect(typeof result.isExpiring).toBe('boolean');
|
||||
expect(typeof result.checkedAt).toBe('string');
|
||||
});
|
||||
|
||||
test('checkCert rejects with TLS error event', async () => {
|
||||
tls.connect.mockImplementation(() => {
|
||||
const sock = makeSocket({ error: new Error('TLS boom') });
|
||||
return sock;
|
||||
});
|
||||
await expect(monitor.checkCert('broken.sami')).rejects.toThrow(/TLS/);
|
||||
});
|
||||
|
||||
test('checkAll returns empty status when no services configured', async () => {
|
||||
const status = await monitor.checkAll();
|
||||
expect(status).toEqual({});
|
||||
});
|
||||
|
||||
test('checkAll handles HTTPS services and stores results', async () => {
|
||||
fakeStateManager.read.mockResolvedValue([
|
||||
{ id: 'web', name: 'Web', url: 'https://web.sami' },
|
||||
]);
|
||||
const futureDate = new Date(Date.now() + 60 * 24 * 60 * 60 * 1000);
|
||||
tls.connect.mockImplementation((_opts, onConnect) => {
|
||||
const sock = makeSocket({
|
||||
cert: {
|
||||
subject: { CN: 'web.sami' },
|
||||
issuer: { O: "Sami's CA" },
|
||||
valid_from: new Date().toUTCString(),
|
||||
valid_to: futureDate.toUTCString(),
|
||||
fingerprint: 'AA:BB:CC',
|
||||
},
|
||||
});
|
||||
setImmediate(() => onConnect && onConnect());
|
||||
return sock;
|
||||
});
|
||||
|
||||
const status = await monitor.checkAll();
|
||||
expect(status['web.sami']).toBeDefined();
|
||||
expect(status['web.sami'].hostname).toBe('web.sami');
|
||||
expect(monitor.getServiceCertStatus('web')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('start() schedules periodic checks and stop() clears them', () => {
|
||||
jest.useFakeTimers();
|
||||
const originalCheckAll = monitor.checkAll.bind(monitor);
|
||||
monitor.checkAll = jest.fn().mockResolvedValue(undefined);
|
||||
monitor.start(120000);
|
||||
expect(monitor.intervalHandle).not.toBeNull();
|
||||
monitor.stop();
|
||||
expect(monitor.intervalHandle).toBeNull();
|
||||
monitor.checkAll = originalCheckAll;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('_saveCache and _loadCache round-trip via fs-helpers', async () => {
|
||||
await monitor._saveCache();
|
||||
expect(fsHelpers.writeJsonFile).toHaveBeenCalled();
|
||||
|
||||
fsHelpers.readJsonFile.mockResolvedValue({
|
||||
lastChecked: new Date().toISOString(),
|
||||
certs: { 'a.sami': { hostname: 'a.sami', daysRemaining: 30 } },
|
||||
hostnameToServiceId: { 'a.sami': 'svc-a' },
|
||||
});
|
||||
const fresh = new SSLMonitor({
|
||||
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
servicesStateManager: fakeStateManager,
|
||||
siteConfig: {},
|
||||
buildServiceUrl: id => `https://${id}.sami`,
|
||||
});
|
||||
await fresh._loadCache();
|
||||
expect(fresh.certStatus.get('a.sami')).toBeDefined();
|
||||
expect(fresh.hostnameToServiceId.get('a.sami')).toBe('svc-a');
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ jest.mock('fs', () => ({
|
||||
|
||||
const lockfile = require('proper-lockfile');
|
||||
const fs = require('fs');
|
||||
const StateManager = require('../state-manager');
|
||||
const StateManager = require('../src/managers/state-manager');
|
||||
|
||||
describe('StateManager', () => {
|
||||
let sm;
|
||||
|
||||
@@ -22,7 +22,7 @@ fs.existsSync.mockReturnValue(false);
|
||||
fs.readFileSync.mockReturnValue('{}');
|
||||
fs.writeFileSync.mockReturnValue(undefined);
|
||||
|
||||
const updateManager = require('../update-manager');
|
||||
const updateManager = require('../src/managers/update-manager');
|
||||
|
||||
// Helper to create a fake https request that responds with a given statusCode/headers/body
|
||||
function mockHttpsResponse({ statusCode = 200, headers = {}, body = '' } = {}) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { resolveServiceUrl } = require('../url-resolver');
|
||||
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||
|
||||
describe('URL Resolver — DashCaddy service URL resolution', () => {
|
||||
const buildServiceUrl = jest.fn(id => `https://${id}.sami`);
|
||||
|
||||
Reference in New Issue
Block a user