This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import cloneTargetModule from '../src/shared/clone-target.cjs';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const { cloneDirectoryName, resolveCloneTarget } = cloneTargetModule;
const { GitService } = gitModule;
test('derives a safe repository folder name from HTTPS and SSH clone URLs', () => {
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/ForgeFlow.git'), 'ForgeFlow');
assert.equal(cloneDirectoryName('git@gitea.example.test:jens/my-app.git'), 'my-app');
assert.equal(cloneDirectoryName('ssh://git@gitea.example.test/jens/app.git?ref=main'), 'app');
});
test('resolves the automatic clone target inside the configured project root', () => {
const root = path.join(os.tmpdir(), 'forgeflow-projects');
const plan = resolveCloneTarget(root, 'https://gitea.example.test/jens/portfolio.git');
assert.equal(plan.root, path.resolve(root));
assert.equal(plan.target, path.join(path.resolve(root), 'portfolio'));
assert.equal(plan.directoryName, 'portfolio');
});
test('clone target inspection accepts missing and empty destinations', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-target-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const service = new GitService();
const remote = 'https://gitea.example.test/jens/app.git';
const missing = await service.inspectCloneTarget(remote, path.join(root, 'missing-app'));
assert.equal(missing.state, 'missing');
const emptyPath = path.join(root, 'empty-app');
await fs.mkdir(emptyPath);
const empty = await service.inspectCloneTarget(remote, emptyPath);
assert.equal(empty.state, 'empty');
});
test('clone target inspection reuses an existing checkout with the same origin', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-reuse-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const target = path.join(root, 'app');
await fs.mkdir(target);
await exec('git', ['init'], { cwd: target, encoding: 'utf8' });
await exec('git', ['remote', 'add', 'origin', 'git@gitea.example.test:jens/app.git'], { cwd: target, encoding: 'utf8' });
const service = new GitService();
const assessment = await service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target);
assert.equal(assessment.state, 'matching-repository');
});
test('clone target inspection blocks a different repository and ordinary files', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-conflict-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const service = new GitService();
const otherRepository = path.join(root, 'repository');
await fs.mkdir(otherRepository);
await exec('git', ['init'], { cwd: otherRepository, encoding: 'utf8' });
await exec('git', ['remote', 'add', 'origin', 'https://gitea.example.test/jens/other.git'], { cwd: otherRepository, encoding: 'utf8' });
await assert.rejects(
service.inspectCloneTarget('https://gitea.example.test/jens/app.git', otherRepository),
(error) => error.code === 'CLONE_TARGET_DIFFERENT_REPOSITORY'
);
const ordinaryFolder = path.join(root, 'ordinary');
await fs.mkdir(ordinaryFolder);
await fs.writeFile(path.join(ordinaryFolder, 'notes.txt'), 'do not overwrite\n');
await assert.rejects(
service.inspectCloneTarget('https://gitea.example.test/jens/app.git', ordinaryFolder),
(error) => error.code === 'CLONE_TARGET_NOT_EMPTY'
);
});
test('clone target inspection blocks a file at the automatic destination', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-clone-file-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const target = path.join(root, 'app');
await fs.writeFile(target, 'not a directory');
const service = new GitService();
await assert.rejects(
service.inspectCloneTarget('https://gitea.example.test/jens/app.git', target),
(error) => error.code === 'CLONE_TARGET_NOT_DIRECTORY'
);
});
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import deploymentModule from '../src/main/deployment-service.cjs';
const { DeploymentService, terminalRunConclusion, applicationVerificationFailure } = deploymentModule;
test('maps runner conclusions to ForgeFlow terminal states', () => {
assert.equal(terminalRunConclusion({ conclusion: 'success' }), 'success');
assert.equal(terminalRunConclusion({ conclusion: 'failure' }), 'failed');
assert.equal(terminalRunConclusion({ status: 'timed_out' }), 'failed');
assert.equal(terminalRunConclusion({ conclusion: 'cancelled' }), 'cancelled');
assert.equal(terminalRunConclusion({ status: 'running' }), null);
});
test('dispatches only controlled deployment inputs', async () => {
const sha = 'a'.repeat(40);
let dispatched = null;
const operations = new Map();
const profile = {
id: 'staging', name: 'Staging', environment: 'staging', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow',
inputs: { commit_sha: 'b'.repeat(40), request_id: 'forged', arbitrary: 'ignored' }
};
const store = {
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); }
};
const service = new DeploymentService(store, {
dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; }
}, {
status: async () => ({ head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, error: async () => {} });
const operation = await service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: profile.id, sha });
assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['commit_sha', 'environment', 'request_id']);
assert.equal(dispatched.inputs.commit_sha, sha);
assert.equal(dispatched.inputs.environment, 'staging');
assert.equal(dispatched.inputs.request_id, operation.id);
assert.equal(dispatched.inputs.arbitrary, undefined);
});
test('requires exact server SHA and matching request ID after a successful workflow', () => {
const operation = { id: 'request-1', repository: 'jens/app', environment: 'staging', sha: 'a'.repeat(40), shortSha: 'aaaaaaa' };
assert.equal(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}), null);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: 'another-request', lastExitCode: 0, healthy: true
}).message, /different deployment request/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: 'b'.repeat(40), requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}).message, /server reports/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: false, liveSha: null,
requestId: null, healthy: null
}).message, /not reachable/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'other/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 0, healthy: true
}).message, /belongs to other\/app/i);
assert.match(applicationVerificationFailure(operation, {
statusConfigured: true, statusReachable: true, statusRepository: 'jens/app', statusEnvironment: 'staging',
liveSha: operation.sha, requestedSha: operation.sha, requestId: operation.id, lastExitCode: 70, healthy: false
}).message, /exit code 70/i);
});
test('rollback accepts only the currently reported previous SHA and dispatches controlled inputs', async () => {
const liveSha = 'a'.repeat(40);
const previousSha = 'b'.repeat(40);
let dispatched = null;
let verified = null;
const operations = new Map();
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow',
inputs: { target_sha: 'c'.repeat(40), request_id: 'forged', arbitrary: 'ignored' }
};
const store = {
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => { operations.set(operation.id, structuredClone(operation)); return structuredClone(operation); }
};
const service = new DeploymentService(store, {
listWorkflowRuns: async () => ({ runs: [] }),
dispatchWorkflow: async (payload) => { dispatched = payload; return { accepted: true, status: 204 }; }
}, {
verifyCommitOnRemoteBranch: async (...args) => { verified = args; return { valid: true }; }
}, { info: async () => {}, warning: async () => {}, error: async () => {} });
service.refreshProfileState = async () => ({
statusReachable: true,
statusRepository: 'jens/app',
statusEnvironment: 'production',
liveSha,
previousSha
});
const operation = await service.rollback({
repository: { fullName: 'jens/app', localPath: '/repo' },
profileId: profile.id,
targetSha: previousSha
});
assert.deepEqual(verified, ['/repo', previousSha, 'main']);
assert.deepEqual(Object.keys(dispatched.inputs).sort(), ['environment', 'request_id', 'target_sha']);
assert.equal(dispatched.inputs.environment, 'production');
assert.equal(dispatched.inputs.target_sha, previousSha);
assert.equal(dispatched.inputs.request_id, operation.id);
assert.equal(dispatched.inputs.arbitrary, undefined);
});
test('rollback refuses a stale target that is no longer the server-reported previous SHA', async () => {
const previousSha = 'b'.repeat(40);
let dispatched = false;
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml',
statusUrl: 'https://app.example.test/.well-known/forgeflow'
};
const service = new DeploymentService({
getDeploymentProfile: () => profile,
getToken: () => '',
addOperation: async (operation) => operation
}, {
dispatchWorkflow: async () => { dispatched = true; }
}, {
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, warning: async () => {}, error: async () => {} });
service.refreshProfileState = async () => ({
statusReachable: true,
statusRepository: 'jens/app',
statusEnvironment: 'production',
liveSha: 'a'.repeat(40),
previousSha
});
await assert.rejects(
service.rollback({
repository: { fullName: 'jens/app', localPath: '/repo' },
profileId: profile.id,
targetSha: 'c'.repeat(40)
}),
/no longer the previous server version/i
);
assert.equal(dispatched, false);
});
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import diagnosticsModule from '../src/main/diagnostics-service.cjs';
const { DiagnosticsService } = diagnosticsModule;
function unzipLocalEntries(buffer) {
const entries = new Map();
let offset = 0;
while (offset + 4 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) {
const method = buffer.readUInt16LE(offset + 8);
const compressedSize = buffer.readUInt32LE(offset + 18);
const nameLength = buffer.readUInt16LE(offset + 26);
const extraLength = buffer.readUInt16LE(offset + 28);
const nameStart = offset + 30;
const dataStart = nameStart + nameLength + extraLength;
const name = buffer.subarray(nameStart, nameStart + nameLength).toString('utf8');
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
entries.set(name, method === 8 ? zlib.inflateRawSync(compressed) : compressed);
offset = dataStart + compressedSize;
}
return entries;
}
test('writes structured local diagnostics and exports a secret-free support bundle', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-diagnostics-'));
t.after(() => rm(root, { recursive: true, force: true }));
const secret = ['gitea', 'TEST', 'ONLY', 'ULTRA', 'SECRET', '1234567890'].join('_');
const service = new DiagnosticsService({
userDataPath: root,
appInfo: { name: 'ForgeFlow', version: '0.3.0-test' },
secretProvider: () => [secret],
preferencesProvider: () => ({ diagnosticsEnabled: true, diagnosticLevel: 'debug', logRetentionDays: 14, maxLogFileMb: 8 })
});
await service.initialize();
await service.error('test.failure', {
authorization: `token ${secret}`,
password: 'unsafe-password',
message: `request failed with ${secret}`,
path: path.join(os.homedir(), 'private', 'repository')
});
await service.flush();
const status = await service.getStatus();
assert.equal(status.enabled, true);
assert.ok(status.fileCount >= 1);
const raw = (await Promise.all((await service.listLogFiles()).map((file) => readFile(file.path, 'utf8')))).join('\n');
assert.doesNotMatch(raw, new RegExp(secret));
assert.doesNotMatch(raw, /unsafe-password/);
assert.doesNotMatch(raw, new RegExp(os.homedir().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
const destination = path.join(root, 'support.zip');
const result = await service.exportSupportBundle({
destinationPath: destination,
privacyMode: 'strict',
publicState: { gitea: { baseUrl: 'https://gitea.example.test', hasToken: true, encryptedToken: 'ciphertext' }, preferences: {} },
repositories: [{ id: 1, fullName: 'jens/private-repo', localPath: path.join(os.homedir(), 'private-repo'), localStatus: { head: 'a'.repeat(40), branch: { head: 'main' }, counts: {}, clean: true } }],
operations: [{ repository: 'jens/private-repo', status: 'failed', runnerLog: `Authorization: token ${secret}` }],
preflight: { checks: [] }
});
assert.equal(service.isKnownBundlePath(result.path), true);
const entries = unzipLocalEntries(await readFile(destination));
const bundleText = [...entries.values()].map((value) => value.toString('utf8')).join('\n');
assert.doesNotMatch(bundleText, new RegExp(secret));
assert.doesNotMatch(bundleText, /ciphertext|unsafe-password|jens\/private-repo/);
assert.match(entries.get('manifest.json').toString(), /"containsSecrets": false/);
assert.match(entries.get('repositories-sanitized.json').toString(), /fullname-[a-f0-9]{12}/);
const cleared = await service.clear();
assert.ok(cleared.fileCount >= 1, 'clear writes a new safe session marker');
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const { GitService } = gitModule;
async function git(args, cwd) {
return exec('git', args, { cwd, encoding: 'utf8' });
}
test('GitService reads changes and commits/pushes selected files to a real bare remote', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n');
await git(['add', 'README.md'], working);
await git(['commit', '-m', 'Initial commit'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await fs.appendFile(path.join(working, 'README.md'), '\nDesktop release cockpit.\n');
await fs.writeFile(path.join(working, 'feature.txt'), 'new file\n');
const service = new GitService();
const before = await service.status(working);
assert.equal(before.branch.head, 'main');
assert.equal(before.branch.ahead, 0);
assert.equal(before.branch.behind, 0);
assert.equal(before.counts.changed, 2);
assert.deepEqual(new Set(before.files.map((file) => file.path)), new Set(['README.md', 'feature.txt']));
const diff = await service.diff(working, 'README.md');
assert.match(diff, /Desktop release cockpit/);
const result = await service.commitAndPush(working, 'Add desktop cockpit copy', ['README.md', 'feature.txt']);
assert.equal(result.status.clean, true);
assert.equal(result.status.branch.ahead, 0);
assert.match(result.pushOutput, /main/);
const remoteLog = await git(['--git-dir', remote, 'log', '-1', '--pretty=%s', 'refs/heads/main'], root);
assert.equal(remoteLog.stdout.trim(), 'Add desktop cockpit copy');
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import gitStatus from '../src/shared/git-status.cjs';
const { parsePorcelainV2 } = gitStatus;
test('parses branch metadata and ordinary changes', () => {
const output = [
'# branch.oid 0123456789abcdef',
'# branch.head main',
'# branch.upstream origin/main',
'# branch.ab +2 -1',
'1 .M N... 100644 100644 100644 abc def src/main.js',
'1 M. N... 100644 100644 100644 abc def README.md',
'? new file.txt',
''
].join('\0');
const parsed = parsePorcelainV2(output);
assert.equal(parsed.branch.head, 'main');
assert.equal(parsed.branch.ahead, 2);
assert.equal(parsed.branch.behind, 1);
assert.equal(parsed.counts.changed, 3);
assert.equal(parsed.counts.staged, 1);
assert.equal(parsed.counts.untracked, 1);
assert.equal(parsed.files[0].path, 'src/main.js');
});
test('parses rename records with original path', () => {
const output = '2 R. N... 100644 100644 100644 abc def R100 src/new.js\0src/old.js\0';
const parsed = parsePorcelainV2(output);
assert.equal(parsed.files[0].path, 'src/new.js');
assert.equal(parsed.files[0].originalPath, 'src/old.js');
assert.equal(parsed.files[0].status, 'renamed');
});
+46
View File
@@ -0,0 +1,46 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import gitModule from '../src/main/git-service.cjs';
const exec = promisify(execFile);
const { GitService } = gitModule;
const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' });
test('supports commit-only, branch creation, stash lifecycle and remote SHA verification', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-workflow-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
await git(['init', '--bare', remote], root);
await git(['clone', remote, working], root);
await git(['config', 'user.name', 'ForgeFlow Test'], working);
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
const service = new GitService();
const branchStatus = await service.createBranch(working, 'feature/release-flow');
assert.equal(branchStatus.branch.head, 'feature/release-flow');
await fs.writeFile(path.join(working, 'release.txt'), 'release cockpit\n');
const committed = await service.commit(working, 'Add release flow', ['release.txt']);
assert.equal(committed.status.branch.ahead, 0, 'unpublished branches have no upstream-based ahead count');
const pushed = await service.push(working);
assert.equal(pushed.status.branch.upstream, 'origin/feature/release-flow');
await service.verifyCommitOnRemoteBranch(working, committed.sha, 'feature/release-flow');
await fs.appendFile(path.join(working, 'release.txt'), 'local draft\n');
await fs.writeFile(path.join(working, 'untracked.txt'), 'draft\n');
const stashed = await service.stash(working, 'Draft release work');
assert.equal(stashed.status.clean, true);
assert.equal(stashed.stashes.length, 1);
const restored = await service.popStash(working, stashed.stashes[0].ref);
assert.equal(restored.status.counts.changed, 2);
});
+90
View File
@@ -0,0 +1,90 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import giteaModule from '../src/main/gitea-service.cjs';
const { GiteaService } = giteaModule;
function makeStore() {
return { data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => 'demo-token' };
}
test('normalizes run payloads from different Actions API shapes', () => {
const service = new GiteaService(makeStore());
const run = service.normalizeRun({
task_id: 42,
index: 7,
workflow_name: 'Deploy',
status: 'success',
commit: { sha: 'a'.repeat(40) },
ref: 'refs/heads/main',
workflow_file: '.gitea/workflows/deploy.yml',
start_time: '2026-07-24T12:00:00Z'
});
assert.equal(run.id, 42);
assert.equal(run.runNumber, 7);
assert.equal(run.conclusion, 'success');
assert.equal(run.headSha, 'a'.repeat(40));
assert.equal(run.headBranch, 'refs/heads/main');
});
test('retries Actions runs without optional filters when a server rejects them', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => {
calls.push(pathname);
if (calls.length === 1) {
const error = new Error('Unsupported query');
error.status = 422;
throw error;
}
return { data: { workflow_runs: [{ id: 11, run_number: 11, status: 'queued', head_sha: 'b'.repeat(40), head_branch: 'main' }] } };
};
const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app', sha: 'b'.repeat(40), branch: 'main' });
assert.equal(calls.length, 2);
assert.match(calls[0], /head_sha=/);
assert.doesNotMatch(calls[1], /head_sha=/);
assert.equal(result.source, 'runs');
assert.equal(result.runs[0].runNumber, 11);
});
test('falls back to legacy Actions tasks endpoint when runs is unavailable', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => {
calls.push(pathname);
if (pathname.includes('/runs?')) {
const error = new Error('Not found');
error.status = 404;
throw error;
}
return { data: [{ task_id: 9, index: 3, status: 'running', commit_sha: 'c'.repeat(40), branch: 'main' }] };
};
const result = await service.listWorkflowRuns({ owner: 'jens', repo: 'app' });
assert.equal(result.source, 'tasks');
assert.equal(result.runs[0].id, 9);
assert.ok(calls.some((pathname) => pathname.includes('/tasks?')));
});
test('selects the newest matching workflow run', async () => {
const service = new GiteaService(makeStore());
service.listWorkflowRuns = async () => ({ source: 'runs', runs: [
{ id: 1, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T10:00:00Z' },
{ id: 2, headSha: 'd'.repeat(40), headBranch: 'main', workflowPath: '.gitea/workflows/deploy.yml', createdAt: '2026-07-24T11:00:00Z' },
{ id: 3, headSha: 'e'.repeat(40), headBranch: 'main', workflowPath: 'deploy.yml', createdAt: '2026-07-24T12:00:00Z' }
] });
const found = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml' });
assert.equal(found.run.id, 2);
const excludingNewest = await service.findWorkflowRun({ owner: 'jens', repo: 'app', sha: 'd'.repeat(40), branch: 'main', workflowFile: 'deploy.yml', excludeRunIds: [2] });
assert.equal(excludingNewest.run.id, 1);
});
test('checks repository workflow files through the contents API', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname) => { calls.push(pathname); return { data: { type: 'file' } }; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/deploy.yml', ref: 'main' }), true);
assert.match(calls[0], /contents\/\.gitea\/workflows\/deploy\.yml\?ref=main/);
service.request = async () => { const error = new Error('missing'); error.status = 404; throw error; };
assert.equal(await service.repositoryFileExists({ owner: 'jens', repo: 'app', filePath: '.gitea/workflows/missing.yml', ref: 'main' }), false);
});
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import redaction from '../src/main/log-redaction.cjs';
const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias } = redaction;
test('redacts runtime credentials, structured secrets, private keys and URL credentials', () => {
const token = ['gitea', 'TEST', 'ONLY', 'SecretToken123456'].join('_');
const input = [
`Authorization: Bearer ${token}`,
`https://${['jens', 'p4ssw0rd'].join(':')}@gitea.example.test/api?access_token=${token}`,
'client_secret=another-secret-value',
['-----BEGIN', 'PRIVATE KEY-----\nsecret-key-material\n-----END PRIVATE KEY-----'].join(' ')
].join('\n');
const output = redactSecrets(input, [token]);
assert.doesNotMatch(output, /ThisIsARealisticSecret|p4ssw0rd|another-secret-value|secret-key-material/);
assert.match(output, /REDACTED/);
});
test('sanitizes nested sensitive keys and aliases user paths', () => {
const value = {
accessToken: 'do-not-keep',
nested: { password: 'do-not-keep-either', path: 'C:\\Users\\Jens\\Projects\\ForgeFlow' },
home: '/home/jens/projects/forgeflow'
};
const sanitized = sanitizeForDiagnostics(value, { homeDir: '/home/jens', cwd: '/work/ForgeFlow' });
assert.equal(sanitized.accessToken, '[REDACTED]');
assert.equal(sanitized.nested.password, '[REDACTED]');
assert.doesNotMatch(JSON.stringify(sanitized), /do-not-keep|Users\\Jens|\/home\/jens/);
assert.match(JSON.stringify(sanitized), /<HOME>/);
});
test('strict privacy mode replaces stable identifiers deterministically', () => {
const first = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true });
const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens' }, { strictIdentifiers: true });
assert.equal(first.fullName, second.fullName);
assert.equal(first.login, second.login);
assert.notEqual(first.fullName, 'jens/private-project');
assert.match(first.fullName, /^fullname-[a-f0-9]{12}$/);
assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo'));
});
test('path aliasing handles slash variants', () => {
const result = pathAlias('C:\\Users\\Jens\\src and C:/Users/Jens/src', { homeDir: 'C:\\Users\\Jens', cwd: 'D:\\ForgeFlow' });
assert.doesNotMatch(result, /Users[\\/]Jens/);
assert.match(result, /<HOME>/);
});
+65
View File
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import preflightModule from '../src/main/preflight-service.cjs';
const { PreflightService, summarize, check } = preflightModule;
test('only required failed checks block readiness', () => {
const summary = summarize([
check('required-pass', 'Required pass', 'pass', 'ok', { required: true }),
check('optional-warning', 'Optional warning', 'warning', 'notice'),
check('optional-fail', 'Optional fail', 'fail', 'not blocking'),
check('required-fail', 'Required fail', 'fail', 'blocked', { required: true })
]);
assert.equal(summary.ready, false);
assert.deepEqual(summary.blocking, ['required-fail']);
assert.equal(summary.counts.warning, 1);
});
test('system preflight can pass before credentials are entered', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-system-'));
t.after(() => rm(root, { recursive: true, force: true }));
const service = new PreflightService({
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
git: { isAvailable: async () => ({ available: true, version: 'git version test' }) },
gitea: {}, deployments: {},
diagnostics: { logDirectory: path.join(root, 'diagnostics'), info: async () => {} },
userDataPath: path.join(root, 'data'),
secureStorageAvailable: () => true
});
service.gitIdentity = async () => ({ name: 'Jens', email: 'jens@example.test' });
const result = await service.runSystem({ roots: [root] });
assert.equal(result.summary.ready, true);
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'pass');
});
test('deployment preflight verifies exact Git, workflow, Actions and server prerequisites', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-deploy-'));
t.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, '.gitea', 'workflows'), { recursive: true });
await writeFile(path.join(root, '.gitea', 'workflows', 'deploy.yml'), 'name: deploy\n');
await writeFile(path.join(root, '.gitea', 'workflows', 'rollback.yml'), 'name: rollback\n');
const sha = 'a'.repeat(40);
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: 'https://app.example.test/status', healthcheckUrl: 'https://app.example.test/health' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: {
status: async () => ({ root, head: sha, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } }),
verifyCommitOnRemoteBranch: async () => true
},
gitea: { repositoryFileExists: async () => true, listWorkflowRuns: async () => ({ runs: [] }) },
deployments: {
readStatusEndpoint: async () => ({ configured: true, reachable: true, ok: true, liveSha: sha, status: 200 }),
checkHealth: async () => ({ configured: true, healthy: true, status: 200, latencyMs: 12 })
},
diagnostics: { info: async () => {} }, userDataPath: root
});
const result = await service.runDeployment({ repository: { fullName: 'jens/app', localPath: root }, profileId: profile.id });
assert.equal(result.summary.ready, true);
assert.equal(result.checks.filter((item) => item.status === 'fail').length, 0);
assert.equal(result.head, sha);
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
test('changed file list has an independently scrollable bounded layout', async () => {
const css = await readFile(new URL('../src/renderer/styles.css', import.meta.url), 'utf8');
assert.match(css, /\.main-canvas\.repository-canvas\s*\{[^}]*overflow:\s*hidden/);
assert.match(css, /\.file-panel\s*\{[^}]*min-height:\s*0[^}]*overflow:\s*hidden/);
assert.match(css, /\.file-list\s*\{[^}]*flex:\s*1 1 auto[^}]*overflow-y:\s*auto/);
});
test('commit workflow explains every disabled prerequisite', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /Commit message <span class="required-mark">required/);
assert.match(renderer, /Enter a commit message to enable commit and push/);
assert.match(renderer, /ForgeFlow stages the selected files automatically/);
assert.match(renderer, /Commit selected & push to Gitea/);
});
test('ITWorx branding is integrated into titlebar and setup', async () => {
const renderer = await readFile(new URL('../src/renderer/app.js', import.meta.url), 'utf8');
assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark\.png/);
});
+18
View File
@@ -0,0 +1,18 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import matching from '../src/shared/repository-match.cjs';
const { normalizeRemoteUrl, matchRemoteToRepository } = matching;
const repositories = [{ full_name: 'jens/forgeflow', name: 'forgeflow', owner: { login: 'jens' } }];
test('normalizes HTTPS remotes', () => {
assert.deepEqual(normalizeRemoteUrl('https://gitea.internal/jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' });
});
test('normalizes SCP-style SSH remotes', () => {
assert.deepEqual(normalizeRemoteUrl('git@gitea.internal:jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' });
});
test('matches local remote to Gitea full name', () => {
assert.equal(matchRemoteToRepository('git@gitea.internal:jens/forgeflow.git', repositories)?.full_name, 'jens/forgeflow');
});
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import monitorModule from '../src/main/repository-monitor.cjs';
const { RepositoryMonitor } = monitorModule;
test('repository monitor establishes a baseline and emits only on later changes', async () => {
let revision = 1;
const changes = [];
const git = {
status: async (localPath) => ({ localPath, revision }),
statusFingerprint: (status) => String(status.revision)
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
monitor.setPaths(['/repo']);
await monitor.tick();
assert.equal(changes.length, 0);
revision = 2;
await monitor.tick();
assert.equal(changes.length, 1);
assert.equal(changes[0].reason, 'working-tree-changed');
monitor.pause('/repo');
revision = 3;
await monitor.tick();
assert.equal(changes.length, 1);
monitor.resume('/repo');
await monitor.tick();
assert.equal(changes.length, 2);
});
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import validation from '../src/shared/validation.cjs';
import redaction from '../src/main/log-redaction.cjs';
const {
normalizeBaseUrl,
assertRepositoryRelativePath,
assertRepositoryRelativePaths,
assertFullCommitSha,
assertWorkflowFile,
assertWorkflowFileName,
assertBranchName,
assertEnvironmentName,
assertHttpUrl,
assertCloneRemote
} = validation;
const { redactSecrets } = redaction;
test('rejects credentials embedded in service URLs', () => {
assert.throws(() => normalizeBaseUrl(`https://${['jens', 'secret'].join(':')}@gitea.example.test`), /credentials/i);
assert.throws(() => assertHttpUrl(`https://${['user', 'secret'].join(':')}@app.example.test/health`), /credentials/i);
});
test('accepts repository-relative paths but blocks escapes and absolute paths', () => {
assert.equal(assertRepositoryRelativePath('./src/main.ts'), 'src/main.ts');
assert.deepEqual(assertRepositoryRelativePaths(['src/main.ts', 'src/main.ts', 'docs/readme.md']), ['src/main.ts', 'docs/readme.md']);
assert.throws(() => assertRepositoryRelativePath('../secrets.txt'), /escape/i);
assert.throws(() => assertRepositoryRelativePath('/etc/passwd'), /absolute/i);
assert.throws(() => assertRepositoryRelativePath('C:\\Windows\\win.ini'), /absolute/i);
});
test('validates full commit SHAs and workflow filenames', () => {
const sha = 'A'.repeat(40);
assert.equal(assertFullCommitSha(sha), 'a'.repeat(40));
assert.equal(assertWorkflowFile('.gitea/workflows/deploy.yml'), '.gitea/workflows/deploy.yml');
assert.throws(() => assertFullCommitSha('abc1234'), /full commit SHA/i);
assert.throws(() => assertWorkflowFile('../deploy.yml'), /escape/i);
assert.throws(() => assertWorkflowFile('deploy.sh'), /YAML/i);
assert.equal(assertWorkflowFileName('deploy.yml'), 'deploy.yml');
assert.throws(() => assertWorkflowFileName('.gitea/workflows/deploy.yml'), /filename/i);
});
test('allows supported Git remotes and rejects unsafe protocols/passwords', () => {
assert.equal(assertCloneRemote('git@gitea.example.test:jens/app.git'), 'git@gitea.example.test:jens/app.git');
assert.equal(assertCloneRemote('ssh://git@gitea.example.test/jens/app.git'), 'ssh://git@gitea.example.test/jens/app.git');
assert.throws(() => assertCloneRemote('file:///tmp/repo.git'), /unsupported/i);
assert.throws(() => assertCloneRemote(`https://${['jens', 'secret'].join(':')}@gitea.example.test/jens/app.git`), /password/i);
});
test('redacts known tokens, authorization headers, query tokens and URL passwords', () => {
const token = 'super-secret-token';
const source = `Authorization: token ${token}\nhttps://gitea.test/api?access_token=${token}\nhttps://${['jens', 'password'].join(':')}@gitea.test\n${token}`;
const result = redactSecrets(source, [token]);
assert.doesNotMatch(result, /super-secret-token|password/);
assert.match(result, /\[REDACTED\]/);
});
test('validates deployment branch and environment identifiers', () => {
assert.equal(assertBranchName('release/staging'), 'release/staging');
assert.equal(assertEnvironmentName('Production-EU'), 'production-eu');
assert.throws(() => assertBranchName('-dangerous'), /invalid/i);
assert.throws(() => assertBranchName('main..backup'), /invalid/i);
assert.throws(() => assertEnvironmentName('production eu'), /environment/i);
});
+13
View File
@@ -0,0 +1,13 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { parseVersion, compareVersions, isNewerVersion } = require('../src/shared/semver.cjs');
test('semantic versions are compared without lexical mistakes', () => {
assert.equal(parseVersion('v0.4.0').minor, 4);
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
assert.equal(isNewerVersion('0.4.1', '0.4.0'), true);
assert.equal(isNewerVersion('0.4.0-beta.1', '0.4.0'), false);
});
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, copyFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
import shellVerification from '../src/shared/shell-verification.cjs';
const { bashSyntaxCheckInvocation, normalizeRelativePosixPath } = shellVerification;
test('Bash syntax validation keeps Windows project roots in cwd and passes a relative POSIX path', () => {
const invocation = bashSyntaxCheckInvocation('C:\\Projects\\ForgeFlow');
assert.equal(invocation.command, 'bash');
assert.deepEqual(invocation.args, ['-n', 'examples/server/forgeflow-deploy']);
assert.equal(invocation.options.cwd, 'C:\\Projects\\ForgeFlow');
assert.equal(invocation.args[1].includes('\\'), false);
assert.equal(/^[A-Za-z]:/.test(invocation.args[1]), false);
});
test('Shell validation refuses absolute and escaping script paths', () => {
assert.throws(() => normalizeRelativePosixPath('C:\\Projects\\ForgeFlow\\script.sh'), /must be relative/);
assert.throws(() => normalizeRelativePosixPath('/tmp/script.sh'), /must be relative/);
assert.throws(() => normalizeRelativePosixPath('../script.sh'), /may not escape/);
});
test('Bash syntax validation works from a project root containing spaces', async (t) => {
if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) {
t.skip('Bash is not available in this environment.');
return;
}
const tempBase = await mkdtemp(path.join(os.tmpdir(), 'forge flow verify '));
try {
const relativeDirectory = path.join(tempBase, 'examples', 'server');
await mkdir(relativeDirectory, { recursive: true });
await copyFile(new URL('../examples/server/forgeflow-deploy', import.meta.url), path.join(relativeDirectory, 'forgeflow-deploy'));
const invocation = bashSyntaxCheckInvocation(tempBase);
const result = spawnSync(invocation.command, invocation.args, invocation.options);
assert.equal(result.status, 0, result.stderr);
} finally {
try {
await rm(tempBase, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
} catch (error) {
// Git Bash on Windows can retain a short-lived working-directory handle
// after bash -n exits. Do not fail a successful syntax test solely because
// Windows delayed releasing that temporary directory.
if (!['EBUSY', 'EPERM', 'ENOTEMPTY'].includes(error?.code)) throw error;
}
}
});
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import toolInvocation from '../src/shared/tool-invocation.cjs';
const { npmProbeCandidates } = toolInvocation;
test('uses npm CLI through Node when doctor is launched by npm on Windows', () => {
const candidates = npmProbeCandidates({
platform: 'win32',
execPath: 'C:\\Program Files\\nodejs\\node.exe',
env: {
npm_execpath: 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js',
npm_node_execpath: 'C:\\Program Files\\nodejs\\node.exe',
ComSpec: 'C:\\Windows\\System32\\cmd.exe'
}
});
assert.deepEqual(candidates[0], {
file: 'C:\\Program Files\\nodejs\\node.exe',
args: ['C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', '--version'],
source: 'npm_execpath'
});
});
test('falls back to cmd.exe for npm command shims on Windows', () => {
const candidates = npmProbeCandidates({
platform: 'win32',
execPath: 'C:\\Program Files\\nodejs\\node.exe',
env: { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
});
assert.deepEqual(candidates, [{
file: 'C:\\Windows\\System32\\cmd.exe',
args: ['/d', '/s', '/c', 'npm --version'],
source: 'windows-command-shim'
}]);
});
test('uses npm directly on non-Windows systems', () => {
assert.deepEqual(npmProbeCandidates({ platform: 'linux', env: {}, execPath: '/usr/bin/node' }), [
{ file: 'npm', args: ['--version'], source: 'path' }
]);
});
+126
View File
@@ -0,0 +1,126 @@
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, 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: '' }),
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);
});
+52
View File
@@ -0,0 +1,52 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { UpdateService } = require('../src/main/update-service.cjs');
test('update check pins version to an exact branch commit', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-'));
const saved = [];
const store = {
data: {
gitea: { baseUrl: 'https://gitea.example.test' },
updates: { owner: 'Jens', repo: 'ForgeFlow', branch: 'main', autoCheck: true }
},
async save() { saved.push(true); }
};
const calls = [];
const gitea = {
async getBranch(owner, repo, branch) {
calls.push(['branch', owner, repo, branch]);
return { commit: { id: 'a'.repeat(40) } };
},
async getRepositoryFile(input) {
calls.push(['file', input]);
return { decoded: JSON.stringify({ name: 'forgeflow', version: '0.4.1' }) };
}
};
const service = new UpdateService({
store, gitea, diagnostics: null,
appInfo: { version: '0.4.0', packaged: false },
sourcePath: temp, userDataPath: temp
});
const result = await service.check();
assert.equal(result.available, true);
assert.equal(result.remoteSha, 'a'.repeat(40));
assert.equal(calls[1][1].ref, 'a'.repeat(40));
assert.equal(saved.length, 1);
await rm(temp, { recursive: true, force: true });
});
test('update repository parts reject path injection', async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-update-test-'));
const service = new UpdateService({
store: { data: { updates: { owner: '../Jens', repo: 'ForgeFlow', branch: 'main' } }, save: async () => {} },
gitea: {}, diagnostics: null, appInfo: { version: '0.4.0', packaged: false }, sourcePath: temp, userDataPath: temp
});
await assert.rejects(() => service.check(), /unsupported characters/);
await rm(temp, { recursive: true, force: true });
});
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import validation from '../src/shared/validation.cjs';
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest } = validation;
test('normalizes Gitea base URL', () => {
assert.equal(normalizeBaseUrl('https://gitea.example.com/'), 'https://gitea.example.com');
});
test('rejects blank commit messages', () => {
assert.throws(() => assertCommitMessage(' '), /commit message/i);
});
test('requires exact SHA and workflow profile', () => {
assert.throws(() => assertDeploymentRequest({ branch: 'main', workflowFile: 'deploy.yml' }, 'nope'), /commit SHA/i);
});
+41
View File
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import zlib from 'node:zlib';
import zipModule from '../src/shared/zip-writer.cjs';
const { createZip, crc32 } = zipModule;
function unzipLocalEntries(buffer) {
const entries = new Map();
let offset = 0;
while (offset + 4 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) {
const method = buffer.readUInt16LE(offset + 8);
const expectedCrc = buffer.readUInt32LE(offset + 14);
const compressedSize = buffer.readUInt32LE(offset + 18);
const nameLength = buffer.readUInt16LE(offset + 26);
const extraLength = buffer.readUInt16LE(offset + 28);
const nameStart = offset + 30;
const dataStart = nameStart + nameLength + extraLength;
const name = buffer.subarray(nameStart, nameStart + nameLength).toString('utf8');
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
const data = method === 8 ? zlib.inflateRawSync(compressed) : compressed;
assert.equal(crc32(data), expectedCrc);
entries.set(name, data);
offset = dataStart + compressedSize;
}
return entries;
}
test('creates a valid deflated ZIP with UTF-8 entry names and CRCs', () => {
const archive = createZip([
{ name: 'manifest.json', data: '{"ok":true}\n' },
{ name: 'logs/diagnostics.jsonl', data: Buffer.from('hello diagnostics\n') },
{ name: 'unicode/één.txt', data: 'veilig' }
]);
assert.equal(archive.readUInt32LE(0), 0x04034b50);
assert.equal(archive.readUInt32LE(archive.length - 22), 0x06054b50);
const entries = unzipLocalEntries(archive);
assert.equal(entries.size, 3);
assert.equal(entries.get('manifest.json').toString(), '{"ok":true}\n');
assert.equal(entries.get('unicode/één.txt').toString(), 'veilig');
});