Files
dashcaddy/dashcaddy-api/__tests__/update-manager.test.js
T
Hermes a4e4b24732
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
fix(update-manager): force IPv4 + per-request timeout + transient-only retry on registry digest probes (DC-078) [glm-grade=A]
The per-hour checkForUpdates() loop called Docker Hub / ghcr.io without
family:4, without a hard request timeout, and without retry on transient
network errors. On DNS2 (Technitium at 100.121.150.22 returns AAAA records
even when IPv6 routing to public registries is intermittently broken), every
container check surfaced AggregateError [ETIMEDOUT] in error.log with stack
`at internalConnectMultiple (node:net:1114:18)`. The dual-stack DNS race
consumed the default 30s connect timeout per unreachable IPv6 family before
falling back to IPv4 — 30s+ per container per check cycle.

Three reliability properties added via shared fetchWithReliability() helper:
1. family:4 — IPv4-only DNS lookup. Avoids the dual-stack race entirely.
2. Hard per-request timeout (10s) — caps total latency per attempt.
3. Retry on transient codes only (ETIMEDOUT/ENOTFOUND/ENETUNREACH/...) — HTTP
   4xx/5xx are surfaced as real responses, not retried.

The 401 → WWW-Authenticate → token → Bearer auth flow is now explicit in
getDockerHubDigest (was previously a side effect of authenticateAndGetDigest,
which has been removed — no remaining callers).

Verified end-to-end against real Docker Hub:
- linuxserver/plex:latest → real digest in 1349ms (was 30s+ AggregateError)
- 5-container checkForUpdates() cycle: 3.6s total (was 150s+)
- 86/86 update-manager tests pass; 2343/2343 full suite (4 pre-existing
  pdfkit module-resolution failures unrelated to this change)
2026-08-18 17:35:59 -07:00

1324 lines
48 KiB
JavaScript

