Add advanced Git and deployment workflows, secure backups and auditing, live Gitea integration, desktop notifications, connection validation, and the premium responsive UX refresh.
304 lines
17 KiB
JavaScript
304 lines
17 KiB
JavaScript
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, /<Name>Portfolio<\/Name>/);
|
|
assert.match(template, /<Repository>forgeflow\/portfolio:production<\/Repository>/);
|
|
assert.match(template, /<WebUI>http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/);
|
|
assert.match(template, /<Icon>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');
|
|
});
|
|
|
|
test('existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth', () => {
|
|
const { deriveDetectedProfile } = require('../src/main/unraid-deployment-service.cjs');
|
|
const result = deriveDetectedProfile({
|
|
repository: { name: 'blockpilot-autonomous', defaultBranch: 'main', sshUrl: 'ssh://git@gitea/Jens/blockpilot-autonomous.git' },
|
|
server: { id: 'unraid', host: '192.168.10.150' },
|
|
remoteFolder: 'blockpilot-autonomous',
|
|
remotePath: '/mnt/user/appdata/blockpilot-autonomous',
|
|
payload: {
|
|
head: 'a'.repeat(40),
|
|
branch: 'main',
|
|
remote: 'ssh://git@gitea/Jens/blockpilot-autonomous.git',
|
|
composeFiles: ['compose.yml'],
|
|
compose: { services: { app: { image: 'blockpilot:test' } } },
|
|
containers: [{
|
|
Name: '/blockpilot',
|
|
State: { Running: true },
|
|
Config: { Image: 'blockpilot:test', Env: ['TOKEN=secret', 'MODE=prod'], Labels: { 'com.docker.compose.service': 'app', 'com.docker.compose.project': 'blockpilot' } },
|
|
HostConfig: { RestartPolicy: { Name: 'unless-stopped' } },
|
|
NetworkSettings: { Ports: { '8080/tcp': [{ HostIp: '0.0.0.0', HostPort: '1223' }] }, Networks: { bridge: {} } },
|
|
Mounts: [{ Type: 'bind', Source: '/mnt/user/appdata/blockpilot-autonomous/data', Destination: '/data', RW: true }]
|
|
}],
|
|
dockerManXml: '<Container><Name>blockpilot</Name><WebUI>http://[IP]:[PORT:1223]/</WebUI><Icon>https://example.test/icon.png</Icon><Shell>/bin/bash</Shell></Container>'
|
|
}
|
|
});
|
|
assert.equal(result.profile.hostPort, 1223);
|
|
assert.equal(result.profile.containerPort, 8080);
|
|
assert.equal(result.profile.containerName, 'blockpilot');
|
|
assert.equal(result.profile.composeService, 'app');
|
|
assert.equal(result.profile.webUiUrl, 'http://[IP]:[PORT:1223]/');
|
|
assert.equal(result.profile.iconUrl, 'https://example.test/icon.png');
|
|
assert.equal(result.profile.dockerShell, '/bin/bash');
|
|
assert.deepEqual(result.profile.detectedMetadata.envNames, ['TOKEN', 'MODE']);
|
|
assert.ok(result.profile.preservePaths.includes('data'));
|
|
assert.equal(result.provenance.hostPort.origin, 'docker-inspect');
|
|
});
|