import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash } = require('../src/main/unraid-deployment-service.cjs'); const { fingerprintKey, shellQuote } = require('../src/main/ssh-service.cjs'); test('Unraid remote paths cannot escape appdata project folder', () => { assert.equal(safeRemoteFolder('lumaops'), 'lumaops'); assert.throws(() => safeRemoteFolder('../lumaops')); assert.equal(safeRelativeRemoteFile('deploy/docker-compose.yml'), 'deploy/docker-compose.yml'); assert.throws(() => safeRelativeRemoteFile('../../etc/passwd')); }); test('server inspection key-value payload is decoded safely', () => { const b64 = (value) => Buffer.from(value).toString('base64'); const parsed = parseInspection(`noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=${b64(' M docker-compose.yml\n')}\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`); assert.equal(parsed.rootGit, true); assert.deepEqual(parsed.composeFiles, ['docker-compose.yml']); assert.deepEqual(parsed.nestedGit, ['source']); assert.equal(parsed.trackedChanges.length, 1); assert.match(parsed.dockerignoreContent, /\.git/); assert.deepEqual(parsed.existingPreservePaths, ['data', 'logs']); }); test('Docker ignore checks identify exact runtime and Git context exclusions', () => { const rules = '# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n'; assert.equal(dockerIgnoreHasPath(rules, '.git'), true); assert.equal(dockerIgnoreHasPath(rules, 'data'), true); assert.equal(dockerIgnoreHasPath(rules, 'logs'), true); assert.equal(dockerIgnoreHasPath(rules, 'source'), false); }); test('server inspection detects preserved runtime paths and missing Docker context exclusions', async () => { const b64 = (value) => Buffer.from(value).toString('base64'); let receivedCommand = ''; const store = { getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', preservePaths: ['data', 'logs'] }), getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }) }; const ssh = { exec: async (_serverId, command) => { receivedCommand = command; return { stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${'a'.repeat(40)}\nbranch=main\nremote=${b64('ssh://git@gitea/Jens/LumaOps.git')}\ntrackedChanges=\ncomposeFiles=${b64('docker-compose.yml\n')}\nnestedGit=${b64('source\n')}\ndockerfile=true\ndockerignoreContent=${b64('.git\ndata/\n')}\nexistingPreservePaths=${b64('data\nlogs\n')}\n`, stderr: '', exitCode: 0 }; } }; const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null }); const inspection = await service.inspect({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps' }, profileId: 'production' }); assert.match(receivedCommand, /base64 -d \| bash$/); assert.equal(inspection.remotePath, '/mnt/user/appdata/lumaops'); assert.equal(inspection.dockerignoreGitExcluded, true); assert.deepEqual(inspection.existingPreservePaths.sort(), ['data', 'logs']); assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), ['logs', 'source']); }); test('preflight summary blocks only failed checks', () => { const result = checksSummary([{ id: 'a', status: 'pass' }, { id: 'b', status: 'warning' }, { id: 'c', status: 'fail' }]); assert.equal(result.ready, false); assert.deepEqual(result.blocking, ['c']); }); test('SSH helpers produce pinned fingerprints and quoted commands', () => { assert.match(fingerprintKey(Buffer.from('host-key')), /^SHA256:/); assert.equal(shellQuote("a'b"), "'a'\\''b'"); const wrapped = bash('git fetch origin main'); assert.match(wrapped, /base64 -d \| bash$/); assert.equal(wrapped.includes('\n'), false); const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; const decoded = Buffer.from(encoded, 'base64').toString('utf8'); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /BatchMode=yes/); assert.match(decoded, /git fetch origin main/); }); test('SSH rollback refuses any SHA other than the exact recorded previous deployment', async () => { const previousSha = 'a'.repeat(40); const store = { getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production' }), getServer: () => ({ id: 'unraid', basePath: '/mnt/user/appdata' }), getDeploymentState: () => ({ liveSha: 'b'.repeat(40), previousSha }) }; const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null }); await assert.rejects( service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: 'c'.repeat(40) }), (error) => error.code === 'ROLLBACK_TARGET_NOT_PREVIOUS_SHA' ); }); test('successful SSH rollback records the formerly live SHA as the new rollback target', async () => { const previousSha = 'a'.repeat(40); const liveSha = 'b'.repeat(40); const savedStates = []; const operations = []; const store = { getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'lumaops', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }), getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), getDeploymentState: () => ({ liveSha, previousSha }), addOperation: async (operation) => { operations.push(operation); return operation; }, saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; } }; const git = { verifyCommitOnRemoteBranch: async () => true }; const ssh = { exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }) }; const service = new UnraidDeploymentService({ store, ssh, git, diagnostics: null }); service.inspect = async () => ({ rootGit: true, trackedChanges: [], head: liveSha }); service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null }); const result = await service.rollback({ repository: { fullName: 'Jens/LumaOps', name: 'LumaOps', localPath: '/tmp/lumaops' }, profileId: 'production', targetSha: previousSha }); assert.equal(result.status, 'rolled-back'); assert.equal(savedStates.at(-1).liveSha, previousSha); assert.equal(savedStates.at(-1).previousSha, liveSha); assert.equal(operations.at(-1).previousSha, liveSha); }); test('generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name', () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); const compose = service.generatedCompose({ composeService: 'Portfolio', hostPort: 5150, containerPort: 80 }, { name: 'Portfolio' }); assert.match(compose, / portfolio:/); assert.match(compose, /image: forgeflow\/portfolio:production/); assert.match(compose, /container_name: Portfolio/); }); test('SSH deployment dispatch returns a running operation while the remote build continues in background', async () => { const sha = 'd'.repeat(40); const operations = []; let resolveRemote; const store = { getDeploymentProfile: () => ({ id: 'production', provider: 'ssh-unraid', serverId: 'unraid', remoteFolder: 'Portfolio', cloneUrl: 'forgeflow-gitea:Jens/Portfolio.git', composeFile: 'docker-compose.yml', branch: 'main', environment: 'production', healthcheckUrl: '', iconMode: 'none' }), getServer: () => ({ id: 'unraid', name: 'Unraid', basePath: '/mnt/user/appdata' }), addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); }, saveDeploymentState: async () => ({}) }; const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }) }; const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null }); service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null } }); service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null }); const operation = await service.deploy({ repository: { fullName: 'Jens/Portfolio', name: 'Portfolio' }, profileId: 'production', sha }); assert.equal(operation.status, 'running'); assert.match(operation.logs.join('\n'), /background/i); resolveRemote({ stdout: 'Container Portfolio started\n', stderr: '', exitCode: 0 }); await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(operations.at(-1).status, 'success'); }); test('Unraid preflight verifies repository access before a deployment can start', async () => { const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8')); assert.match(source, /server-git-access/); assert.match(source, /git ls-remote --exit-code/); assert.match(source, /Unraid → Gitea access/); }); test('DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names', () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); const metadata = service.metadataCompose({ composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio', environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/admin', dockerShell: '/bin/sh' }, { name: 'Portfolio' }, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'); assert.match(metadata, / portfolio:/); assert.match(metadata, /image: forgeflow\/portfolio:production/); assert.match(metadata, /container_name: Portfolio/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); assert.match(metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/); assert.match(metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/); }); test('DockerMan integration writes a persistent template fallback and invalidates cached metadata', () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null }); const profile = { composeService: 'portfolio', containerName: 'Portfolio', remoteFolder: 'Portfolio', environment: 'production', hostPort: 5150, webUiUrl: 'http://192.168.10.150:5150/', dockerShell: '/bin/sh' }; const repository = { name: 'Portfolio' }; const icon = 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'; const template = service.dockerManTemplate(profile, repository, icon); const refresh = service.dockerManRefreshScript(profile, repository, icon); assert.match(template, /Portfolio<\/Name>/); assert.match(template, /forgeflow\/portfolio:production<\/Repository>/); assert.match(template, /http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/); assert.match(template, /file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/); assert.match(refresh, /templates-user\/my-Portfolio\.xml/); assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/); assert.match(refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/); assert.doesNotMatch(refresh, /dockerManRefreshScript/); assert.equal(xmlEscape('A&B<"x">'), 'A&B<"x">'); }); test('built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage', async (t) => { const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); const os = await import('node:os'); const path = await import('node:path'); const sourcePath = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-icon-')); t.after(() => rm(sourcePath, { recursive: true, force: true })); const asset = path.join(sourcePath, 'src', 'renderer', 'assets', 'itworx-mark.png'); await mkdir(path.dirname(asset), { recursive: true }); await writeFile(asset, Buffer.from([137, 80, 78, 71])); const uploads = []; const service = new UnraidDeploymentService({ store: {}, git: {}, diagnostics: null, sourcePath, ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; } } }); const icon = await service.prepareIcon({ iconMode: 'builtin', remoteFolder: 'Portfolio' }, { name: 'Portfolio' }, { id: 'unraid' }); assert.equal(icon, 'file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png'); assert.equal(uploads.length, 1); assert.equal(uploads[0][0], 'unraid'); assert.equal(uploads[0][1], asset); assert.equal(uploads[0][2], '/boot/config/plugins/dockerMan/images/Portfolio-icon.png'); }); test('stuck SSH deployment is reconciled to success when exact SHA and container health are live', async () => { const sha = 'f'.repeat(40); const saved = []; const operation = { id: 'op-1', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha, status: 'running', logs: [] }; const store = { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } }; const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null }); service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true }); const result = await service.refreshOperation('op-1'); assert.equal(result.status, 'success'); assert.match(result.logs.at(-1), /reconciled/i); assert.equal(saved.at(-1).status, 'success'); }); test('DockerMan metadata repair refreshes known Unraid icon caches after container recreation', async () => { const source = await import('node:fs/promises').then(({ readFile }) => readFile(new URL('../src/main/unraid-deployment-service.cjs', import.meta.url), 'utf8')); assert.match(source, /\/var\/lib\/docker\/unraid\/images/); assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /-icon\.png/); assert.match(source, /cp \${shellQuote\(localIconPath\)}/); assert.match(source, /--force-recreate/); }); test('stuck deployment is cleared as superseded when a different healthy commit is already live', async () => { const requested = 'a'.repeat(40); const live = 'b'.repeat(40); const operation = { id: 'op-superseded', type: 'deployment', provider: 'ssh-unraid', action: 'deploy', repository: 'Jens/Portfolio', profileId: 'production', sha: requested, status: 'running', logs: [] }; const saved = []; const service = new UnraidDeploymentService({ store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; } }, ssh: {}, git: {}, diagnostics: null }); service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true }); const result = await service.refreshOperation(operation.id); assert.equal(result.status, 'cancelled'); assert.match(result.error, /Superseded/); assert.equal(saved.at(-1).status, 'cancelled'); });