// Update Manager Tests
// Validates Docker image update detection, scheduling, rollback, and auto-update
const mockDockerInstance = {
listContainers: jest.fn(),
getContainer: jest.fn(),
getImage: jest.fn(),
pull: jest.fn(),
createContainer: jest.fn(),
modem: { followProgress: jest.fn() }
};
jest.mock('dockerode', () => jest.fn(() => mockDockerInstance));
jest.mock('fs');
jest.mock('https');
const fs = require('fs');
const https = require('https');
// Setup defaults BEFORE requiring singleton
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockReturnValue('{}');
fs.writeFileSync.mockReturnValue(undefined);
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 = '' } = {}) {
return (options, cb) => {
const res = {
statusCode,
headers,
on: jest.fn((event, handler) => {
if (event === 'data' && body) handler(Buffer.from(body));
if (event === 'end') handler();
})
};
if (cb) setImmediate(() => cb(res));
return {
on: jest.fn(),
end: jest.fn(),
setTimeout: jest.fn(),
destroy: jest.fn()
};
};
}
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
fs.existsSync.mockReturnValue(false);
fs.readFileSync.mockReturnValue('{}');
fs.writeFileSync.mockReturnValue(undefined);
// Reset docker mocks
mockDockerInstance.listContainers.mockReset();
mockDockerInstance.getContainer.mockReset();
mockDockerInstance.getImage.mockReset();
mockDockerInstance.pull.mockReset();
mockDockerInstance.createContainer.mockReset();
mockDockerInstance.modem.followProgress.mockReset();
// Reset internal state
updateManager.history = [];
updateManager.config = { autoUpdate: {} };
updateManager.availableUpdates.clear();
updateManager.checking = false;
if (updateManager.checkInterval) {
clearInterval(updateManager.checkInterval);
updateManager.checkInterval = null;
}
if (updateManager.autoUpdateInterval) {
clearInterval(updateManager.autoUpdateInterval);
updateManager.autoUpdateInterval = null;
}
});
afterEach(() => {
updateManager.checking = false;
if (updateManager.checkInterval) { clearInterval(updateManager.checkInterval); updateManager.checkInterval = null; }
if (updateManager.autoUpdateInterval) { clearInterval(updateManager.autoUpdateInterval); updateManager.autoUpdateInterval = null; }
jest.useRealTimers();
});
describe('UpdateManager — Docker image update lifecycle', () => {
describe('start/stop lifecycle', () => {
it('starts update checking', () => {
updateManager.start();
expect(updateManager.checking).toBe(true);
});
it('ignores double start', () => {
updateManager.start();
updateManager.start();
expect(updateManager.checking).toBe(true);
});
it('stops checking and clears intervals', () => {
updateManager.start();
updateManager.stop();
expect(updateManager.checking).toBe(false);
expect(updateManager.checkInterval).toBeNull();
expect(updateManager.autoUpdateInterval).toBeNull();
});
it('ignores stop when not checking', () => {
updateManager.stop();
expect(updateManager.checking).toBe(false);
});
});
describe('extractTag', () => {
it('extracts tag from image:tag', () => {
expect(updateManager.extractTag('nginx:1.25')).toBe('1.25');
});
it('returns latest for untagged images', () => {
expect(updateManager.extractTag('nginx')).toBe('latest');
});
it('handles LinuxServer images', () => {
expect(updateManager.extractTag('lscr.io/linuxserver/plex:latest')).toBe('latest');
});
});
// ─── DC-078: registry digest probe reliability hardening ──────────────────
// Verifies that getLatestImageDigest / getDockerHubDigest / getGhcrDigest /
// fetchWithReliability all apply the IPv4-only + timeout + transient-retry
// policy. Without these guards, the per-hour checkForUpdates() loop on DNS2
// surfaces AggregateError [ETIMEDOUT] in error.log because the container's
// /etc/resolv.conf returns AAAA records from Technitium whose IPv6 path to
// public registries (Docker Hub, ghcr.io) is intermittently unreachable.
describe('DC-078 registry reliability', () => {
// Use real timers — fetchWithReliability's retry uses setTimeout for
// backoff, which jest's fake timers would block indefinitely.
beforeEach(() => {
jest.useRealTimers();
});
afterEach(() => {
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
});
it('_httpsRequestOnce sets family: 4 and timeout on the request options', async () => {
let capturedOptions = null;
const req = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};
https.request.mockImplementation((options, cb) => {
capturedOptions = options;
// Return a 200 immediately so the promise resolves cleanly.
const res = {
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return req;
});
await updateManager._httpsRequestOnce({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
headers: { Accept: 'application/vnd.docker.distribution.manifest.v2+json' },
maxBodyBytes: 65536,
});
expect(capturedOptions).not.toBeNull();
expect(capturedOptions.family).toBe(4);
expect(capturedOptions.timeout).toBeGreaterThan(0);
expect(capturedOptions.method).toBe('GET');
});
it('fetchWithReliability retries on transient ETIMEDOUT and eventually succeeds', async () => {
let attempts = 0;
https.request.mockImplementation((options, cb) => {
attempts += 1;
if (attempts === 1) {
// First attempt: emit ETIMEDOUT via the request 'error' event
const reqErr = new Error('request timeout');
reqErr.code = 'ETIMEDOUT';
const req = {
on: jest.fn((event, handler) => {
if (event === 'error') setImmediate(() => handler(reqErr));
}),
end: jest.fn(),
destroy: jest.fn(),
};
return req;
}
// Second attempt: 200 OK with a digest header
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:abc123def456' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
});
expect(attempts).toBe(2);
expect(result.statusCode).toBe(200);
expect(result.headers['docker-content-digest']).toBe('sha256:abc123def456');
});
it('fetchWithReliability does NOT retry on non-transient HTTP errors', async () => {
let attempts = 0;
https.request.mockImplementation((options, cb) => {
attempts += 1;
const res = {
statusCode: 500,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
});
expect(attempts).toBe(1);
expect(result.statusCode).toBe(500);
});
it('fetchWithReliability retries up to REGISTRY_MAX_RETRIES then throws', async () => {
let attempts = 0;
https.request.mockImplementation(() => {
attempts += 1;
const reqErr = new Error('connect ETIMEDOUT');
reqErr.code = 'ETIMEDOUT';
const req = {
on: jest.fn((event, handler) => {
if (event === 'error') setImmediate(() => handler(reqErr));
}),
end: jest.fn(),
destroy: jest.fn(),
};
return req;
});
await expect(updateManager.fetchWithReliability({
hostname: 'registry-1.docker.io',
path: '/v2/library/nginx/manifests/latest',
})).rejects.toMatchObject({ code: 'ETIMEDOUT' });
// 1 initial attempt + REGISTRY_MAX_RETRIES retries
expect(attempts).toBe(1 + 1);
});
it('getDockerHubDigest returns digest on 200', async () => {
https.request.mockImplementation((options, cb) => {
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:hubdigest9999' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
expect(digest).toBe('sha256:hubdigest9999');
});
it('getDockerHubDigest acquires bearer token on 401 then returns digest', async () => {
let calls = 0;
https.request.mockImplementation((options, cb) => {
calls += 1;
if (calls === 1) {
// First call to registry-1.docker.io returns 401 with WWW-Authenticate
const res = {
statusCode: 401,
headers: {
'www-authenticate': 'Bearer realm="https://auth.example.com/token",service="registry.docker.io",scope="repository:library/nginx:pull"',
},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
} else if (calls === 2) {
// Second call: auth.example.com returns the token JSON
const res = {
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') handler(Buffer.from(JSON.stringify({ token: 'jwt-token-xyz' })));
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
} else {
// Third call: registry-1.docker.io with Bearer header returns the digest
expect(options.headers['Authorization']).toBe('Bearer jwt-token-xyz');
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:autheddigest7777' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
}
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const digest = await updateManager.getDockerHubDigest('nginx', 'latest');
expect(digest).toBe('sha256:autheddigest7777');
expect(calls).toBe(3);
});
it('getGhcrDigest returns digest on 200', async () => {
https.request.mockImplementation((options, cb) => {
expect(options.hostname).toBe('ghcr.io');
const res = {
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:ghcrdigest1234' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
}),
};
setImmediate(() => cb(res));
return { on: jest.fn(), end: jest.fn(), destroy: jest.fn() };
});
const digest = await updateManager.getGhcrDigest('ghcr.io/some/repo', 'latest');
expect(digest).toBe('sha256:ghcrdigest1234');
});
it('getLatestImageDigest returns null on transient errors after retries (registry unavailable)', async () => {
// Simulate a totally-down registry: every attempt fails with ETIMEDOUT.
// After REGISTRY_MAX_RETRIES the error propagates to getLatestImageDigest's
// catch arm, which logs and returns null (matches old behavior).
https.request.mockImplementation(() => {
const reqErr = new Error('connect ETIMEDOUT');
reqErr.code = 'ETIMEDOUT';
const req = {
on: jest.fn((event, handler) => {
if (event === 'error') setImmediate(() => handler(reqErr));
}),
end: jest.fn(),
destroy: jest.fn(),
};
return req;
});
const digest = await updateManager.getLatestImageDigest('nginx:latest');
expect(digest).toBeNull();
});
});
describe('parseAuthHeader', () => {
it('parses Docker Hub Bearer auth header', () => {
const header = 'Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull"';
const result = updateManager.parseAuthHeader(header);
expect(result).toContain('https://auth.docker.io/token');
expect(result).toContain('service=registry.docker.io');
expect(result).toContain('scope=repository');
});
it('returns null for missing header', () => {
expect(updateManager.parseAuthHeader(null)).toBeNull();
});
it('returns null for non-Bearer header', () => {
expect(updateManager.parseAuthHeader('Basic realm="test"')).toBeNull();
});
});
describe('extractPorts', () => {
it('extracts host port mappings from inspect data', () => {
const inspect = {
NetworkSettings: {
Ports: {
'32400/tcp': [{ HostIp: '0.0.0.0', HostPort: '32400' }],
'1900/udp': [{ HostIp: '0.0.0.0', HostPort: '1900' }]
}
}
};
const ports = updateManager.extractPorts(inspect);
expect(ports).toHaveLength(2);
expect(ports[0]).toEqual({ containerPort: '32400', hostPort: '32400', protocol: 'tcp' });
expect(ports[1]).toEqual({ containerPort: '1900', hostPort: '1900', protocol: 'udp' });
});
it('returns empty for no port bindings', () => {
const inspect = { NetworkSettings: { Ports: { '8080/tcp': null } } };
expect(updateManager.extractPorts(inspect)).toEqual([]);
});
it('returns empty for missing NetworkSettings', () => {
expect(updateManager.extractPorts({})).toEqual([]);
});
});
describe('history management', () => {
it('addToHistory appends and saves', () => {
updateManager.addToHistory({ id: 'update-1', status: 'success' });
expect(updateManager.history).toHaveLength(1);
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('caps history at 100 entries', () => {
for (let i = 0; i < 110; i++) {
updateManager.addToHistory({ id: `update-${i}` });
}
expect(updateManager.history).toHaveLength(100);
});
it('getHistory returns newest first', () => {
updateManager.addToHistory({ id: 'old' });
updateManager.addToHistory({ id: 'new' });
const history = updateManager.getHistory();
expect(history[0].id).toBe('new');
});
it('getHistory respects limit', () => {
for (let i = 0; i < 10; i++) {
updateManager.addToHistory({ id: `update-${i}` });
}
expect(updateManager.getHistory(3)).toHaveLength(3);
});
});
describe('getAvailableUpdates', () => {
it('returns empty array when no updates', () => {
expect(updateManager.getAvailableUpdates()).toEqual([]);
});
it('returns list of available updates', () => {
updateManager.availableUpdates.set('abc123', {
containerId: 'abc123',
containerName: 'plex',
imageName: 'linuxserver/plex:latest'
});
const updates = updateManager.getAvailableUpdates();
expect(updates).toHaveLength(1);
expect(updates[0].containerName).toBe('plex');
});
});
describe('scheduleUpdate', () => {
it('schedules future update', () => {
const futureTime = new Date(Date.now() + 60000).toISOString();
updateManager.scheduleUpdate('abc123', futureTime);
// No error thrown, timer set
});
it('rejects past time', () => {
const pastTime = new Date(Date.now() - 60000).toISOString();
expect(() => updateManager.scheduleUpdate('abc123', pastTime))
.toThrow('must be in the future');
});
});
describe('configureAutoUpdate', () => {
it('stores auto-update config', () => {
updateManager.configureAutoUpdate('abc123', {
schedule: 'daily',
maintenanceWindow: '02:00-04:00',
autoRollback: true
});
const config = updateManager.getAutoUpdateConfig();
expect(config['abc123'].enabled).toBe(true);
expect(config['abc123'].schedule).toBe('daily');
expect(config['abc123'].maintenanceWindow).toBe('02:00-04:00');
expect(fs.writeFileSync).toHaveBeenCalled();
});
it('defaults schedule to weekly', () => {
updateManager.configureAutoUpdate('abc123', {});
expect(updateManager.getAutoUpdateConfig()['abc123'].schedule).toBe('weekly');
});
});
describe('persistence (loadConfig/saveConfig)', () => {
it('loadConfig returns saved config', () => {
const saved = { autoUpdate: { abc123: { enabled: true, schedule: 'daily' } } };
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockReturnValue(JSON.stringify(saved));
const config = updateManager.loadConfig();
expect(config.autoUpdate.abc123.schedule).toBe('daily');
});
it('loadConfig returns defaults on missing file', () => {
fs.existsSync.mockReturnValue(false);
const config = updateManager.loadConfig();
expect(config.autoUpdate).toEqual({});
});
it('loadConfig returns defaults on error', () => {
fs.existsSync.mockReturnValue(true);
fs.readFileSync.mockImplementation(() => { throw new Error('read error'); });
const config = updateManager.loadConfig();
expect(config.autoUpdate).toEqual({});
});
it('loadHistory returns empty array on missing file', () => {
fs.existsSync.mockReturnValue(false);
expect(updateManager.loadHistory()).toEqual([]);
});
it('saveConfig writes JSON', () => {
updateManager.config = { autoUpdate: { test: { enabled: true } } };
updateManager.saveConfig();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.any(String),
expect.stringContaining('test')
);
});
it('saveConfig handles write error', () => {
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
updateManager.saveConfig(); // should not throw
});
it('saveHistory handles write error', () => {
fs.writeFileSync.mockImplementation(() => { throw new Error('disk full'); });
updateManager.saveHistory(); // should not throw
});
});
describe('formatChangelog', () => {
it('formats repo description and tags', () => {
const repoInfo = { description: 'Plex Media Server', pull_count: 1000000 };
const tags = [
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
{ name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
];
const result = updateManager.formatChangelog(repoInfo, tags, 'latest');
expect(result).toContain('Plex Media Server');
expect(result).toContain('Recent tags');
expect(result).toContain('latest');
expect(result).toContain('1,000,000');
});
it('returns fallback for empty data', () => {
const result = updateManager.formatChangelog(null, [], 'latest');
expect(result).toBe('No changelog available');
});
});
describe('verifyContainer', () => {
it('returns true when container is running (no health check)', async () => {
const container = {
inspect: jest.fn().mockResolvedValue({
State: { Running: true }
})
};
const result = await updateManager.verifyContainer(container, 5000);
expect(result).toBe(true);
});
it('returns true when container is healthy', async () => {
const container = {
inspect: jest.fn().mockResolvedValue({
State: { Running: true, Health: { Status: 'healthy' } }
})
};
const result = await updateManager.verifyContainer(container, 5000);
expect(result).toBe(true);
});
it('throws on inspect error', async () => {
const container = {
inspect: jest.fn().mockRejectedValue(new Error('gone'))
};
await expect(updateManager.verifyContainer(container, 1000))
.rejects.toThrow('Container verification failed');
});
});
describe('checkForUpdates', () => {
it('detects updates and stores them in availableUpdates', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'abc123', Names: ['/plex'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'lscr.io/linuxserver/plex:latest' },
Image: 'sha256:current12345678'
})
});
// Custom registry returns null (not Docker Hub)
jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newdigest98765');
await updateManager.checkForUpdates();
expect(updateManager.availableUpdates.has('abc123')).toBe(true);
const update = updateManager.availableUpdates.get('abc123');
expect(update.containerName).toBe('plex');
expect(update.imageName).toBe('lscr.io/linuxserver/plex:latest');
updateManager.getLatestImageDigest.mockRestore();
});
it('removes from availableUpdates when no update needed', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'abc123', Names: ['/plex'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'plex:latest' },
Image: 'sha256:samedigest'
})
});
jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:samedigest');
// Pre-populate with stale update
updateManager.availableUpdates.set('abc123', { stale: true });
await updateManager.checkForUpdates();
expect(updateManager.availableUpdates.has('abc123')).toBe(false);
updateManager.getLatestImageDigest.mockRestore();
});
it('handles error during container inspect gracefully', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'broken', Names: ['/broken'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockRejectedValue(new Error('inspect failed'))
});
// Should not throw
await expect(updateManager.checkForUpdates()).resolves.not.toThrow();
});
it('handles error during list containers gracefully', async () => {
mockDockerInstance.listContainers.mockRejectedValue(new Error('docker down'));
// Should not throw
await expect(updateManager.checkForUpdates()).resolves.not.toThrow();
});
it('emits update-available event when new update detected', async () => {
mockDockerInstance.listContainers.mockResolvedValue([
{ Id: 'newupdate', Names: ['/radarr'] }
]);
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'linuxserver/radarr:latest' },
Image: 'sha256:oldradarr'
})
});
jest.spyOn(updateManager, 'getLatestImageDigest').mockResolvedValue('sha256:newradarr');
const events = [];
updateManager.on('update-available', e => events.push(e));
await updateManager.checkForUpdates();
expect(events).toHaveLength(1);
expect(events[0].containerName).toBe('radarr');
updateManager.removeAllListeners();
updateManager.getLatestImageDigest.mockRestore();
});
});
describe('getLatestImageDigest', () => {
it('routes Docker Hub library images to getDockerHubDigest', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:digest123');
const result = await updateManager.getLatestImageDigest('nginx:latest');
expect(result).toBe('sha256:digest123');
expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('nginx', 'latest');
updateManager.getDockerHubDigest.mockRestore();
});
it('routes Docker Hub user images to getDockerHubDigest', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:abc');
const result = await updateManager.getLatestImageDigest('linuxserver/plex:latest');
expect(result).toBe('sha256:abc');
expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('linuxserver/plex', 'latest');
updateManager.getDockerHubDigest.mockRestore();
});
it('returns null for custom registries', async () => {
const result = await updateManager.getLatestImageDigest('lscr.io/linuxserver/plex:latest');
expect(result).toBeNull();
});
it('returns null on error', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockRejectedValue(new Error('boom'));
const result = await updateManager.getLatestImageDigest('nginx:latest');
expect(result).toBeNull();
updateManager.getDockerHubDigest.mockRestore();
});
it('defaults tag to latest when not specified', async () => {
jest.spyOn(updateManager, 'getDockerHubDigest').mockResolvedValue('sha256:xyz');
await updateManager.getLatestImageDigest('redis');
expect(updateManager.getDockerHubDigest).toHaveBeenCalledWith('redis', 'latest');
updateManager.getDockerHubDigest.mockRestore();
});
});
describe('getDockerHubDigest (https)', () => {
it('returns digest from successful response', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:fromregistry' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
})
}));
return { on: jest.fn(), end: jest.fn() };
});
const result = await updateManager.getDockerHubDigest('nginx', 'latest');
expect(result).toBe('sha256:fromregistry');
});
it('handles 401 with no auth header by rejecting', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 401,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
})
}));
return { on: jest.fn(), end: jest.fn() };
});
await expect(updateManager.getDockerHubDigest('nginx', 'latest')).rejects.toThrow();
});
it('rejects on https request error', async () => {
// ECONNREFUSED is in REGISTRY_TRANSIENT_ERROR_CODES, so this would retry.
// Use a non-transient code (or no code) for the test to propagate.
jest.useRealTimers();
https.request.mockImplementation(() => {
const req = { on: jest.fn(), end: jest.fn() };
// Trigger error event asynchronously
setImmediate(() => {
const errorHandler = req.on.mock.calls.find(c => c[0] === 'error');
if (errorHandler) errorHandler[1](new Error('connection refused'));
});
return req;
});
await expect(updateManager.getDockerHubDigest('nginx', 'latest'))
.rejects.toThrow('connection refused');
jest.useFakeTimers({ doNotFake: ['setImmediate', 'queueMicrotask', 'nextTick'] });
});
it('normalizes library/ prefix for official images', async () => {
let capturedPath;
https.request.mockImplementation((options, cb) => {
capturedPath = options.path;
setImmediate(() => cb({
statusCode: 200,
headers: { 'docker-content-digest': 'sha256:digest' },
on: jest.fn((event, handler) => {
if (event === 'end') setImmediate(handler);
})
}));
return { on: jest.fn(), end: jest.fn() };
});
await updateManager.getDockerHubDigest('nginx', 'latest');
expect(capturedPath).toBe('/v2/library/nginx/manifests/latest');
});
});
describe('pullImage', () => {
it('pulls image and resolves with output', async () => {
mockDockerInstance.pull.mockImplementation((imageName, cb) => {
cb(null, { stream: 'fake' });
});
mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => {
cb(null, [{ status: 'Downloaded' }]);
});
const result = await updateManager.pullImage('nginx:latest');
expect(result).toEqual([{ status: 'Downloaded' }]);
});
it('rejects on pull error', async () => {
mockDockerInstance.pull.mockImplementation((imageName, cb) => {
cb(new Error('image not found'));
});
await expect(updateManager.pullImage('bogus:latest')).rejects.toThrow('image not found');
});
it('rejects on followProgress error', async () => {
mockDockerInstance.pull.mockImplementation((imageName, cb) => {
cb(null, { stream: 'fake' });
});
mockDockerInstance.modem.followProgress.mockImplementation((stream, cb) => {
cb(new Error('progress error'));
});
await expect(updateManager.pullImage('nginx:latest')).rejects.toThrow('progress error');
});
});
describe('verifyContainer with timeout', () => {
it('waits for container to start running', async () => {
let inspectCount = 0;
const container = {
inspect: jest.fn().mockImplementation(() => {
inspectCount++;
if (inspectCount < 2) return Promise.resolve({ State: { Running: false } });
return Promise.resolve({ State: { Running: true } });
})
};
jest.useRealTimers();
const result = await updateManager.verifyContainer(container, 5000);
expect(result).toBe(true);
expect(inspectCount).toBeGreaterThanOrEqual(2);
jest.useFakeTimers();
});
it('throws on timeout when container never starts', async () => {
const container = {
inspect: jest.fn().mockResolvedValue({ State: { Running: false } })
};
jest.useRealTimers();
await expect(updateManager.verifyContainer(container, 100))
.rejects.toThrow();
jest.useFakeTimers();
});
});
describe('rollbackUpdate', () => {
it('rolls back container from last successful backup', async () => {
const backup = {
containerId: 'old-id',
containerName: 'plex',
imageName: 'plex:latest',
config: { Image: 'plex:latest', Env: [] },
hostConfig: { Binds: [] }
};
updateManager.history.push({
containerId: 'old-id',
status: 'success',
backup,
timestamp: new Date().toISOString()
});
mockDockerInstance.getContainer.mockReturnValue({
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.createContainer.mockResolvedValue({
start: jest.fn().mockResolvedValue()
});
const events = [];
updateManager.on('rollback-complete', e => events.push(e));
const result = await updateManager.rollbackUpdate('old-id');
expect(result).toBe(true);
expect(events).toHaveLength(1);
expect(events[0].containerName).toBe('plex');
updateManager.removeAllListeners();
});
it('throws when no backup exists', async () => {
await expect(updateManager.rollbackUpdate('no-backup-id'))
.rejects.toThrow('No backup found for rollback');
});
it('continues rollback even if old container removal fails', async () => {
const backup = {
containerName: 'plex',
imageName: 'plex:latest',
config: { Image: 'plex:latest' },
hostConfig: {}
};
updateManager.history.push({
containerId: 'gone-id',
status: 'success',
backup,
timestamp: new Date().toISOString()
});
mockDockerInstance.getContainer.mockReturnValue({
stop: jest.fn().mockRejectedValue(new Error('not found')),
remove: jest.fn().mockRejectedValue(new Error('not found'))
});
mockDockerInstance.createContainer.mockResolvedValue({
start: jest.fn().mockResolvedValue()
});
const result = await updateManager.rollbackUpdate('gone-id');
expect(result).toBe(true);
});
});
describe('updateContainer', () => {
it('completes full update pipeline successfully', async () => {
const inspectData = {
Config: { Image: 'plex:latest', Env: ['TZ=UTC'] },
HostConfig: { Binds: [] },
NetworkSettings: { Ports: {} },
Image: 'sha256:oldid',
Name: '/plex'
};
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue(inspectData),
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: ['plex@sha256:olddigest'] }),
remove: jest.fn().mockResolvedValue()
});
const newContainer = {
id: 'new-container-id',
inspect: jest.fn().mockResolvedValue({
State: { Running: true },
Image: 'sha256:newid',
NetworkSettings: { Ports: {} }
}),
start: jest.fn().mockResolvedValue()
};
mockDockerInstance.createContainer.mockResolvedValue(newContainer);
// Mock pullImage and verifyContainerExtended to skip their internals
jest.spyOn(updateManager, 'pullImage').mockResolvedValue([{ status: 'Downloaded' }]);
jest.spyOn(updateManager, 'verifyContainerExtended').mockResolvedValue(true);
const events = [];
updateManager.on('update-start', e => events.push({ type: 'start', ...e }));
updateManager.on('update-complete', e => events.push({ type: 'complete', ...e }));
const result = await updateManager.updateContainer('container-id');
expect(result.status).toBe('success');
expect(result.containerName).toBe('plex');
expect(events).toHaveLength(2);
updateManager.removeAllListeners();
updateManager.pullImage.mockRestore();
updateManager.verifyContainerExtended.mockRestore();
});
it('attempts rollback on failure', async () => {
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'plex:latest' },
HostConfig: {},
NetworkSettings: {},
Image: 'sha256:old',
Name: '/plex'
}),
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: [] })
});
jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed'));
jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true);
await expect(updateManager.updateContainer('fail-id')).rejects.toThrow('pull failed');
expect(updateManager.rollbackUpdate).toHaveBeenCalledWith('fail-id');
expect(updateManager.history[0].status).toBe('failed');
updateManager.pullImage.mockRestore();
updateManager.rollbackUpdate.mockRestore();
});
it('skips rollback when autoRollback is false', async () => {
mockDockerInstance.getContainer.mockReturnValue({
inspect: jest.fn().mockResolvedValue({
Config: { Image: 'plex:latest' },
HostConfig: {},
NetworkSettings: {},
Image: 'sha256:old',
Name: '/plex'
}),
stop: jest.fn().mockResolvedValue(),
remove: jest.fn().mockResolvedValue()
});
mockDockerInstance.getImage.mockReturnValue({
inspect: jest.fn().mockResolvedValue({ RepoDigests: [] })
});
jest.spyOn(updateManager, 'pullImage').mockRejectedValue(new Error('pull failed'));
jest.spyOn(updateManager, 'rollbackUpdate').mockResolvedValue(true);
await expect(updateManager.updateContainer('id', { autoRollback: false }))
.rejects.toThrow('pull failed');
expect(updateManager.rollbackUpdate).not.toHaveBeenCalled();
updateManager.pullImage.mockRestore();
updateManager.rollbackUpdate.mockRestore();
});
});
describe('fetchDockerHubRepo / fetchDockerHubTags', () => {
it('fetchDockerHubRepo returns parsed response on 200', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') {handler(Buffer.from(JSON.stringify({
description: 'Plex Media Server',
pull_count: 1000000,
star_count: 500
})));}
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubRepo('linuxserver/plex', false);
expect(result.description).toBe('Plex Media Server');
expect(result.pull_count).toBe(1000000);
});
it('fetchDockerHubRepo returns null on 404', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 404,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubRepo('nonexistent', false);
expect(result).toBeNull();
});
it('fetchDockerHubRepo returns null on https error', async () => {
https.request.mockImplementation(() => {
const req = { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
setImmediate(() => {
const errCall = req.on.mock.calls.find(c => c[0] === 'error');
if (errCall) errCall[1](new Error('connection refused'));
});
return req;
});
const result = await updateManager.fetchDockerHubRepo('nginx', true);
expect(result).toBeNull();
});
it('fetchDockerHubTags returns results array on 200', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 200,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'data') {handler(Buffer.from(JSON.stringify({
results: [
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z' },
{ name: '1.40', last_pushed: '2026-03-15T00:00:00Z' }
]
})));}
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubTags('linuxserver/plex', false);
expect(result).toHaveLength(2);
expect(result[0].name).toBe('latest');
});
it('fetchDockerHubTags returns empty on non-200', async () => {
https.request.mockImplementation((options, cb) => {
setImmediate(() => cb({
statusCode: 500,
headers: {},
on: jest.fn((event, handler) => {
if (event === 'end') handler();
})
}));
return { on: jest.fn(), end: jest.fn(), setTimeout: jest.fn(), destroy: jest.fn() };
});
const result = await updateManager.fetchDockerHubTags('nginx', true);
expect(result).toEqual([]);
});
});
describe('getChangelog', () => {
it('returns changelog with repo info and tags', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({
description: 'Nginx web server',
pull_count: 5000000,
star_count: 1000,
last_updated: '2026-04-01T00:00:00Z'
});
jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([
{ name: 'latest', last_pushed: '2026-04-01T00:00:00Z', digest: 'sha256:abcdef123456789', full_size: 1024 },
{ name: '1.25', last_pushed: '2026-03-15T00:00:00Z', digest: 'sha256:fedcba987654321', full_size: 2048 }
]);
const result = await updateManager.getChangelog('nginx:latest');
expect(result.imageName).toBe('nginx:latest');
expect(result.currentTag).toBe('latest');
expect(result.repository.description).toBe('Nginx web server');
expect(result.repository.pullCount).toBe(5000000);
expect(result.tags).toHaveLength(2);
expect(result.urls.dockerHub).toContain('hub.docker.com');
expect(result.changelog).toContain('Nginx web server');
updateManager.fetchDockerHubRepo.mockRestore();
updateManager.fetchDockerHubTags.mockRestore();
});
it('handles user images (namespace/repo format)', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({
description: 'Plex',
pull_count: 1000,
star_count: 50
});
jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]);
const result = await updateManager.getChangelog('linuxserver/plex:latest');
expect(result.repository.name).toBe('linuxserver/plex');
expect(result.urls.dockerHub).toContain('linuxserver/plex');
updateManager.fetchDockerHubRepo.mockRestore();
updateManager.fetchDockerHubTags.mockRestore();
});
it('returns fallback on fetch error', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockRejectedValue(new Error('network'));
const result = await updateManager.getChangelog('nginx:latest');
expect(result.imageName).toBe('nginx:latest');
expect(result.error).toBe('network');
expect(result.changelog).toContain('Unable to fetch');
updateManager.fetchDockerHubRepo.mockRestore();
});
it('defaults tag to latest when not specified', async () => {
jest.spyOn(updateManager, 'fetchDockerHubRepo').mockResolvedValue({});
jest.spyOn(updateManager, 'fetchDockerHubTags').mockResolvedValue([]);
const result = await updateManager.getChangelog('redis');
expect(result.currentTag).toBe('latest');
updateManager.fetchDockerHubRepo.mockRestore();
updateManager.fetchDockerHubTags.mockRestore();
});
});
describe('runAutoUpdates', () => {
it('skips containers not enabled', async () => {
updateManager.config.autoUpdate = {
'cont-1': { enabled: false, schedule: 'daily' }
};
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('skips outside maintenance window', async () => {
// Force time to be 10am (outside 2-4am default window)
jest.setSystemTime(new Date('2026-04-06T10:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily' }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('skips containers without available updates', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00')); // Inside default 2-4am window
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily' }
};
// No update in availableUpdates
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('runs update when in custom maintenance window', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00', autoRollback: true }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'plex' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({ status: 'success' });
const events = [];
updateManager.on('auto-update-complete', e => events.push(e));
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).toHaveBeenCalledWith('cont-1', expect.any(Object));
expect(events).toHaveLength(1);
updateManager.removeAllListeners();
updateManager.updateContainer.mockRestore();
});
it('emits auto-update-failed on update error', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'daily', maintenanceWindow: '02:00-05:00' }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'plex' });
jest.spyOn(updateManager, 'updateContainer').mockRejectedValue(new Error('update failed'));
const events = [];
updateManager.on('auto-update-failed', e => events.push(e));
await updateManager.runAutoUpdates();
expect(events).toHaveLength(1);
expect(events[0].error).toBe('update failed');
updateManager.removeAllListeners();
updateManager.updateContainer.mockRestore();
});
it('skips weekly schedule when not Sunday', async () => {
// Monday April 6 2026
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': { enabled: true, schedule: 'weekly', maintenanceWindow: '02:00-05:00' }
};
updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
it('skips when already ran today', async () => {
jest.setSystemTime(new Date('2026-04-06T03:00:00'));
updateManager.config.autoUpdate = {
'cont-1': {
enabled: true,
schedule: 'daily',
maintenanceWindow: '02:00-05:00',
lastAutoUpdate: new Date('2026-04-06T02:30:00').toISOString()
}
};
updateManager.availableUpdates.set('cont-1', { containerName: 'test' });
jest.spyOn(updateManager, 'updateContainer').mockResolvedValue({});
await updateManager.runAutoUpdates();
expect(updateManager.updateContainer).not.toHaveBeenCalled();
updateManager.updateContainer.mockRestore();
});
});
describe('DashCaddy scenarios', () => {
it('Radarr auto-update: weekly at 3AM', () => {
updateManager.configureAutoUpdate('radarr-id', {
schedule: 'weekly',
maintenanceWindow: '02:00-05:00',
autoRollback: true
});
const config = updateManager.getAutoUpdateConfig()['radarr-id'];
expect(config.schedule).toBe('weekly');
expect(config.maintenanceWindow).toBe('02:00-05:00');
expect(config.autoRollback).toBe(true);
});
it('tracks update for Plex container', () => {
updateManager.availableUpdates.set('plex-id', {
containerId: 'plex-id',
containerName: 'plex',
imageName: 'lscr.io/linuxserver/plex:latest',
currentDigest: 'sha256:abc12',
latestDigest: 'sha256:def34',
detectedAt: new Date().toISOString()
});
const updates = updateManager.getAvailableUpdates();
expect(updates[0].imageName).toBe('lscr.io/linuxserver/plex:latest');
});
});
});