Update
This commit is contained in:
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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' }
|
||||
]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user