Publish curated ForgeFlow source from 2ed1787c0b52
ForgeFlow quality gate / quality (push) Successful in 4m12s
ForgeFlow quality gate / secret-scan (push) Successful in 7s

This commit is contained in:
NuklearRabbit committed 2026-09-29 22:50:57 +02:00
commit f60b269686
254 files changed
+46204

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readAcceptanceConfig } from '../scripts/acceptance.mjs';
test('acceptance harness requires an explicit complete environment', () => {
assert.throws(() => readAcceptanceConfig({}), /Missing acceptance environment variables/);
const config = readAcceptanceConfig({ FORGEFLOW_GITEA_URL: 'https://gitea.test/', FORGEFLOW_GITEA_TOKEN: 'token', FORGEFLOW_REPOSITORY: 'owner/app', FORGEFLOW_LOCAL_PATH: 'C:/Projects/App', FORGEFLOW_BRANCH: 'main', FORGEFLOW_STATUS_URL: 'https://app.test/status', FORGEFLOW_HEALTH_URL: 'https://app.test/health' });
assert.equal(config.baseUrl, 'https://gitea.test');
assert.equal(config.workflow, 'deploy.yml');
assert.throws(() => readAcceptanceConfig({ ...process.env, FORGEFLOW_GITEA_URL: 'x', FORGEFLOW_GITEA_TOKEN: 'x', FORGEFLOW_REPOSITORY: 'invalid', FORGEFLOW_LOCAL_PATH: 'x', FORGEFLOW_BRANCH: 'x', FORGEFLOW_STATUS_URL: 'x', FORGEFLOW_HEALTH_URL: 'x' }), /owner\/repository/);
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const workflowUrl = new URL('../examples/gitea-actions/forgeflow-approved-deploy.yml', import.meta.url);
const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url);
test('central approved workflow transports signed target evidence only to the root-owned deploy wrapper', async () => {
const workflow = await readFile(workflowUrl, 'utf8');
for (const input of [
'repository',
'environment',
'commit_sha',
'request_id',
'approval_id',
'approval_fingerprint',
'evidence_issued_at',
'evidence_signature',
]) {
assert.match(workflow, new RegExp(`\\b${input}:`));
}
assert.match(workflow, /\$\{\{ inputs\.repository \}\}/);
assert.doesNotMatch(workflow, /\$\{\{ gitea\.repository \}\}/);
assert.match(workflow, /FF_APPROVAL_ID.*FF_REQUEST_ID/s);
assert.match(workflow, /sudo \/usr\/local\/bin\/forgeflow-deploy/);
assert.doesNotMatch(workflow, /actions\/checkout/);
assert.doesNotMatch(workflow, /docker compose/);
assert.doesNotMatch(workflow, /git\s+-C/);
});
test('server wrapper verifies Ed25519 evidence before any live git or compose mutation', async () => {
const script = await readFile(deployUrl, 'utf8');
const verifyIndex = script.indexOf('openssl pkeyutl -verify');
const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"');
const composeIndex = script.indexOf('docker compose -f "$COMPOSE_FILE" up -d --build');
assert.ok(verifyIndex > 0, 'expected cryptographic verification');
assert.ok(resetIndex > verifyIndex, 'git reset must happen after evidence verification');
assert.ok(composeIndex > verifyIndex, 'compose mutation must happen after evidence verification');
assert.match(script, /EVIDENCE_PUBLIC_KEY_FILE="\/etc\/forgeflow\/evidence\.pub"/);
assert.match(script, /evidence_owner.*root/s);
assert.match(script, /8#022/);
assert.match(script, /EVIDENCE_ISSUED_AT >= now_epoch - 1800/);
assert.match(script, /"evidence_verified": \$EVIDENCE_VERIFIED/);
assert.match(script, /\(\( \$# == 3 \|\| \$# == 4 \|\| \$# == 8 \)\)/);
});
test('signed message fields match the AppOps evidence v1 contract and exclude runner-chosen workflow/ref', async () => {
const script = await readFile(deployUrl, 'utf8');
const marker = "printf 'forgeflow-evidence-v1\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n%s\\n'";
assert.ok(script.includes(marker));
assert.match(
script,
/"\$APPROVAL_ID"[\s\\]+"\$APPROVAL_FINGERPRINT"[\s\\]+"\$REPOSITORY"[\s\\]+"\$ENVIRONMENT"[\s\\]+"\$\{SHA,,\}"[\s\\]+"\$REQUEST_ID"[\s\\]+"\$EVIDENCE_ISSUED_AT"/s,
);
assert.doesNotMatch(script.slice(0, script.indexOf('APP_DIR=""')), /WORKFLOW|workflow|ref=/);
});
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const deployUrl = new URL('../examples/server/forgeflow-deploy', import.meta.url);
test('a signed approved request is consumed once before target selection or mutation', async () => {
const script = await readFile(deployUrl, 'utf8');
const verifyIndex = script.indexOf('openssl pkeyutl -verify');
const consumeIndex = script.indexOf('mkdir -m 0700 "$EVIDENCE_REPLAY_DIR/$APPROVAL_ID"');
const targetIndex = script.indexOf('APP_DIR=""');
const resetIndex = script.indexOf('git -C "$APP_DIR" reset --hard "$SHA"');
assert.ok(verifyIndex > 0);
assert.ok(consumeIndex > verifyIndex);
assert.ok(targetIndex > consumeIndex);
assert.ok(resetIndex > consumeIndex);
assert.match(script, /EVIDENCE_REPLAY_DIR="\/var\/lib\/forgeflow-status\/approved-requests"/);
assert.match(script, /install -d -o root -g root -m 0700 "\$EVIDENCE_REPLAY_DIR"/);
assert.match(script, /Approved deployment evidence was already consumed/);
});
+25
View File
@@ -0,0 +1,25 @@
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 auditModule from '../src/main/audit-service.cjs';
const { AuditService } = auditModule;
test('audit service appends ordered records and exports CSV', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-audit-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const audit = new AuditService({ userDataPath: root, appInfo: { version: 'test' } });
await Promise.all([
audit.append('deployment.requested', { repository: 'owner/app', sha: 'a'.repeat(40), note: 'Release, wave 1' }),
audit.append('deployment.completed', { repository: 'owner/app', result: 'success' })
]);
const entries = await audit.list();
assert.equal(entries.length, 2);
assert.equal(entries[0].event, 'deployment.completed');
const destination = path.join(root, 'audit.csv');
const result = await audit.exportTo(destination, 'csv');
assert.equal(result.count, 2);
assert.match(await fs.readFile(destination, 'utf8'), /"Release, wave 1"/);
});
+362
View File
@@ -0,0 +1,362 @@
import { test, expect } from "@playwright/test";
import { writeFile } from "node:fs/promises";
const consoleEntries = new WeakMap();
const runtimeErrors = new WeakMap();
test.beforeEach(async ({ page }, testInfo) => {
const logs = [];
const errors = [];
consoleEntries.set(page, logs);
runtimeErrors.set(page, errors);
await page.emulateMedia({ colorScheme: testInfo.project.metadata.theme, reducedMotion: testInfo.project.metadata.reduced ? "reduce" : "no-preference" });
page.on("console", (message) => logs.push({ type: message.type(), text: message.text() }));
page.on("pageerror", (error) => errors.push({ name: error.name, message: error.message, stack: error.stack }));
await page.goto("/");
await expect(page.locator(".app-shell")).toBeVisible();
const theme = testInfo.project.metadata.theme;
await page.evaluate((requested) => {
document.documentElement.dataset.theme = requested;
localStorage.setItem("forgeflow-demo-theme", requested);
}, theme);
});
test.afterEach(async ({ page }, testInfo) => {
const logs = consoleEntries.get(page) || [];
const errors = runtimeErrors.get(page) || [];
if (testInfo.status !== testInfo.expectedStatus) {
const prefix = testInfo.outputPath("failure");
await writeFile(`${prefix}-console.json`, JSON.stringify({ test: testInfo.title, project: testInfo.project.name, metadata: testInfo.project.metadata, logs, errors }, null, 2));
await writeFile(`${prefix}-dom.html`, await page.content());
await writeFile(`${prefix}-fixture.json`, JSON.stringify({ url: page.url(), viewport: page.viewportSize(), theme: await page.locator("html").getAttribute("data-theme") }, null, 2));
}
expect(errors, "page errors").toEqual([]);
expect(logs.filter((entry) => entry.type === "error"), "console errors").toEqual([]);
});
async function assertSurface(page) {
const audit = await page.evaluate(() => {
const interactive = [...document.querySelectorAll('button,input,select,textarea,a[href],[role="button"]')].filter((element) => {
const style = getComputedStyle(element);
return style.display !== "none" && style.visibility !== "hidden" && element.getBoundingClientRect().width > 0;
});
const unnamed = interactive.filter((element) => !String(element.getAttribute("aria-label") || element.getAttribute("title") || element.labels?.[0]?.textContent || element.textContent || element.value || "").trim());
const outside = interactive.filter((element) => { const rect = element.getBoundingClientRect(); const fixed = ["fixed", "sticky"].includes(getComputedStyle(element).position) || Boolean(element.closest('[role="dialog"]')); return rect.left < -1 || rect.right > innerWidth + 1 || (fixed && (rect.top < -1 || rect.bottom > innerHeight + 1)); });
const text = document.body.innerText;
return {
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
unnamed: unnamed.map((element) => element.outerHTML.slice(0, 180)),
outside: outside.map((element) => element.outerHTML.slice(0, 180)),
badTokens: ["undefined", "[object Object]", "â", "Â", "Ã"].filter((token) => text.includes(token)),
nullText: /(^|\s)null($|\s)/i.test(text),
headings: [...document.querySelectorAll("h1,h2,h3")].map((heading) => Number(heading.tagName[1])),
};
});
expect(audit.horizontalOverflow).toBe(false);
expect(audit.unnamed).toEqual([]);
expect(audit.outside).toEqual([]);
expect(audit.badTokens).toEqual([]);
expect(audit.nullText).toBe(false);
expect(audit.headings.length).toBeGreaterThan(0);
}
async function assertScrollableWhenOverflowing(page, selector) {
const target = page.locator(selector);
await expect(target).toBeVisible();
await expect(target).toHaveCSS("overflow-y", /auto|scroll/);
let metrics;
await expect.poll(async () => {
metrics = await target.evaluate((element) => ({
connected: element.isConnected,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
return metrics.connected && metrics.clientHeight > 0;
}).toBe(true);
if (metrics.scrollHeight > metrics.clientHeight + 1) {
await expect.poll(() => target.evaluate((element) => {
if (element.scrollHeight <= element.clientHeight + 1) return 1;
element.scrollTop = element.scrollHeight;
return element.scrollTop;
})).toBeGreaterThan(0);
}
}
test("shell, overview, repositories and settings remain responsive and accessible", async ({ page }, testInfo) => {
await assertSurface(page);
for (const view of ["overview", "deployments", "settings", "help"]) {
await page.locator(`.nav-button[data-action="navigate"][data-view="${view}"]`).click();
await expect(page.locator("main")).toBeVisible();
await assertSurface(page);
}
await expect(page.locator("html")).toHaveAttribute("data-theme", String(testInfo.project.metadata.theme));
if (testInfo.project.metadata.reduced) {
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
}
});
test("repository changes, Git tools and Git Validator complete their primary flow", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
await expect(page.locator('[data-action="repo-tab"]')).toHaveCount(6);
for (const tab of ["changes", "history", "deployments", "gittools", "validator", "settings"]) {
const control = page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`);
if (await control.count()) {
await control.click();
await expect(control).toHaveClass(/active/);
await assertSurface(page);
}
}
await page.locator('[data-action="repo-tab"][data-tab="validator"]').click();
await expect(page.locator(".validator-score")).toBeVisible();
await assertScrollableWhenOverflowing(page, ".validator-page");
await expect(page.locator("#validator-policy")).toBeVisible();
await page.locator("#validator-policy").selectOption("production");
await expect(page.locator(".validator-hero")).toContainText(/Production policy/i);
await expect(page.locator(".validator-hero")).toContainText(/review required/i);
await page.keyboard.press("Tab");
await expect(page.locator(":focus")).toBeVisible();
});
test("repository context, tabs and content never overlap in a compact workspace", async ({ page }) => {
await page.setViewportSize({ width: 1024, height: 768 });
await page.locator('[data-action="select-repo"][data-deployment-count]:not([data-deployment-count="0"])').first().click();
await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click();
const layout = await page.evaluate(() => {
const context = document.querySelector(".repo-context")?.getBoundingClientRect();
const tabs = document.querySelector(".tabs")?.getBoundingClientRect();
const content = document.querySelector(".repo-content")?.getBoundingClientRect();
return {
contextEndsBeforeTabs: Boolean(context && tabs && context.bottom <= tabs.top + 0.5),
tabsEndBeforeContent: Boolean(tabs && content && tabs.bottom <= content.top + 0.5),
horizontalTabFallback: Boolean(tabs && document.querySelector(".tabs").scrollWidth >= document.querySelector(".tabs").clientWidth),
};
});
expect(layout).toEqual({ contextEndsBeforeTabs: true, tabsEndBeforeContent: true, horizontalTabFallback: true });
});
test("Help center is searchable and contextual guidance opens the requested topic", async ({ page }) => {
await page.locator('.nav-button[data-view="help"]').click();
await expect(page.getByRole("heading", { name: "How can we help?" })).toBeVisible();
await expect(page.locator(".help-topic")).toHaveCount(8);
await page.locator("#help-search").fill("deploy key");
await expect(page.locator(".help-topic")).toHaveCount(1);
await expect(page.locator(".help-topic")).toContainText("Repair repository deploy keys");
await page.locator('[data-action="select-repo"]').first().click();
await page.locator('[data-action="repo-tab"][data-tab="gittools"]').click();
await page.locator('[data-action="open-context-help"][data-topic="workspace-sync"]').click();
await expect(page.locator('[data-help-topic="workspace-sync"]')).toHaveAttribute("open", "");
await expect(page.locator('[data-help-topic="workspace-sync"]')).toContainText("Make a local project match Gitea");
await assertSurface(page);
});
test("every long application surface retains a working vertical scroll owner", async ({ page }) => {
for (const view of ["overview", "deployments", "diagnostics", "settings", "help"]) {
await test.step(`${view} view scrolls`, async () => {
const navigation = page.locator(`.nav-button[data-view="${view}"]`);
await navigation.click();
await expect(navigation).toHaveClass(/active/);
await assertScrollableWhenOverflowing(page, ".main-canvas");
});
}
await page.locator('[data-action="select-repo"]').first().click();
for (const tab of ["history", "deployments", "gittools", "validator", "settings"]) {
await page.locator(`[data-action="repo-tab"][data-tab="${tab}"]`).click();
const scrollRoot = page.locator(".repo-content > .tab-page, .repo-content > .validator-page");
if (await scrollRoot.count())
await assertScrollableWhenOverflowing(page, ".repo-content > .tab-page, .repo-content > .validator-page");
}
});
test("deployment inventory supports dense workloads without ambiguous blank cards", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="deployments"]').click();
await expect(page.locator(".deploy-card, .server-inventory-panel .tool-row").first()).toBeVisible();
const cards = page.locator(".deploy-card, .server-inventory-panel .tool-row");
const count = await cards.count();
expect(count).toBeGreaterThan(0);
for (let index = 0; index < Math.min(count, 25); index += 1) {
await expect(cards.nth(index)).not.toHaveText(/^\s*$/);
}
const unresolved = page.locator(".tool-row", { hasText: "Legacy Worker" });
await expect(unresolved).toContainText("Link unresolved");
await expect(unresolved).not.toContainText(/^Linked$/);
await expect(page.locator(".server-inventory-panel").first()).toContainText("1 unresolved");
const repositoryLink = page.locator('[data-action="open-deployment-link"]');
if (await repositoryLink.count()) {
await repositoryLink.first().click();
await expect(page.locator('.repo-row.active')).toHaveAttribute("data-deployment-count", /^[1-9]/);
await expect(page.locator('.repo-row.active .deployment-badge')).toBeVisible();
await expect(page.locator('.tab[data-action="repo-tab"][data-tab="deployments"]')).toHaveClass(/active/);
await expect(page.locator(".repository-workloads")).toBeVisible();
await expect(page.locator(".repository-workload-row").first()).toContainText("Repository linked");
}
await assertSurface(page);
});
test("dialogs expose semantics, labels, keyboard close and focus restoration", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
const trigger = page.locator('[data-action="edit-deployment-profile"], [data-action="add-deployment-profile"]').first();
if (await trigger.count()) {
await trigger.focus();
await trigger.click();
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible();
await expect(dialog.locator("button").first()).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
}
await assertSurface(page);
});
test("onboarding and updater states remain usable without an existing configuration", async ({ page }) => {
await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "false"));
await page.reload();
await expect(page.locator(".setup-window")).toBeVisible();
await expect(page.getByRole("heading", { name: "Check this computer" })).toBeVisible();
await page.locator('[data-action="setup-run-preflight"]').click();
await expect(page.locator('[data-action="setup-continue"]')).toBeEnabled();
await assertSurface(page);
await page.evaluate(() => localStorage.setItem("forgeflow-demo-setup", "true"));
await page.reload();
await page.locator('.nav-button[data-view="settings"]').click();
await page.locator('[data-action="check-updates"]').first().click();
await expect(page.locator(".update-card")).toContainText(/ForgeFlow/i);
await assertSurface(page);
});
test("inventory, deployment safety and failure evidence dialogs are reviewable", async ({ page }) => {
await page.locator('.nav-button[data-view="deployments"]').click();
const reconciliation = page.locator('[data-action="plan-server-reconciliation"]').first();
if (await reconciliation.count()) {
await reconciliation.click();
await expect(page.locator('[role="dialog"]')).toContainText(/reconciliation/i);
await page.keyboard.press("Escape");
}
const keyLifecycle = page.locator('[data-action="manage-deploy-key"]').first();
if (await keyLifecycle.count()) {
await keyLifecycle.click();
await expect(page.locator('[role="dialog"]')).toContainText(/Deploy key lifecycle/i);
await page.keyboard.press("Escape");
}
const preflight = page.locator('[data-action="run-deployment-preflight"]').first();
await preflight.click();
await expect(page.locator('[role="dialog"]')).toContainText(/preflight/i);
await page.keyboard.press("Escape");
const failed = page.locator('[data-action="open-operation"]').last();
if (await failed.count()) {
await failed.click();
await expect(page.locator('[role="dialog"]')).toContainText(/failed|failure|healthcheck/i);
await page.keyboard.press("Escape");
}
await assertSurface(page);
});
// A repository or deployment poll renders the whole shell again. Changing an
// unrelated part of the state is what a poll effectively does, and it must not
// take the caret or the scroll position away from the user.
async function forceUnrelatedRerender(page) {
await page.evaluate(() => {
ui.diagnosticsStatus = { ...(ui.diagnosticsStatus || {}), enabled: !(ui.diagnosticsStatus?.enabled === false) };
render();
});
}
test("a background refresh keeps typing and caret position intact", async ({ page }) => {
const search = page.locator("#global-search");
await search.click();
await search.fill("Forge");
// Typing schedules a debounced render. Wait for it, otherwise the caret below
// can land on the element that render is about to replace.
await expect.poll(() => page.evaluate(() => ui.inputRenderTimer === null)).toBe(true);
await search.evaluate((element) => element.setSelectionRange(1, 3));
await forceUnrelatedRerender(page);
await expect(search).toBeFocused();
expect(await search.inputValue()).toBe("Forge");
expect(await search.evaluate((element) => [element.selectionStart, element.selectionEnd])).toEqual([1, 3]);
});
test("a background refresh keeps scroll offsets intact", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="settings"]').click();
const canvas = page.locator(".main-canvas");
const scrolled = await canvas.evaluate((element) => {
element.scrollTop = Math.min(120, Math.max(0, element.scrollHeight - element.clientHeight));
return element.scrollTop;
});
expect(scrolled).toBeGreaterThan(0);
await forceUnrelatedRerender(page);
expect(await canvas.evaluate((element) => element.scrollTop)).toBe(scrolled);
});
test("sections that used to be injected after render are part of the rendered markup", async ({ page }) => {
await page.locator('.nav-button[data-action="navigate"][data-view="diagnostics"]').click();
const auditPanel = page.locator(".diagnostics-page .section-block", { hasText: "Operational audit log" });
await expect(auditPanel).toBeVisible();
await expect(auditPanel).toContainText("Load the operational audit log");
// The audit rows are state the shell renders itself now, so a plain render has
// to pick them up without any post-render injection step.
await page.evaluate(() => {
ui.auditEvents = [{ timestamp: new Date().toISOString(), event: "deployment.requested", details: { repository: "Jens/Probe", result: "queued" } }];
render();
});
await expect(auditPanel.locator("table.data-table")).toContainText("Jens/Probe");
await expect(auditPanel.locator("table.data-table")).toContainText("deployment.requested");
});
test("a very large diff is capped instead of freezing the window", async ({ page }) => {
const selected = await page.evaluate(() => {
const withChanges = ui.repositories.find((repository) => repository.localStatus?.counts?.changed);
if (!withChanges) return null;
selectRepository(withChanges.id);
return withChanges.fullName;
});
expect(selected, "the demo needs a repository with local changes").not.toBeNull();
await expect(page.locator(".diff-view")).toBeVisible();
// Selecting a repository loads its diff asynchronously; that load would
// otherwise overwrite the diff injected below.
await expect.poll(() => page.evaluate(() => Boolean(ui.diff) && !ui.diff.startsWith("Loading"))).toBe(true);
const measured = await page.evaluate(() => {
const newline = String.fromCharCode(10);
const lines = ["diff --git a/package-lock.json b/package-lock.json"];
for (let index = 0; index < 40_000; index += 1) lines.push(`+ "package-${index}": "^1.2.3",`);
ui.diff = lines.join(newline);
ui.repositoryTab = "changes";
const started = performance.now();
render();
return {
renderMs: performance.now() - started,
rendered: document.querySelectorAll(".diff-line").length,
storedLines: ui.diff.split(newline).length,
};
});
expect(measured.storedLines).toBe(40_001);
expect(measured.rendered).toBeLessThan(2100);
expect(measured.renderMs).toBeLessThan(3000);
await expect(page.locator(".diff-view")).toContainText("more lines are not shown");
});
test("an unchanged render leaves the existing DOM in place", async ({ page }) => {
await page.locator('[data-action="select-repo"]').first().click();
const marked = await page.evaluate(() => {
// Relative timestamps ("just now" turning into "1m ago") and pending async
// state legitimately change the markup between two renders that are seconds
// apart. Rendering twice inside one synchronous block removes that window,
// so the second render can only be skipped because nothing changed.
render();
document.querySelector(".repo-list").dataset.renderProbe = "kept";
render();
return document.querySelector(".repo-list")?.dataset.renderProbe || null;
});
expect(marked).toBe("kept");
const replaced = await page.evaluate(() => {
document.querySelector(".repo-list").dataset.renderProbe = "kept";
ui.repoSearch = `probe-${Date.now()}`;
render();
return document.querySelector(".repo-list")?.dataset.renderProbe || null;
});
expect(replaced).toBe(null);
});
+120
View File
@@ -0,0 +1,120 @@
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('a clone target that would leave the project root is refused', () => {
const root = path.join(os.tmpdir(), 'forgeflow-projects');
const resolved = path.resolve(root);
// The escape guard inside resolveCloneTarget stays as a backstop, but no
// sanitised folder name can reach it any more: the name is a single path
// segment and a dots-only segment falls back to "repository".
for (const remote of ['..', '.', '../escape', '/', '', '....git', 'https://gitea.example.test/jens/....git']) {
const plan = resolveCloneTarget(root, remote);
assert.ok(
plan.target.startsWith(`${resolved}${path.sep}`) && plan.target !== resolved,
`${remote} resolved outside the project root: ${plan.target}`,
);
}
for (const badRoot of ['', ' ', null, undefined]) {
assert.throws(() => resolveCloneTarget(badRoot, 'https://gitea.example.test/jens/app.git'), /project root is required/);
}
});
test('a folder name that sanitises away still produces a usable directory', () => {
// Windows strips trailing dots, so a dots-only name would land on the project
// root itself instead of a subdirectory.
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/....git'), 'repository');
assert.equal(cloneDirectoryName('..'), 'repository');
assert.equal(cloneDirectoryName(''), 'repository');
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/app.git#readme'), 'app');
assert.equal(cloneDirectoryName('https://gitea.example.test/jens/spaced name.git'), 'spaced-name');
});
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'
);
});
+275
View File
@@ -0,0 +1,275 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import configModule from "../src/main/config-store.cjs";
const { ConfigStore } = configModule;
async function storeFixture(t) {
const directory = await mkdtemp(path.join(os.tmpdir(), "forgeflow-config-store-"));
t.after(() => rm(directory, { recursive: true, force: true }));
return { directory, store: new ConfigStore(directory) };
}
test("config migration normalizes legacy deployments, inventory and validator state", async (t) => {
const { store } = await storeFixture(t);
const migrated = store.migrate({
schemaVersion: 2,
workspaceRoots: [" C:/Projects ", "C:/Projects", ""],
favorites: ["Owner/App", "owner/app"],
inventoryReviewDecisions: { server: [{ workloadId: "one" }] },
gitValidator: { policies: { "owner/app": { id: "strict" } }, suppressions: { "owner/app": [{ checkId: "x" }] }, trends: { "owner/app": [{ score: 70 }] } },
deploymentProfiles: {
"owner/app": [{ id: "legacy", provider: "ssh-unraid", remoteFolder: "MyApp", composeFile: "compose.yml", containerName: "MyApp", iconUrl: "https://itworx.tech/assets/itworx-icon.png", serverGitAccess: { deployKeyId: "4" } }],
},
operations: [{ id: "op", runnerLog: "secret", status: "success" }],
});
assert.equal(migrated.schemaVersion, 13);
assert.deepEqual(migrated.workspaceRoots, ["C:/Projects"]);
assert.deepEqual(migrated.favorites, ["owner/app"]);
const profile = migrated.deploymentProfiles["owner/app"][0];
assert.equal(profile.deploymentMode, "push-bundle");
assert.equal(profile.composeService, "myapp");
assert.equal(profile.iconMode, "builtin");
assert.equal("runnerLog" in migrated.operations[0], false);
assert.equal(migrated.gitValidator.policies["owner/app"].id, "strict");
});
test("load creates missing config and recovers malformed JSON", async (t) => {
const { directory, store } = await storeFixture(t);
let state = await store.load();
assert.equal(state.schemaVersion, 13);
assert.equal(JSON.parse(await readFile(store.filePath, "utf8")).appearance, "dark");
await writeFile(store.filePath, "{ malformed", "utf8");
state = await store.load();
assert.equal(state.setupComplete, false);
assert.ok((await readdir(directory)).some((name) => name.includes(".corrupt-")));
});
test("server normalization rejects unsafe targets and preserves bounded scan configuration", async (t) => {
const { store } = await storeFixture(t);
assert.throws(() => store.normalizeServer({ host: "bad host", username: "root" }), /hostname/);
assert.throws(() => store.normalizeServer({ host: "unraid", username: "bad user" }), /username/);
assert.throws(() => store.normalizeServer({ host: "unraid", username: "root", basePath: "relative" }), /absolute Unix/);
const server = store.normalizeServer({ host: "unraid.local", username: "root", port: 70000, basePath: "/mnt/user/appdata/", scanRoots: ["/mnt/user/appdata/", "relative"], scanExcludes: ["backup*", "bad/path"], authType: "privateKey", privateKeyPath: "C:/key" });
assert.equal(server.port, 65535);
assert.deepEqual(server.scanRoots, ["/mnt/user/appdata"]);
assert.deepEqual(server.scanExcludes, ["backup*"]);
store.data.servers = [{ ...server, encryptedPassword: "hidden", encryptedPassphrase: "hidden" }];
assert.equal(store.getPublicServer(store.data.servers[0]).hasPassphrase, true);
assert.equal("encryptedPassword" in store.getPublicState().servers[0], false);
assert.throws(() => store.getServerCredentials("missing"), /no longer exists/);
});
test("deployment profiles validate both Gitea Actions and safe Unraid topology", async (t) => {
const { store } = await storeFixture(t);
const actions = await store.saveDeploymentProfile("Owner/App", { id: "actions", environment: "production", branch: "main", provider: "gitea-actions", workflowFile: "deploy.yml", rollbackWorkflowFile: "rollback.yml", statusUrl: "https://app.test/status" });
assert.equal(actions.provider, "gitea-actions");
const unraid = await store.saveDeploymentProfile("Owner/App", { id: "unraid", name: "Production", environment: "production", branch: "main", provider: "ssh-unraid", serverId: "server", remoteFolder: "App", deploymentMode: "server-git", composeFiles: ["compose.yml"], composeServices: ["Web", "worker"], containerName: "Visible-App", cloneUrl: "git@gitea.test:owner/app.git", hostPort: 99999, containerPort: 0, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "none", dockerShell: "/bin/bash", preservePaths: [".env", "data"], composeProject: "App_prod", composeWorkingDir: "/mnt/user/appdata/App", serverGitAccess: { configured: true, deployKeyId: "42", keyFingerprint: "SHA256:key", hostFingerprint: "SHA256:host" } });
assert.equal(unraid.composeService, "web");
assert.deepEqual(unraid.composeServices, ["web", "worker"]);
assert.equal(unraid.hostPort, 65535);
assert.equal(unraid.containerPort, null);
assert.equal(unraid.serverGitAccess.deployKeyId, 42);
assert.equal(store.getDeploymentProfiles("owner/app").length, 2);
assert.equal(store.getDeploymentProfile("owner/app", "unraid").containerName, "Visible-App");
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", environment: "prod", branch: "main", remoteFolder: "../escape", composeFiles: ["compose.yml"] }), /escape|relative path|safe path/i);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", environment: "prod", branch: "main", remoteFolder: "app", composeFiles: ["compose.yml"], composeService: "bad service" }), /Compose service/);
await store.deleteDeploymentProfile("owner/app", "actions");
assert.equal(store.getDeploymentProfiles("owner/app").length, 1);
});
test("configuration mutations persist mappings, favorites, reviews, trends, operations and bounded preferences", async (t) => {
const { store } = await storeFixture(t);
await store.saveMapping("Owner/App", "C:/Projects/App");
assert.equal(store.data.repositoryMappings["owner/app"], "C:/Projects/App");
await store.setFavorite("Owner/App", true);
await store.setFavorite("Owner/App", false);
assert.deepEqual(store.data.favorites, []);
await store.setUpdatePreferences({ owner: " Team ", repo: " App ", branch: "release/1", autoCheck: false });
assert.equal(store.data.updates.branch, "release/1");
await store.saveInventoryReviewDecision("server", { workloadId: "workload", evidenceHash: "a".repeat(64), action: "monitor-only" });
assert.equal(store.getInventoryReviewDecisions("server").length, 1);
await store.deleteInventoryReviewDecision("server", "workload");
await assert.rejects(() => store.saveInventoryReviewDecision("", {}), /evidence hash/);
await store.setGitValidatorPolicy("Owner/App", { id: "production" });
await store.addGitValidatorSuppression("Owner/App", { checkId: "signed-tags" });
await store.appendGitValidatorTrend("Owner/App", { score: 81 });
assert.equal(store.getGitValidatorState("owner/app").trends[0].score, 81);
await store.saveDeploymentState("profile", { liveSha: "a".repeat(40) });
assert.equal(store.getDeploymentState("profile").liveSha.length, 40);
await store.addOperation({ id: "operation", status: "running" });
await store.addOperation({ id: "operation", status: "success" });
assert.equal(store.getOperation("operation").status, "success");
const state = await store.setPreferences({ repositoryPollSeconds: 0, operationPollSeconds: 999, fetchIntervalMinutes: 999, preferredCloneProtocol: "invalid", diagnosticLevel: "invalid", logRetentionDays: 0, maxLogFileMb: 100, editor: { executable: "code", args: ["{file}"] }, terminal: null, closeToTray: true, startAtLogin: true });
assert.equal(state.preferences.repositoryPollSeconds, 4);
assert.equal(state.preferences.operationPollSeconds, 120);
assert.equal(state.preferences.fetchIntervalMinutes, 240);
assert.equal(state.preferences.preferredCloneProtocol, "https");
assert.equal(state.preferences.diagnosticLevel, "info");
assert.equal(state.preferences.maxLogFileMb, 50);
const manualRemoteAwareness = await store.setPreferences({ fetchIntervalMinutes: 0 });
assert.equal(manualRemoteAwareness.preferences.fetchIntervalMinutes, 0);
await store.removeMapping("owner/app");
assert.equal(store.data.repositoryMappings["owner/app"], undefined);
});
test("server deletion removes only linked profiles and their deployment state", async (t) => {
const { store } = await storeFixture(t);
store.data.servers = [{ id: "remove", host: "old" }, { id: "keep", host: "new" }];
store.data.deploymentProfiles = {
"owner/app": [{ id: "old-profile", serverId: "remove" }, { id: "keep-profile", serverId: "keep" }],
"owner/only-old": [{ id: "only-old", serverId: "remove" }]
};
store.data.deploymentStates = { "old-profile": { healthy: true }, "only-old": { healthy: true }, "keep-profile": { healthy: true } };
await store.deleteServer("remove");
assert.deepEqual(store.data.servers.map((server) => server.id), ["keep"]);
assert.deepEqual(store.data.deploymentProfiles["owner/app"].map((profile) => profile.id), ["keep-profile"]);
assert.equal(store.data.deploymentProfiles["owner/only-old"], undefined);
assert.equal(store.data.deploymentStates["old-profile"], undefined);
assert.equal(store.data.deploymentStates["only-old"], undefined);
assert.equal(store.data.deploymentStates["keep-profile"].healthy, true);
});
test("configuration restore retains credentials only for unchanged endpoints and never restores operations", async (t) => {
const { store } = await storeFixture(t);
store.data.gitea = { baseUrl: "https://gitea.test", user: { login: "jens" }, encryptedToken: "encrypted-token" };
store.data.servers = [
{ ...store.normalizeServer({ id: "same", host: "same", username: "root", authType: "password" }), encryptedPassword: "password", encryptedPassphrase: null },
{ ...store.normalizeServer({ id: "changed", host: "old", username: "root", authType: "privateKey", privateKeyPath: "C:/old" }), encryptedPassword: null, encryptedPassphrase: "passphrase" }
];
store.data.operations = [{ id: "current-operation" }];
const backup = structuredClone(store.data);
backup.servers[1].host = "new";
backup.operations = [{ id: "untrusted-operation" }];
await store.restoreConfiguration(backup);
assert.equal(store.data.gitea.encryptedToken, "encrypted-token");
assert.equal(store.getServer("same").encryptedPassword, "password");
assert.equal(store.getServer("changed").encryptedPassphrase, null);
assert.deepEqual(store.data.operations, [{ id: "current-operation" }]);
await store.restoreConfiguration({ ...backup, gitea: { ...backup.gitea, baseUrl: "https://other.test" } });
assert.equal(store.data.gitea.encryptedToken, null);
});
test("profile, review and operation lookups return safe empty values", async (t) => {
const { store } = await storeFixture(t);
assert.equal(store.getPublicServer(null), null);
assert.equal(store.getServer("missing"), null);
assert.deepEqual(store.getDeploymentProfiles("missing/repo"), []);
assert.equal(store.getDeploymentProfile("missing/repo", "profile"), null);
assert.deepEqual(store.getInventoryReviewDecisions("missing"), []);
assert.equal(store.getDeploymentState("missing"), null);
assert.equal(store.getOperation("missing"), null);
assert.deepEqual(store.getGitValidatorState("missing/repo"), { policy: { id: "standard" }, suppressions: [], trends: [] });
await store.deleteInventoryReviewDecision("missing", "workload");
await store.deleteDeploymentProfile("missing/repo", "profile");
});
test("session credentials preserve, replace and clear safely when OS encryption is unavailable", async (t) => {
const { store } = await storeFixture(t);
assert.deepEqual(store.setToken(" session-token "), { persistent: false, preserved: false });
assert.equal(store.getToken(), "session-token");
assert.deepEqual(store.setToken("", { preserveExisting: true }), { persistent: false, preserved: true });
assert.equal(store.getToken(), "session-token");
assert.deepEqual(store.setToken("replacement"), { persistent: false, preserved: false });
assert.equal(store.getToken(), "replacement");
assert.deepEqual(store.setToken(""), { persistent: true, preserved: false });
assert.equal(store.getToken(), "");
assert.throws(() => store.encryptSecret("password"), (error) => error.code === "SECURE_STORAGE_UNAVAILABLE");
assert.equal(store.encryptSecret(""), null);
assert.equal(store.decryptSecret(null), "");
assert.equal(store.decryptSecret("not-base64-encrypted-data"), "");
});
test("setup, Gitea updates and generic patches retain normalized public state", async (t) => {
const { store } = await storeFixture(t);
const completed = await store.completeSetup({
baseUrl: "https://gitea.test", token: "token", user: { login: "jens" },
workspaceRoots: [" C:/Projects ", "C:/Projects", ""]
});
assert.equal(completed.state.setupComplete, true);
assert.equal(completed.state.gitea.hasToken, true);
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
await assert.rejects(
store.updateGitea({ baseUrl: "https://new.test", token: "", user: null }),
(error) => error.code === "GITEA_TOKEN_ORIGIN_CHANGED"
);
const update = await store.updateGitea({ baseUrl: "https://gitea.test", token: "", user: null });
assert.equal(update.preserved, true);
assert.equal(store.data.gitea.user.login, "jens");
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
assert.equal(patched.appearance, "light");
assert.deepEqual(patched.workspaceRoots, ["D:/Code"]);
assert.equal("encryptedToken" in patched.gitea, false);
});
test("server saves reject absent credentials before mutating configuration", async (t) => {
const { store } = await storeFixture(t);
await assert.rejects(
store.saveServer({ host: "unraid", username: "root", authType: "password", basePath: "/mnt/apps" }),
/password is required/i
);
await assert.rejects(
store.saveServer({ host: "unraid", username: "root", authType: "privateKey", basePath: "/mnt/apps", privateKeyPath: "" }),
/select a private key/i
);
assert.deepEqual(store.data.servers, []);
});
test("server credentials and trust are cleared when the connection identity changes", async (t) => {
const { store } = await storeFixture(t);
store.encryptSecret = (value) => `encrypted:${value}`;
const saved = await store.saveServer({ host: "server-one", username: "deploy", authType: "password", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" }, { password: "test-password" });
await assert.rejects(
store.saveServer({ ...saved, host: "server-two" }, {}),
/password is required/i
);
assert.equal(store.data.servers[0].host, "server-one");
const changed = await store.saveServer({ ...saved, host: "server-two" }, { password: "replacement-password" });
assert.equal(changed.hostFingerprint, "");
assert.equal(changed.hasPassword, true);
});
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
const { store } = await storeFixture(t);
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
assert.equal(actions.provider, "gitea-actions");
assert.equal(actions.name, "qa");
assert.equal(actions.branch, "main");
assert.equal(actions.workflowFile, "deploy.yml");
assert.equal(actions.rollbackWorkflowFile, "");
assert.equal(actions.confirmationRequired, true);
const unraid = store.normalizeDeploymentProfile({
id: "all-options", name: " Server ", environment: "production", provider: "ssh-unraid", branch: "release",
serverId: " server ", remoteFolder: "apps/App", deploymentMode: "monitor-only", generatedCompose: true,
composeFiles: [], composeServices: ["WEB", "Worker"], composeProject: "App.prod", composeWorkingDir: "/mnt/apps/App",
containerName: "Visible.App", cloneUrl: "https://gitea.test/Owner/App.git", alignRemote: true,
hostPort: -2, containerPort: 70000, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "upload",
iconFilePath: "C:/icon.png", dockerShell: "/bin/bash", preservePaths: [], adoptedFromServer: true,
serverSourceOfTruth: true, manageDockerMan: true, forceRecreate: true, removeOrphans: true,
workloadIdentity: { workloadId: "one" }, serverGitAccess: { configured: true, deployKeyId: "invalid", keyFingerprint: "", hostFingerprint: "", configuredAt: "now" },
provenance: { remoteFolder: "server" }, detectedMetadata: { source: "docker" }, serverIconReference: " icon ",
deploymentPolicy: { frozen: true, freezeReason: " maintenance ", requireNote: true, maintenanceWindows: [{ days: [0, 0, 6, 7, "bad"], start: "01:00", end: "02:00" }] }
});
assert.equal(unraid.name, "Server");
assert.equal(unraid.serverId, "server");
assert.equal(unraid.composeFile, "docker-compose.yml");
assert.deepEqual(unraid.composeServices, ["web", "worker"]);
assert.equal(unraid.hostPort, 1);
assert.equal(unraid.containerPort, 65535);
assert.equal(unraid.iconMode, "upload");
assert.equal(unraid.dockerShell, "/bin/bash");
assert.equal(unraid.serverGitAccess.deployKeyId, null);
assert.equal(unraid.serverGitAccess.keyFingerprint, null);
assert.deepEqual(unraid.deploymentPolicy.maintenanceWindows[0].days, [0, 6]);
assert.equal(unraid.serverIconReference, "icon");
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeService: "app", composeServices: ["bad service"] }), /Compose services/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeProject: "bad project!" }), /Compose project/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeWorkingDir: "relative" }), /working directory/);
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", containerName: "bad name" }), /Container name/);
});
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import backupModule from '../src/main/configuration-backup.cjs';
import configModule from '../src/main/config-store.cjs';
const { sanitizeConfiguration, createEncryptedBackup, readEncryptedBackup } = backupModule;
const { ConfigStore } = configModule;
test('configuration backups exclude credentials and operation history', () => {
const clean = sanitizeConfiguration({
gitea: { baseUrl: 'https://gitea.test', encryptedToken: 'secret-token' },
servers: [{ id: 'server', host: 'unraid.test', encryptedPassword: 'password', encryptedPassphrase: 'passphrase' }],
operations: [{ id: 'operation', sha: 'a'.repeat(40) }],
preferences: { autoRefresh: true }
});
assert.equal(clean.gitea.encryptedToken, null);
assert.equal('encryptedPassword' in clean.servers[0], false);
assert.equal('encryptedPassphrase' in clean.servers[0], false);
assert.deepEqual(clean.operations, []);
});
test('configuration backups round-trip with authenticated encryption', () => {
const serialized = createEncryptedBackup({ workspaceRoots: ['C:/Projects'], gitea: { encryptedToken: 'secret' } }, 'correct horse battery staple');
assert.doesNotMatch(serialized, /C:\/Projects|secret/);
const restored = readEncryptedBackup(serialized, 'correct horse battery staple');
assert.deepEqual(restored.configuration.workspaceRoots, ['C:/Projects']);
assert.equal(restored.configuration.gitea.encryptedToken, null);
assert.throws(() => readEncryptedBackup(serialized, 'incorrect passphrase'), /could not be decrypted/i);
});
test('recovery snapshots preserve the exact in-memory configuration before a mutation', async (context) => {
const directory = await mkdtemp(path.join(tmpdir(), 'forgeflow-config-snapshot-'));
context.after(() => rm(directory, { recursive: true, force: true }));
const store = new ConfigStore(directory);
store.data.workspaceRoots = ['C:/Projects'];
store.data.deploymentProfiles = { 'jens/example': [{ id: 'production', provider: 'gitea-actions' }] };
await store.save();
const before = `${JSON.stringify(store.data, null, 2)}\n`;
const snapshot = await store.createRecoverySnapshot('server reconciliation / production');
assert.equal(await readFile(snapshot.filePath, 'utf8'), before);
assert.equal(snapshot.reason, 'server-reconciliation-production');
});
+165
View File
@@ -0,0 +1,165 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { registerDeploymentIpc } = require("../src/main/ipc/deployment-handlers.cjs");
const { UnraidDeploymentService } = require("../src/main/unraid-deployment-service.cjs");
const REPOSITORY = { fullName: "Jens/ForgeFlow", owner: { login: "Jens" }, localPath: "C:/Projects/ForgeFlow" };
const WORKLOAD = { workloadId: "workload-1", classification: { type: "ambiguous" } };
// Every collaborator answers, so a channel can only fail on a dependency the
// module references but never receives.
function harness(overrides = {}) {
const calls = [];
const record = (name, result) => async (...args) => { calls.push({ name, args }); return typeof result === "function" ? result(...args) : result; };
const handlers = new Map();
const profile = overrides.profile || { id: "profile-1", provider: "ssh-unraid", branch: "main", name: "Production" };
const dependencies = {
register: (channel, handler) => handlers.set(channel, handler),
store: {
data: { servers: [{ id: "server-1", name: "Unraid" }], operations: [] },
getDeploymentProfile: () => profile,
getPublicState: () => ({ ok: true }),
saveDeploymentProfile: record("store.saveDeploymentProfile", profile),
deleteDeploymentProfile: record("store.deleteDeploymentProfile", []),
addOperation: record("store.addOperation", null),
},
resolveRepository: record("resolveRepository", REPOSITORY),
unraid: {
preflight: record("unraid.preflight", { ok: true }),
repairWriteAccess: record("unraid.repairWriteAccess", { changed: true, after: {}, before: {} }),
deploy: record("unraid.deploy", { id: "operation-1" }),
rollback: record("unraid.rollback", { id: "operation-2" }),
linkServerWorkload: record("unraid.linkServerWorkload", { linked: true }),
configureServerGitAccess: record("unraid.configureServerGitAccess", { keyFingerprint: "a", hostFingerprint: "b" }),
verifyServerGitProfile: record("unraid.verifyServerGitProfile", { readiness: "ready", ready: true, checkedAt: "now" }),
discoverServerWorkloads: record("unraid.discoverServerWorkloads", { serverId: "server-1", workloads: [] }),
planServerInventoryReconciliation: record("unraid.planServerInventoryReconciliation", { plan: { id: "plan-1", summary: {} } }),
reconcileServerInventory: record("unraid.reconcileServerInventory", { adopted: 0, refreshed: 0, retired: 0 }),
scanServerInventory: record("unraid.scanServerInventory", { workloads: [WORKLOAD] }),
refreshProfileState: record("unraid.refreshProfileState", { liveSha: null }),
applyDockerManMetadata: record("unraid.applyDockerManMetadata", { applied: true }),
refreshOperation: record("unraid.refreshOperation", null),
reconcileRecordedOperations: record("unraid.reconcileRecordedOperations", []),
},
deployments: {
deploy: record("deployments.deploy", { id: "operation-3" }),
rollback: record("deployments.rollback", { id: "operation-4" }),
checkHealth: record("deployments.checkHealth", { healthy: true }),
refreshProfileState: record("deployments.refreshProfileState", { liveSha: null }),
},
evaluateDeploymentPolicy: () => ({ note: "", overridden: false, reason: "", violations: [] }),
audit: { append: record("audit.append", null) },
deployKeys: {
inventory: record("deployKeys.inventory", { keys: [] }),
planRotation: record("deployKeys.planRotation", { id: "rotation-1" }),
rotate: record("deployKeys.rotate", { rotated: true }),
planRevocation: record("deployKeys.planRevocation", { id: "revocation-1" }),
revoke: record("deployKeys.revoke", { revoked: true }),
restore: record("deployKeys.restore", { restored: true }),
},
repositories: { refresh: record("repositories.refresh", [REPOSITORY]) },
inventoryReviews: {
preview: (...args) => { calls.push({ name: "inventoryReviews.preview", args }); return { id: "review-1" }; },
apply: record("inventoryReviews.apply", { applied: true }),
},
diagnostics: { info: record("diagnostics.info"), warning: record("diagnostics.warning"), error: record("diagnostics.error"), debug: record("diagnostics.debug") },
git: {},
gitea: { getBranch: record("gitea.getBranch", { commit: { id: "c".repeat(40) } }) },
ssh: {},
preflight: { runDeployment: record("preflight.runDeployment", { ok: "actions" }) },
...overrides.dependencies,
};
registerDeploymentIpc(dependencies);
return { handlers, calls, names: () => calls.map((item) => item.name) };
}
const PAYLOAD = {
repository: REPOSITORY,
fullName: REPOSITORY.fullName,
profileId: "profile-1",
sha: "a".repeat(40),
serverId: "server-1",
workloadId: WORKLOAD.workloadId,
planId: "plan-1",
action: "manual-link",
url: "https://app.example/health",
profile: { name: "Production" },
targetSha: "b".repeat(40),
};
// Both provider paths have to run: a dependency that only the Gitea Actions
// branch reads stays invisible while every channel is exercised as SSH/Unraid.
for (const provider of ["ssh-unraid", "gitea-actions"]) {
test(`every deployment IPC channel runs with the dependencies it is given (${provider})`, async () => {
const { handlers } = harness({ profile: { id: "profile-1", provider, branch: "main", name: "Production" } });
assert.ok(handlers.size >= 20, "expected the complete deployment channel surface");
const failures = [];
for (const [channel, handler] of handlers) {
try {
await handler({ ...PAYLOAD });
} catch (error) {
// A refusal is a decision the handler made; a missing dependency is not.
if (error instanceof ReferenceError || error instanceof TypeError) {
failures.push(`${channel}: ${error.name}: ${error.message}`);
}
}
}
assert.deepEqual(failures, []);
});
}
test("deployment preflight routes by provider", async () => {
const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } });
assert.deepEqual(await actions.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: "actions" });
assert.ok(actions.names().includes("preflight.runDeployment"));
const unraid = harness();
assert.deepEqual(await unraid.handlers.get("deployment:preflight")({ ...PAYLOAD }), { ok: true });
assert.ok(unraid.names().includes("unraid.preflight"));
assert.ok(!unraid.names().includes("preflight.runDeployment"));
});
test("write-access repair is refused for anything but an SSH/Unraid profile", async () => {
const actions = harness({ profile: { id: "profile-1", provider: "gitea-actions" } });
await assert.rejects(
() => actions.handlers.get("deployment:repair-write-access")({ ...PAYLOAD }),
/available only for SSH \/ Unraid/,
);
});
test("a stale workload blocks an inventory review instead of guessing", async () => {
const { handlers } = harness({
dependencies: { unraid: { scanServerInventory: async () => ({ workloads: [] }) } },
});
for (const channel of ["deployment:plan-inventory-review", "deployment:apply-inventory-review"]) {
await assert.rejects(() => handlers.get(channel)({ ...PAYLOAD }), (error) => {
assert.equal(error.code, "INVENTORY_REVIEW_WORKLOAD_STALE");
return true;
});
}
});
test("write-access repair builds a repair script that preserves runtime paths", () => {
const service = new UnraidDeploymentService({});
const profile = {
id: "profile-3",
provider: "ssh-unraid",
remoteFolder: "portfolio",
composeFiles: ["docker-compose.yml"],
preservePaths: ["data/uploads"],
};
const server = { id: "server-1", basePath: "/mnt/user/appdata" };
const script = service.permissionRepairScript(profile, server, "/mnt/user/appdata/portfolio");
assert.equal(typeof script, "string");
assert.match(script, /data\/uploads/);
assert.match(script, /node_modules/);
assert.match(script, /ForgeFlow repaired project write access/);
});
+216
View File
@@ -0,0 +1,216 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
const SERVER = { id: "unraid", basePath: "/mnt/user/appdata" };
const REPOSITORY = { fullName: "Jens/Portfolio" };
// The host reaches the server through a single exec call, so capturing the script
// it sends is the only way to assert what actually happens to the key material.
function keyHost(stdout = "") {
const scripts = [];
const ssh = {
exec: async (serverId, command, options) => {
const encoded = command.match(/printf '%s' '([^']+)'/)?.[1] || "";
scripts.push({ serverId, options, script: Buffer.from(encoded, "base64").toString("utf8") });
return { stdout };
},
};
return { host: new UnraidDeployKeyHost({ ssh }), scripts };
}
test("deploy-key storage is repository-scoped, deterministic and stays under the server base path", () => {
const { host } = keyHost();
const first = host.paths(REPOSITORY, SERVER);
const again = host.paths({ fullName: "jens/portfolio" }, SERVER);
const other = host.paths({ fullName: "Jens/Other" }, SERVER);
assert.deepEqual(first, again, "the same repository always resolves to the same directory");
assert.notEqual(first.directory, other.directory, "a different repository never shares a key directory");
for (const value of Object.values(first)) {
assert.ok(value.startsWith("/mnt/user/appdata/.forgeflow/git-credentials/"), value);
assert.ok(!value.includes(".."));
}
assert.ok(!first.directory.toLowerCase().includes("portfolio"), "the repository name is hashed, not embedded");
});
test("the server pull remote is taken from the first usable SSH URL and refused when there is none", () => {
const { host } = keyHost();
assert.equal(
host.remote({ ...REPOSITORY, localStatus: { remoteUrl: "https://gitea.example/Jens/Portfolio.git" }, sshUrl: "git@gitea.example:Jens/Portfolio.git" }, {}),
"git@gitea.example:Jens/Portfolio.git",
"an HTTPS remote is skipped in favour of the SSH URL",
);
assert.equal(
host.remote({ ...REPOSITORY }, { cloneUrl: "ssh://git@gitea.example:2222/Jens/Portfolio.git" }),
"ssh://git@gitea.example:2222/Jens/Portfolio.git",
);
assert.throws(
() => host.remote({ ...REPOSITORY, sshUrl: "https://gitea.example/Jens/Portfolio.git" }, {}),
(error) => {
assert.equal(error.code, "SERVER_GIT_SSH_URL_REQUIRED");
return true;
},
);
});
test("the Git SSH environment pins the scoped key and refuses an unknown host", () => {
const { host } = keyHost();
const paths = host.paths(REPOSITORY, SERVER);
const environment = host.environment(paths);
assert.match(environment, /IdentitiesOnly=yes/);
assert.match(environment, /BatchMode=yes/);
assert.match(environment, /StrictHostKeyChecking=yes/);
assert.ok(environment.includes(paths.knownHosts), "the pinned host key file is repository-scoped");
assert.ok(environment.includes(paths.privateKey));
});
test("a backup copies the current key material into a fresh recovery slot", async () => {
const publicKey = "ssh-ed25519 QkFL forgeflow";
const { host, scripts } = keyHost(
`__FORGEFLOW_KEY_BACKUP__\nrecovery=/mnt/user/appdata/.forgeflow/git-credentials/abc/recovery/backup-1\npublicKey=${Buffer.from(publicKey).toString("base64")}\n`,
);
const backup = await host.backup({ repository: REPOSITORY, server: SERVER });
assert.equal(backup.publicKey, publicKey);
assert.match(backup.recovery, /recovery\/backup-1$/);
assert.match(scripts[0].script, /umask 077/, "recovered key material is not world readable");
assert.match(scripts[0].script, /deploy-key deploy-key\.pub known_hosts/);
});
test("candidate verification only reports ready on a real remote commit", async () => {
const remoteSha = "d".repeat(40);
const candidate = { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } };
const context = {
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
profile: { branch: "main" },
server: SERVER,
candidate,
};
const proven = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${remoteSha}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n`);
const proof = await proven.host.verifyCandidate(context);
assert.deepEqual(proof, { ready: true, remoteSha, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" });
assert.match(proven.scripts[0].script, /git ls-remote --exit-code/);
assert.match(proven.scripts[0].script, /refs\/heads\/main/);
assert.equal(proven.scripts[0].options.timeout, 45_000);
assert.deepEqual(await proven.host.preflightCandidate(context), proof);
const unproven = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
assert.equal((await unproven.host.verifyCandidate(context)).ready, false);
await assert.rejects(() => unproven.host.preflightCandidate(context), /did not prove the remote branch/);
});
test("a preflight reuses a proof it was handed instead of asking the server again", async () => {
const reused = keyHost("__FORGEFLOW_KEY_PROOF__\nremoteSha=\nfingerprint=\nhostFingerprint=\n");
const proof = { ready: true, remoteSha: "f".repeat(40), fingerprint: "SHA256:new", hostFingerprint: "SHA256:host" };
const context = {
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
profile: { branch: "main" },
server: SERVER,
candidate: { paths: { privateKey: "/k/deploy-key", publicKey: "/k/deploy-key.pub", knownHosts: "/k/known_hosts" } },
proof,
};
assert.deepEqual(await reused.host.preflightCandidate(context), proof);
assert.equal(reused.scripts.length, 0, "no second connection is opened");
// A proof that never established a remote commit is not a shortcut.
await assert.rejects(
() => reused.host.preflightCandidate({ ...context, proof: { ready: false } }),
/did not prove the remote branch/,
);
assert.equal(reused.scripts.length, 1, "an unusable proof falls back to verifying");
});
test("verifying the active key uses the repository-scoped paths rather than a candidate", async () => {
const { host, scripts } = keyHost(`__FORGEFLOW_KEY_PROOF__\nremoteSha=${"e".repeat(40)}\nfingerprint=SHA256:active\nhostFingerprint=SHA256:host\n`);
const paths = host.paths(REPOSITORY, SERVER);
const proof = await host.verifyActive({
repository: { ...REPOSITORY, sshUrl: "git@gitea.example:Jens/Portfolio.git" },
profile: { branch: "main" },
server: SERVER,
});
assert.equal(proof.ready, true);
assert.ok(scripts[0].script.includes(paths.privateKey));
assert.ok(scripts[0].script.includes(paths.knownHosts));
});
test("promotion only replaces key material after proving the candidate is complete", async () => {
const { host, scripts } = keyHost();
const paths = host.paths(REPOSITORY, SERVER);
const candidate = { paths: { directory: "/c", privateKey: "/c/deploy-key", publicKey: "/c/deploy-key.pub", knownHosts: "/c/known_hosts" } };
await host.promote({ repository: REPOSITORY, server: SERVER, candidate });
const script = scripts[0].script;
assert.ok(script.includes("test -s '/c/deploy-key'"), "an empty candidate key is refused before anything is replaced");
assert.ok(script.includes("test -s '/c/known_hosts'"));
assert.ok(script.indexOf("test -s") < script.indexOf("mv "), "the checks run before the swap");
// The staging suffix is appended outside the quoted path, so the command reads
// mv '<path>'.new '<path>' rather than mv '<path>.new' '<path>'.
assert.ok(script.includes(`mv '${paths.privateKey}'.new '${paths.privateKey}'`), "the swap is atomic");
assert.ok(script.includes(`cp -p '/c/deploy-key' '${paths.privateKey}'.new`), "the copy lands on the staging name first");
});
test("rollback restores the recovery slot and removes the candidate", async () => {
const { host, scripts } = keyHost();
const paths = host.paths(REPOSITORY, SERVER);
await host.rollback({
repository: REPOSITORY,
server: SERVER,
candidate: { paths: { directory: "/candidate" } },
previous: { key: { recovery: "/recovery/backup-1" } },
});
assert.ok(scripts[0].script.includes("cp -p '/recovery/backup-1'"));
assert.ok(scripts[0].script.includes(paths.directory));
assert.ok(scripts[0].script.includes("rm -rf -- '/candidate'"));
});
test("committing a rotation discards only the candidate directory", async () => {
const { host, scripts } = keyHost();
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
// Every script carries the strict-mode preamble that bash() prepends.
assert.equal(scripts[0].script.split("\n").at(-1), "rm -rf -- '/candidate'");
assert.ok(!scripts[0].script.includes(".forgeflow/git-credentials"), "the active key directory is never touched on commit");
});
test("every server script runs under strict mode with Git prompts disabled", async () => {
const { host, scripts } = keyHost();
await host.commit({ server: SERVER, candidate: { paths: { directory: "/candidate" } } });
assert.match(scripts[0].script, /^set -euo pipefail\nexport GIT_TERMINAL_PROMPT=0\n/);
assert.equal(scripts[0].serverId, SERVER.id);
});
test("revocation moves key material aside so it can still be restored", async () => {
const { host, scripts } = keyHost();
const paths = host.paths(REPOSITORY, SERVER);
await host.revoke({ repository: REPOSITORY, server: SERVER });
const script = scripts[0].script;
assert.ok(script.includes(`${paths.recovery}/revoked-`), "revoked material is kept in the recovery area");
assert.match(script, /mv /, "the key is moved, never deleted");
assert.ok(!/rm -rf/.test(script), "revocation must not destroy the recovery path");
});
test("restore reinstates the newest recovery slot and reports the public evidence", async () => {
const publicKey = "ssh-ed25519 UkVT forgeflow";
const { host, scripts } = keyHost(
`__FORGEFLOW_KEY_RESTORE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:restored\nhostFingerprint=SHA256:host\n`,
);
const restored = await host.restore({ repository: REPOSITORY, server: SERVER });
assert.deepEqual(restored, { publicKey, fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" });
assert.match(scripts[0].script, /sort \| tail -1/, "the newest slot is chosen deterministically");
assert.ok(scripts[0].script.includes('test -n "$slot"'), "restoring without a recovery slot fails loudly");
});
test("marker parsing keeps values that themselves contain separators", () => {
const parsed = parseDeployKeyMarker("noise\n__M__\nkey=a=b=c\nempty\nother=1\n", "__M__");
assert.deepEqual(parsed, { key: "a=b=c", empty: "", other: "1" });
});
+139
View File
@@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { DeployKeyLifecycleService } = require("../src/main/deploy-key-lifecycle-service.cjs");
const { UnraidDeployKeyHost, parseDeployKeyMarker } = require("../src/main/unraid-deploy-key-host.cjs");
const repository = { fullName: "Jens/Portfolio" };
const baseProfile = { id: "production", serverId: "unraid", deploymentMode: "server-git", serverGitAccess: { configured: true, deployKeyId: 7, keyFingerprint: "SHA256:old", hostFingerprint: "SHA256:host" } };
function fixture(overrides = {}) {
let profile = structuredClone(baseProfile);
const events = [];
const remoteKeys = overrides.remoteKeys || [{ id: 7, title: "ForgeFlow old", read_only: true, key: "ssh-ed25519 T0xE old" }];
const store = {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid" }),
getRepositories: () => [{ fullName: repository.fullName }],
getDeploymentProfiles: () => [profile, ...(overrides.otherProfiles || [])],
saveDeploymentProfile: async (_name, value) => { if (overrides.saveFails) throw new Error("switch failed"); profile = structuredClone(value); events.push("profile-saved"); return profile; },
createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }),
};
const gitea = {
listDeployKeys: async () => structuredClone(remoteKeys),
createReadOnlyDeployKey: async () => { if (overrides.registrationFails) throw new Error("registration failed"); return { id: 8, title: "new", read_only: overrides.writable !== true, key: "ssh-ed25519 TkVX new" }; },
deleteDeployKey: async (_owner, _repo, id) => { events.push(`delete:${id}`); if (overrides.deleteOldFails && id === 7) throw new Error("old revoke failed"); return { deleted: true }; },
};
const keyHost = {
inspect: async () => overrides.inspect || ({ privateKeyPresent: true, publicKey: "ssh-ed25519 T0xE old", fingerprint: overrides.changedFingerprint ? "SHA256:changed" : "SHA256:old", hostFingerprint: "SHA256:host" }),
backup: async () => ({ recovery: "server-backup", publicKey: "ssh-ed25519 T0xE old" }),
generate: async () => ({ publicKey: "ssh-ed25519 TkVX new", fingerprint: "SHA256:new" }),
verifyCandidate: async () => overrides.verifyFails ? ({ ready: false, fingerprint: "SHA256:new" }) : ({ ready: true, fingerprint: "SHA256:new", hostFingerprint: "SHA256:host", remoteSha: "a".repeat(40) }),
preflightCandidate: async () => { if (overrides.preflightFails) throw new Error("preflight failed"); events.push("preflight"); },
promote: async () => { events.push("promote"); },
verifyActive: async () => overrides.postFails ? ({ ready: false }) : ({ ready: true, fingerprint: "SHA256:new" }),
commit: async () => { events.push("commit"); },
rollback: async () => { events.push("rollback"); },
revoke: async () => { events.push("revoke-server"); if (overrides.revokeFails) throw new Error("server revoke failed"); },
restore: async () => ({ publicKey: "ssh-ed25519 UkVTVE9SRQ restored", fingerprint: "SHA256:restored", hostFingerprint: "SHA256:host" }),
};
const audit = { append: async (name) => events.push(name) };
const service = new DeployKeyLifecycleService({ store, gitea, keyHost, audit, clock: () => "2026-07-29T00:00:00.000Z" });
return { service, events, getProfile: () => profile };
}
test("deploy-key rotation verifies, switches, revokes and post-verifies in order", async () => {
const { service, events, getProfile } = fixture();
const plan = await service.planRotation({ repository, profileId: "production" });
const result = await service.rotate({ repository, profileId: "production", expectedPlanId: plan.id });
assert.equal(result.profile.serverGitAccess.deployKeyId, 8);
assert.equal(getProfile().serverGitAccess.keyFingerprint, "SHA256:new");
assert.deepEqual(events.filter((event) => ["preflight", "promote", "profile-saved", "delete:7", "commit"].includes(event)), ["preflight", "promote", "profile-saved", "delete:7", "commit"]);
});
for (const [name, overrides, message] of [
["registration failure", { registrationFails: true }, /registration failed/],
["writable candidate", { writable: true }, /write access/],
["candidate verification failure", { verifyFails: true }, /could not prove/],
["candidate preflight failure", { preflightFails: true }, /preflight failed/],
["atomic profile switch failure", { saveFails: true }, /switch failed/],
["old key revocation failure", { deleteOldFails: true }, /old revoke failed/],
["post-rotation failure", { postFails: true }, /Post-rotation verification failed/],
]) test(`deploy-key rotation rolls back on ${name}`, async () => {
const { service, events } = fixture(overrides);
const plan = await service.planRotation({ repository, profileId: "production" });
await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: plan.id }), message);
assert.ok(events.includes("rollback"));
});
test("rotation rejects a stale content-addressed plan", async () => {
const { service } = fixture();
await assert.rejects(service.rotate({ repository, profileId: "production", expectedPlanId: "stale" }), (error) => error.code === "DEPLOY_KEY_ROTATION_PLAN_STALE");
});
test("inventory detects stale, orphaned, shared, conflicting and changed-fingerprint keys", async () => {
const { service } = fixture({
changedFingerprint: true,
remoteKeys: [{ id: 9, title: "ForgeFlow orphan", read_only: true, key: "ssh-ed25519 T1JQSEFO orphan" }, { id: 10, title: "writable", read_only: false, key: "ssh-ed25519 T0xE old" }],
otherProfiles: [{ id: "staging", serverId: "unraid", serverGitAccess: { deployKeyId: 11, keyFingerprint: "SHA256:changed" } }],
});
const report = await service.inventory({ repository, profileId: "production" });
assert.equal(report.stale, true);
assert.equal(report.orphaned.length, 1);
assert.equal(report.shared.length, 1);
assert.equal(report.conflicts.length, 1);
assert.equal(report.ready, false);
});
test("revocation requires reviewed impact, disables pull and preserves recovery", async () => {
const { service, events, getProfile } = fixture();
const plan = await service.planRevocation({ repository, profileId: "production" });
assert.equal(plan.containersUnaffected, true);
await assert.rejects(service.revoke({ repository, profileId: "production" }), (error) => error.code === "DEPLOY_KEY_REVOCATION_PLAN_REQUIRED");
const result = await service.revoke({ repository, profileId: "production", expectedPlanId: plan.id });
assert.equal(result.recovery, "server-backup");
assert.equal(getProfile().deploymentMode, "monitor-only");
assert.ok(events.includes("delete:7"));
assert.ok(events.includes("revoke-server"));
});
test("revoked access can be restored and verified", async () => {
const { service } = fixture();
const result = await service.restore({ repository, profileId: "production" });
assert.equal(result.profile.deploymentMode, "server-git");
assert.equal(result.profile.serverGitAccess.keyFingerprint, "SHA256:restored");
assert.equal(result.proof.ready, true);
});
test("failed server revocation restores repository access", async () => {
const { service, events, getProfile } = fixture({ revokeFails: true });
const plan = await service.planRevocation({ repository, profileId: "production" });
await assert.rejects(service.revoke({ repository, profileId: "production", expectedPlanId: plan.id }), /server revoke failed/);
assert.equal(getProfile().deploymentMode, "server-git");
assert.ok(events.includes("delete:7"));
});
test("Unraid key host parser rejects unverifiable output", () => {
assert.throws(() => parseDeployKeyMarker("ordinary ssh output", "__FORGEFLOW_KEY__"), /did not return/);
});
test("Unraid candidate generation returns public evidence and paths but never private key content", async () => {
const publicKey = "ssh-ed25519 TkVX forgeflow";
const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_CANDIDATE__\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:new\nhostFingerprint=SHA256:host\n` }) };
const host = new UnraidDeployKeyHost({ ssh });
const candidate = await host.generate({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } });
assert.equal(candidate.publicKey, publicKey);
assert.equal(candidate.fingerprint, "SHA256:new");
assert.equal(candidate.privateKey, undefined);
assert.match(candidate.paths.privateKey, /candidate-[0-9a-f-]+\/deploy-key$/);
});
test("Unraid active key inspection exposes only public metadata", async () => {
const publicKey = "ssh-ed25519 T0xE forgeflow";
const ssh = { exec: async () => ({ stdout: `__FORGEFLOW_KEY_INSPECT__\nprivateKeyPresent=true\npublicKey=${Buffer.from(publicKey).toString("base64")}\nfingerprint=SHA256:old\nhostFingerprint=SHA256:host\n` }) };
const host = new UnraidDeployKeyHost({ ssh });
const evidence = await host.inspect({ repository: { fullName: "Jens/Portfolio" }, server: { id: "unraid", basePath: "/mnt/user/appdata" } });
assert.deepEqual(evidence, { privateKeyPresent: true, publicKey, fingerprint: "SHA256:old", hostFingerprint: "SHA256:host" });
});
+497
View File
@@ -0,0 +1,497 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import deploymentModule from '../src/main/deployment-service.cjs';
const { DeploymentService } = deploymentModule;
const SHA = 'a'.repeat(40);
const PREVIOUS_SHA = 'b'.repeat(40);
async function serve(handler) {
const server = http.createServer(handler);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
return {
url: `http://127.0.0.1:${server.address().port}/status`,
close: () => new Promise((resolve) => server.close(resolve))
};
}
function jsonEndpoint(body, statusCode = 200) {
return serve((request, response) => {
response.writeHead(statusCode, { 'Content-Type': 'application/json' });
response.end(typeof body === 'string' ? body : JSON.stringify(body));
});
}
// A port nothing listens on, so the request fails instead of hanging.
async function unreachableUrl() {
const closed = await serve(() => {});
await closed.close();
return closed.url;
}
function makeStore({ profile = null, operations = [] } = {}) {
const saved = new Map(operations.map((item) => [item.id, item]));
const states = new Map();
return {
data: { operations, gitea: { baseUrl: 'https://gitea.example' } },
getToken: () => 'gitea-secret-token',
getDeploymentProfile: () => profile,
getOperation: (id) => saved.get(id) || null,
addOperation: async (operation) => {
saved.set(operation.id, structuredClone(operation));
return structuredClone(operation);
},
saveDeploymentState: async (profileId, state) => {
states.set(profileId, state);
return state;
},
saved,
states
};
}
function makeOperation(overrides = {}) {
return {
id: 'operation-1',
type: 'deployment',
action: 'deploy',
status: 'queued',
repository: 'jens/app',
profileId: 'production',
environment: 'production',
workflowFile: 'deploy.yml',
branch: 'main',
sha: SHA,
shortSha: SHA.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: new DeploymentService({}, {}, {}).makeStages(),
logs: [],
...overrides
};
}
function successPayload(overrides = {}) {
return {
repository: 'jens/app',
environment: 'production',
commit_sha: SHA,
previous_sha: PREVIOUS_SHA,
requested_sha: SHA,
request_id: 'operation-1',
last_exit_code: 0,
health: 'healthy',
...overrides
};
}
test('the status endpoint reader accepts both key spellings and refuses anything that is not a commit SHA', async (context) => {
const service = new DeploymentService(makeStore(), {}, {});
assert.deepEqual(await service.readStatusEndpoint(''), { configured: false });
const snake = await jsonEndpoint(successPayload());
context.after(() => snake.close());
const snakeResult = await service.readStatusEndpoint(snake.url);
assert.equal(snakeResult.ok, true);
assert.equal(snakeResult.liveSha, SHA);
assert.equal(snakeResult.previousSha, PREVIOUS_SHA);
assert.equal(snakeResult.requestedSha, SHA);
assert.equal(snakeResult.requestId, 'operation-1');
assert.equal(snakeResult.lastExitCode, 0);
const camel = await jsonEndpoint({
repository: 'jens/app',
environment: 'PRODUCTION',
commitSha: SHA.toUpperCase(),
previousSha: PREVIOUS_SHA,
requestedSha: SHA,
requestId: 'operation-1',
lastExitCode: 3
});
context.after(() => camel.close());
const camelResult = await service.readStatusEndpoint(camel.url);
assert.equal(camelResult.liveSha, SHA, 'a SHA is normalised to lower case');
assert.equal(camelResult.environment, 'production', 'the environment is compared case-insensitively');
assert.equal(camelResult.lastExitCode, 3);
const untrusted = await jsonEndpoint({ commit_sha: 'HEAD', previous_sha: 'v1.2.3', request_id: 42, requested_sha: 'not-a-sha' });
context.after(() => untrusted.close());
const untrustedResult = await service.readStatusEndpoint(untrusted.url);
assert.equal(untrustedResult.liveSha, null);
assert.equal(untrustedResult.previousSha, null);
assert.equal(untrustedResult.requestedSha, null);
assert.equal(untrustedResult.requestId, null, 'a non-string request id is not accepted');
});
test('an unreachable or failing status endpoint is reported instead of assumed healthy', async (context) => {
const service = new DeploymentService(makeStore(), {}, {});
const failing = await jsonEndpoint({ error: 'boom' }, 503);
context.after(() => failing.close());
const failed = await service.readStatusEndpoint(failing.url);
assert.deepEqual(
{ configured: failed.configured, reachable: failed.reachable, ok: failed.ok, status: failed.status },
{ configured: true, reachable: true, ok: false, status: 503 }
);
const offline = await service.readStatusEndpoint(await unreachableUrl());
assert.equal(offline.reachable, false);
assert.equal(offline.ok, false);
assert.ok(offline.error);
});
test('healthchecks distinguish unconfigured, healthy, rejected and unreachable', async (context) => {
const service = new DeploymentService(makeStore(), {}, {});
assert.deepEqual(await service.checkHealth(''), { configured: false, healthy: null });
const healthy = await jsonEndpoint({ ok: true });
context.after(() => healthy.close());
const healthyResult = await service.checkHealth(healthy.url);
assert.equal(healthyResult.healthy, true);
assert.equal(healthyResult.status, 200);
const rejected = await jsonEndpoint({ ok: false }, 500);
context.after(() => rejected.close());
assert.equal((await service.checkHealth(rejected.url)).healthy, false);
const offline = await service.checkHealth(await unreachableUrl());
assert.equal(offline.healthy, false);
assert.ok(offline.error);
});
test('profile state derives health from the status document when no healthcheck is configured', async (context) => {
const endpoint = await jsonEndpoint(successPayload({ health: 'degraded', deployed_at: '2026-08-01T10:00:00.000Z' }));
context.after(() => endpoint.close());
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
const store = makeStore({ profile });
const service = new DeploymentService(store, {}, {});
const state = await service.refreshProfileState('jens/app', 'production', { expectedSha: SHA });
assert.equal(state.healthConfigured, false);
assert.equal(state.healthy, false, 'a degraded status document is not treated as healthy');
assert.equal(state.liveSha, SHA);
assert.equal(state.versionMatches, true);
assert.equal(state.deployedAt, '2026-08-01T10:00:00.000Z');
assert.equal(store.states.get('production').liveSha, SHA, 'the state is persisted');
});
test('an unknown health word leaves the health state undecided rather than guessing', async (context) => {
const endpoint = await jsonEndpoint(successPayload({ health: 'starting' }));
context.after(() => endpoint.close());
const store = makeStore({ profile: { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' } });
const state = await new DeploymentService(store, {}, {}).refreshProfileState('jens/app', 'production');
assert.equal(state.healthy, null);
assert.equal(state.versionMatches, null, 'without an expected SHA there is nothing to compare');
});
test('refreshing the state of a removed profile fails loudly', async () => {
const service = new DeploymentService(makeStore({ profile: null }), {}, {});
await assert.rejects(() => service.refreshProfileState('jens/app', 'gone'), /Deployment profile not found/);
});
test('a terminal operation is never polled again', async () => {
const operation = makeOperation({ status: 'success' });
const store = makeStore({ operations: [operation] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => assert.fail('a finished deployment must not be polled'),
listWorkflowJobs: async () => assert.fail('a finished deployment must not be polled')
}, {});
assert.equal((await service.refreshOperation('operation-1')).status, 'success');
});
test('an unknown operation is reported instead of silently ignored', async () => {
const service = new DeploymentService(makeStore(), {}, {});
await assert.rejects(() => service.refreshOperation('missing'), /Deployment operation not found/);
});
test('a workflow run that is not visible yet keeps the deployment queued', async () => {
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => ({ run: null, source: 'actions' })
}, {});
const refreshed = await service.refreshOperation('operation-1');
assert.equal(refreshed.status, 'queued');
assert.equal(refreshed.stages.find((stage) => stage.id === 'queued').status, 'active');
assert.match(refreshed.logs.at(-1), /queued or not visible/);
});
test('a failed runner marks the deployment failed and skips verification', async () => {
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => ({ run: { id: 7, runNumber: 7, status: 'completed', conclusion: 'failure', htmlUrl: 'https://gitea.example/run/7' }, source: 'actions' }),
listWorkflowJobs: async () => [{ name: 'build', status: 'completed', conclusion: 'failure' }]
}, {});
const refreshed = await service.refreshOperation('operation-1');
assert.equal(refreshed.status, 'failed');
assert.equal(refreshed.failure.stage, 'runner');
assert.equal(refreshed.stages.find((stage) => stage.id === 'healthcheck').status, 'skipped');
assert.equal(refreshed.runUrl, 'https://gitea.example/run/7');
});
test('a successful runner still fails when the server does not prove it runs the exact commit', async (context) => {
const endpoint = await jsonEndpoint(successPayload({ commit_sha: 'c'.repeat(40) }));
context.after(() => endpoint.close());
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
const store = makeStore({ profile, operations: [makeOperation()] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => ({ run: { id: 8, runNumber: 8, status: 'completed', conclusion: 'success' }, source: 'actions' }),
listWorkflowJobs: async () => []
}, {});
const refreshed = await service.refreshOperation('operation-1');
assert.equal(refreshed.status, 'failed');
assert.equal(refreshed.failure.stage, 'version-verification');
assert.match(refreshed.failure.message, /instead of/);
assert.equal(refreshed.stages.find((stage) => stage.id === 'complete').status, 'failed');
});
test('a verified deployment completes, and the same evidence marks a rollback as rolled back', async (context) => {
const endpoint = await jsonEndpoint(successPayload());
context.after(() => endpoint.close());
const profile = { id: 'production', environment: 'production', statusUrl: endpoint.url, healthcheckUrl: '' };
const gitea = {
findWorkflowRun: async () => ({ run: { id: 9, runNumber: 9, status: 'completed', conclusion: 'success' }, source: 'actions' }),
listWorkflowJobs: async () => [{ name: 'deploy', status: 'completed', conclusion: 'success' }]
};
const deployStore = makeStore({ profile, operations: [makeOperation()] });
const deployed = await new DeploymentService(deployStore, gitea, {}).refreshOperation('operation-1');
assert.equal(deployed.status, 'success');
assert.equal(deployed.stages.find((stage) => stage.id === 'complete').status, 'complete');
assert.equal(deployed.applicationState.liveSha, SHA);
assert.ok(deployed.logs.some((line) => line.includes('[job] deploy: success')));
const rollbackStore = makeStore({ profile, operations: [makeOperation({ action: 'rollback' })] });
const rolledBack = await new DeploymentService(rollbackStore, gitea, {}).refreshOperation('operation-1');
assert.equal(rolledBack.status, 'rolled-back');
});
test('unavailable job details degrade to a warning instead of failing the refresh', async () => {
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => ({ run: { id: 10, runNumber: 10, status: 'in_progress', conclusion: null }, source: 'actions' }),
listWorkflowJobs: async () => { throw new Error('jobs API disabled'); }
}, {});
const refreshed = await service.refreshOperation('operation-1');
assert.equal(refreshed.status, 'running');
assert.equal(refreshed.stages.find((stage) => stage.id === 'runner').status, 'active');
assert.ok(refreshed.logs.some((line) => line.includes('Job details unavailable: jobs API disabled')));
});
test('a failing poll is recorded on the operation without losing it', async () => {
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => { throw new Error('Gitea unreachable'); }
}, {});
const refreshed = await service.refreshOperation('operation-1');
assert.equal(refreshed.pollError, 'Gitea unreachable');
assert.equal(refreshed.status, 'queued', 'the operation keeps its last known state');
assert.ok(refreshed.logs.some((line) => line.includes('Status refresh failed')));
});
test('a deployment whose profile was deleted reports that instead of crashing the poll', async () => {
const store = makeStore({ profile: null, operations: [makeOperation()] });
const refreshed = await new DeploymentService(store, {}, {}).refreshOperation('operation-1');
assert.match(refreshed.pollError, /profile used by this operation no longer exists/);
});
test('a refresh already in flight is not started a second time', async () => {
let calls = 0;
let release;
const gate = new Promise((resolve) => { release = resolve; });
const store = makeStore({ profile: { id: 'production' }, operations: [makeOperation()] });
const service = new DeploymentService(store, {
findWorkflowRun: async () => { calls += 1; await gate; return { run: null, source: 'actions' }; }
}, {});
const first = service.refreshOperation('operation-1');
const second = await service.refreshOperation('operation-1');
assert.equal(second.status, 'queued');
release();
await first;
assert.equal(calls, 1, 'the second caller reuses the in-flight refresh');
});
test('job states drive the runner stage', () => {
const service = new DeploymentService(makeStore(), {}, {});
const stageOf = (jobs) => {
const operation = makeOperation();
service.mapJobsToStages(operation, jobs);
return operation.stages.find((stage) => stage.id === 'runner').status;
};
assert.equal(stageOf([{ status: 'in_progress' }]), 'active');
assert.equal(stageOf([{ conclusion: 'success' }, { conclusion: 'failure' }]), 'failed');
assert.equal(stageOf([{ conclusion: 'success' }]), 'complete');
assert.equal(stageOf([{ status: 'waiting' }]), 'pending');
const untouched = makeOperation();
service.mapJobsToStages(untouched, []);
assert.equal(untouched.stages.find((stage) => stage.id === 'queued').status, 'active', 'no jobs leaves the stages alone');
});
test('a rejected dispatch records the failure on the operation and still surfaces the error', async () => {
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 store = makeStore({ profile });
const service = new DeploymentService(store, {
listWorkflowRuns: async () => ({ runs: [{ id: 1 }, { id: 2 }] }),
dispatchWorkflow: async () => { throw new Error('workflow file not found'); }
}, {
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 () => {} });
await assert.rejects(
() => service.deploy({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', sha: SHA }),
/workflow file not found/
);
const stored = [...store.saved.values()].at(-1);
assert.equal(stored.status, 'failed');
assert.equal(stored.failure.stage, 'dispatch');
assert.deepEqual(stored.baselineRunIds, ['1', '2'], 'runs that existed before dispatch are never mistaken for this one');
assert.equal(stored.stages.find((stage) => stage.id === 'queued').status, 'failed');
});
test('a rejected rollback dispatch is recorded the same way as a rejected deployment', async (context) => {
const endpoint = await jsonEndpoint(successPayload());
context.after(() => endpoint.close());
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: ''
};
const store = makeStore({ profile });
const service = new DeploymentService(store, {
listWorkflowRuns: async () => ({ runs: [] }),
dispatchWorkflow: async () => { throw new Error('rollback workflow is disabled'); }
}, {
verifyCommitOnRemoteBranch: async () => ({ valid: true })
}, { info: async () => {}, error: async () => {} });
await assert.rejects(
() => service.rollback({ repository: { fullName: 'jens/app', localPath: '/repo' }, profileId: 'production', targetSha: PREVIOUS_SHA }),
/rollback workflow is disabled/
);
const stored = [...store.saved.values()].at(-1);
assert.equal(stored.action, 'rollback');
assert.equal(stored.status, 'failed');
assert.equal(stored.failure.stage, 'dispatch');
assert.equal(stored.workflowFile, 'rollback.yml');
});
test('rollback refuses every state where the target is not the server-reported previous version', async (context) => {
const endpoint = await jsonEndpoint(successPayload());
context.after(() => endpoint.close());
const base = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml', statusUrl: endpoint.url, healthcheckUrl: ''
};
const git = { verifyCommitOnRemoteBranch: async () => ({ valid: true }) };
const repository = { fullName: 'jens/app', localPath: '/repo' };
const rollback = (profile, targetSha) => new DeploymentService(makeStore({ profile }), {}, git)
.rollback({ repository, profileId: 'production', targetSha });
await assert.rejects(() => rollback({ ...base, rollbackWorkflowFile: '' }, PREVIOUS_SHA), /No rollback workflow is configured/);
await assert.rejects(() => rollback(base, 'c'.repeat(40)), /no longer the previous server version/);
await assert.rejects(() => rollback(base, SHA), /no longer the previous server version/, 'the live commit is not the previous one either');
// The "already live" guard only remains reachable when the server reports the
// same commit as both its live and its previous version.
const stuck = await jsonEndpoint(successPayload({ previous_sha: SHA }));
context.after(() => stuck.close());
await assert.rejects(() => rollback({ ...base, statusUrl: stuck.url }, SHA), /already live/);
const noPrevious = await jsonEndpoint(successPayload({ previous_sha: null }));
context.after(() => noPrevious.close());
await assert.rejects(() => rollback({ ...base, statusUrl: noPrevious.url }, PREVIOUS_SHA), /does not report a previous version/);
const otherEnvironment = await jsonEndpoint(successPayload({ environment: 'staging' }));
context.after(() => otherEnvironment.close());
await assert.rejects(() => rollback({ ...base, statusUrl: otherEnvironment.url }, PREVIOUS_SHA), /does not match this repository and environment/);
// An unreachable endpoint surfaces the underlying network error rather than a
// generic message, so the reason a rollback was refused stays diagnosable.
const unreachable = { ...base, statusUrl: await unreachableUrl() };
await assert.rejects(() => rollback(unreachable, PREVIOUS_SHA), /fetch failed|ECONNREFUSED|must be reachable/i);
});
test('an unavailable run baseline degrades to a warning rather than blocking the dispatch', async () => {
const profile = {
id: 'production', name: 'Production', environment: 'production', branch: 'main',
workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/.well-known/forgeflow'
};
const store = makeStore({ profile });
const service = new DeploymentService(store, {
listWorkflowRuns: async () => { throw new Error('Actions API disabled'); },
dispatchWorkflow: async () => ({ accepted: true })
}, {
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: 'production', sha: SHA });
assert.equal(operation.status, 'queued');
assert.deepEqual(operation.baselineRunIds, []);
assert.ok(operation.logs.some((line) => line.includes('Could not capture the pre-dispatch run baseline')));
});
test('deployment logs never repeat a line and never carry the Gitea token', () => {
const service = new DeploymentService(makeStore(), {}, {});
const operation = makeOperation({ logs: undefined });
service.appendLog(operation, 'plain line');
service.appendLog(operation, 'plain line');
service.appendLog(operation, 'authorization: token gitea-secret-token');
assert.equal(operation.logs.length, 2, 'a repeated line is not appended twice');
assert.ok(!operation.logs.at(-1).includes('gitea-secret-token'));
for (let index = 0; index < 1200; index += 1) service.appendLog(operation, `line ${index}`);
assert.equal(operation.logs.length, 1000, 'the log is bounded');
assert.equal(operation.logs.at(-1), 'line 1199');
});
test('a repository identity that is not exactly owner/repo is refused', () => {
const service = new DeploymentService(makeStore(), {}, {});
assert.deepEqual(service.splitRepository('jens/app'), { owner: 'jens', repo: 'app' });
for (const value of ['', 'app', 'jens/app/extra', '/app', 'jens/']) {
assert.throws(() => service.splitRepository(value), /Invalid Gitea repository identity/);
}
});
test('deployment is refused without a linked local repository', async () => {
const service = new DeploymentService(makeStore(), {}, {});
await assert.rejects(() => service.deploy({ repository: { fullName: 'jens/app' }, profileId: 'production', sha: SHA }), /linked local repository/);
await assert.rejects(() => service.rollback({ repository: { localPath: '/repo' }, profileId: 'production', targetSha: SHA }), /linked local repository/);
});
test('validation refuses every local state that would deploy something other than the reviewed commit', async () => {
const profile = { id: 'production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app.example.test/status' };
const base = { head: SHA, clean: true, counts: { changed: 0 }, branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 } };
const cases = [
[{ ...base, head: 'c'.repeat(40) }, /no longer matches the local repository/],
[{ ...base, branch: { ...base.branch, head: 'feature' } }, /only allows deployments from main/],
[{ ...base, counts: { changed: 2 } }, /Commit local changes/],
[{ ...base, branch: { ...base.branch, ahead: 1 } }, /Push all local commits/],
[{ ...base, branch: { ...base.branch, behind: 1 } }, /Synchronize with Gitea/],
[{ ...base, branch: { ...base.branch, upstream: '' } }, /Publish this branch/]
];
for (const [status, expected] of cases) {
const service = new DeploymentService(makeStore({ profile }), {}, {
status: async () => status,
verifyCommitOnRemoteBranch: async () => ({ valid: true })
});
await assert.rejects(() => service.validateDeploy({ localPath: '/repo' }, profile, SHA), expected);
}
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import policyModule from '../src/shared/deployment-policy.cjs';
const { evaluateDeploymentPolicy } = policyModule;
test('deployment freeze and maintenance windows fail closed with reasoned overrides', () => {
const profile = { deploymentPolicy: { frozen: true, freezeReason: 'Incident', requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } };
const now = new Date(2026, 6, 28, 12, 0); // Tuesday
assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release' }), (error) => error.code === 'DEPLOYMENT_POLICY_BLOCKED');
assert.throws(() => evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true }), /override reason/i);
const result = evaluateDeploymentPolicy(profile, { now, note: 'Release', override: true, reason: 'Emergency recovery' });
assert.equal(result.overridden, true);
assert.equal(result.violations.length, 2);
});
test('deployment policy accepts an in-window release with required note', () => {
const now = new Date(2026, 6, 27, 9, 30); // Monday
const profile = { deploymentPolicy: { requireNote: true, maintenanceWindows: [{ days: [1], start: '09:00', end: '10:00' }] } };
assert.equal(evaluateDeploymentPolicy(profile, { now, note: 'Version 1.2' }).allowed, true);
assert.throws(() => evaluateDeploymentPolicy(profile, { now }), /release note/i);
});
test('overnight maintenance windows continue into the following day', () => {
const profile = { deploymentPolicy: { maintenanceWindows: [{ days: [1], start: '22:00', end: '02:00' }] } };
assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 27, 23, 0) }).allowed, true);
assert.equal(evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 1, 0) }).allowed, true);
assert.throws(() => evaluateDeploymentPolicy(profile, { now: new Date(2026, 6, 28, 3, 0) }), /outside/);
});
+193
View File
@@ -0,0 +1,193 @@
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('server verification reports every incomplete or mismatched evidence field', () => {
const sha = 'a'.repeat(40);
const operation = { id: 'request-1', repository: 'jens/app', environment: 'production', sha, shortSha: sha.slice(0, 7) };
const valid = { statusConfigured: true, statusReachable: true, statusRepository: operation.repository, statusEnvironment: operation.environment, liveSha: sha, requestedSha: sha, requestId: operation.id, lastExitCode: 0, healthy: true };
const cases = [
[{ ...valid, statusConfigured: false }, /is configured/i],
[{ ...valid, statusRepository: '' }, /did not identify its repository/i],
[{ ...valid, statusEnvironment: '' }, /did not identify its environment/i],
[{ ...valid, statusEnvironment: 'staging' }, /belongs to staging/i],
[{ ...valid, liveSha: '' }, /valid full commit SHA/i],
[{ ...valid, requestedSha: '' }, /requested commit SHA/i],
[{ ...valid, requestedSha: 'b'.repeat(40) }, /different requested commit/i],
[{ ...valid, requestId: '' }, /deployment request ID/i],
[{ ...valid, lastExitCode: null }, /exit code unknown/i],
[{ ...valid, healthy: false, healthStatus: 'degraded' }, /degraded/i],
[{ ...valid, healthy: null, error: 'probe failed' }, /probe failed/i]
];
for (const [state, pattern] of cases) assert.match(applicationVerificationFailure(operation, state).message, pattern);
});
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);
});
test('active deployment refreshes run concurrently with a bounded worker pool', async () => {
const operations = Array.from({ length: 9 }, (_, index) => ({ id: `operation-${index}`, type: 'deployment', status: 'running' }));
const service = new DeploymentService({ data: { operations } }, {}, {});
let running = 0;
let peak = 0;
service.refreshOperation = async (id) => {
running += 1;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 10));
running -= 1;
return { id };
};
const refreshed = await service.refreshActiveOperations();
assert.equal(refreshed.length, operations.length);
assert.ok(peak > 1);
assert.ok(peak <= 4);
});
+78
View File
@@ -0,0 +1,78 @@
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'),
host: '192.168.10.20',
basePath: '/mnt/user/appdata/private-app'
});
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' }, servers: [{ host: '192.168.10.20', username: 'deploy', basePath: '/mnt/user/appdata/private-app' }], 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.doesNotMatch(bundleText, /192\.168\.10\.20|\/mnt\/user\/appdata\/private-app/);
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');
});
+14
View File
@@ -0,0 +1,14 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import toolsModule from '../src/main/external-tools-service.cjs';
const { normalizeTool, expandTool } = toolsModule;
test('external tool templates expand as argument arrays without a shell', () => {
const tool = normalizeTool({ executable: 'code.exe', args: ['--malicious', 'ignored'] }, { executable: 'code.exe' }, 'editor');
const invocation = expandTool(tool, { path: 'C:\\Projects\\App', file: 'C:\\Projects\\App\\src\\app.js', line: 12 });
assert.equal(invocation.executable, 'code.exe');
assert.deepEqual(invocation.args, ['--reuse-window', '--goto', 'C:\\Projects\\App\\src\\app.js:12']);
assert.throws(() => normalizeTool({ executable: 'code.exe\ncalc.exe', args: [] }, {}, 'editor'), /invalid/);
assert.throws(() => normalizeTool({ executable: 'powershell.exe', args: ['-Command', 'calc'] }, {}, 'terminal'), /unsupported terminal tool/i);
});
+395
View File
@@ -0,0 +1,395 @@
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');
});
test('untracked diff rendering refuses links outside the repository and oversized files', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diff-boundary-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const repository = path.join(root, 'repository');
const outside = path.join(root, 'outside');
await fs.mkdir(repository, { recursive: true });
await fs.mkdir(outside, { recursive: true });
await git(['init'], repository);
await fs.writeFile(path.join(outside, 'secret.txt'), 'outside-secret');
try {
await fs.symlink(outside, path.join(repository, 'linked'), process.platform === 'win32' ? 'junction' : 'dir');
} catch {
t.skip('this platform does not allow creating directory links');
return;
}
const service = new GitService();
await assert.rejects(
service.diff(repository, 'linked/secret.txt'),
(error) => error.code === 'DIFF_TARGET_OUTSIDE_REPOSITORY',
);
await fs.writeFile(path.join(repository, 'too-large.txt'), Buffer.alloc(16 * 1024 * 1024 + 1, 0x61));
await assert.rejects(
service.diff(repository, 'too-large.txt'),
(error) => error.code === 'DIFF_FILE_TOO_LARGE' && error.recoverable === true,
);
});
test('stages and pushes deleted and renamed files selected from the working tree', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-delete-rename-'));
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, 'silent-zebra-glow.zip'), 'obsolete archive\n');
await fs.writeFile(path.join(working, 'old-name.txt'), 'rename me\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial files'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await fs.rm(path.join(working, 'silent-zebra-glow.zip'));
await fs.rename(path.join(working, 'old-name.txt'), path.join(working, 'new-name.txt'));
const service = new GitService();
const before = await service.status(working);
assert.ok(before.files.some((file) => file.path === 'silent-zebra-glow.zip' && file.status === 'deleted'));
const selected = before.files.map((file) => file.path);
const result = await service.commitAndPush(working, 'Remove obsolete archive and rename file', selected);
assert.equal(result.status.clean, true);
assert.equal(result.status.branch.ahead, 0);
const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root);
const names = tree.stdout.trim().split(/\r?\n/).filter(Boolean);
assert.deepEqual(names, ['new-name.txt']);
});
test('commits a deletion that was already staged manually without restaging its missing path', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-staged-delete-'));
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, 'silent-zebra-glow.zip'), 'obsolete archive\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial archive'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await fs.rm(path.join(working, 'silent-zebra-glow.zip'));
const service = new GitService();
const staged = await service.stage(working, ['silent-zebra-glow.zip']);
assert.equal(staged.files[0].path, 'silent-zebra-glow.zip');
assert.equal(staged.files[0].staged, true);
assert.equal(staged.files[0].unstaged, false);
// This used to call git add -A for the same already-staged deletion again,
// which fails with a pathspec error because the file no longer exists.
const result = await service.commitAndPush(working, 'Remove obsolete archive', ['silent-zebra-glow.zip']);
assert.equal(result.status.clean, true);
assert.equal(result.status.branch.ahead, 0);
const tree = await git(['--git-dir', remote, 'ls-tree', '-r', '--name-only', 'refs/heads/main'], root);
assert.equal(tree.stdout.trim(), '');
});
test('keeps a successful local commit visible as ahead when the following push fails', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-push-failure-'));
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'), '# Portfolio\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await fs.appendFile(path.join(working, 'README.md'), '\nUpdated locally.\n');
await git(['remote', 'set-url', 'origin', path.join(root, 'missing-remote.git')], working);
const service = new GitService();
await assert.rejects(
service.commitAndPush(working, 'Update portfolio', ['README.md']),
(error) => Boolean(error.code === 'PUSH_AFTER_COMMIT_FAILED' && error.commitSha)
);
const status = await service.status(working);
assert.equal(status.clean, true);
assert.equal(status.branch.ahead, 1);
const subject = await git(['log', '-1', '--pretty=%s'], working);
assert.equal(subject.stdout.trim(), 'Update portfolio');
});
test('stages a large Windows-sized partial selection through NUL-delimited stdin', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-large-selection-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const working = path.join(root, 'working');
await fs.mkdir(working);
await git(['init'], working);
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'), '# Large selection\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
const names = [];
for (let index = 0; index < 850; index += 1) {
const name = `generated/feature-${String(index).padStart(4, '0')}-${'x'.repeat(28)}.txt`;
names.push(name);
await fs.mkdir(path.dirname(path.join(working, name)), { recursive: true });
await fs.writeFile(path.join(working, name), `file ${index}\n`);
}
const service = new GitService();
const status = await service.stage(working, names);
assert.equal(status.counts.staged, names.length);
assert.equal(status.counts.unstaged, 0);
});
test('detects and removes a stale HEAD.lock while skipping Git object storage', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-head-lock-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await git(['init'], root);
await git(['config', 'user.name', 'ForgeFlow Test'], root);
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
await fs.writeFile(path.join(root, 'README.md'), 'lock test\n');
await git(['add', '.'], root);
await git(['commit', '-m', 'Initial'], root);
const headLock = path.join(root, '.git', 'HEAD.lock');
const ignoredObjectLock = path.join(root, '.git', 'objects', 'fake.lock');
await fs.writeFile(headLock, 'stale');
await fs.writeFile(ignoredObjectLock, 'not a repository mutation lock');
const old = new Date(Date.now() - 60_000);
await fs.utimes(headLock, old, old);
const service = new GitService();
const report = await service.listGitLocks(root);
assert.deepEqual(report.locks.map((item) => item.name), ['HEAD.lock']);
const repaired = await service.repairStaleGitLocks(root, { minimumAgeMs: 0, allowWithoutProcessProbe: true });
assert.equal(repaired.removed.length, 1);
await assert.rejects(() => fs.stat(headLock), (error) => error.code === 'ENOENT');
assert.ok(await fs.stat(ignoredObjectLock));
});
test('previews and safely mirrors a workspace to Gitea while preserving every class of local work', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-workspace-sync-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
const external = path.join(root, 'external');
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, '.gitignore'), 'runtime/\n');
await fs.writeFile(path.join(working, 'README.md'), 'initial\n');
await fs.writeFile(path.join(working, 'obsolete.txt'), 'remove remotely\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await git(['clone', remote, external], root);
await git(['config', 'user.name', 'External Gitea Test'], external);
await git(['config', 'user.email', 'external@example.invalid'], external);
await git(['checkout', 'main'], external);
await fs.writeFile(path.join(external, 'README.md'), 'changed on Gitea\n');
await fs.rm(path.join(external, 'obsolete.txt'));
await fs.writeFile(path.join(external, 'remote-only.txt'), 'new on Gitea\n');
await git(['add', '-A'], external);
await git(['commit', '-m', 'External cleanup'], external);
await git(['push', 'origin', 'main'], external);
await fs.writeFile(path.join(working, 'local-commit.txt'), 'local committed work\n');
await git(['add', 'local-commit.txt'], working);
await git(['commit', '-m', 'Local Codex work'], working);
const localHead = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
await fs.appendFile(path.join(working, 'README.md'), 'local uncommitted edit\n');
await fs.writeFile(path.join(working, 'local-notes.txt'), 'untracked local notes\n');
await fs.mkdir(path.join(working, 'runtime'), { recursive: true });
await fs.writeFile(path.join(working, 'runtime', 'local.db'), 'ignored runtime state\n');
const service = new GitService();
const firstPlan = await service.previewWorkspaceSync(working);
assert.match(firstPlan.id, /^[0-9a-f]{64}$/);
assert.equal(firstPlan.summary.localCommitsToProtect, 1);
assert.equal(firstPlan.summary.incomingCommits, 1);
assert.equal(firstPlan.summary.localFilesToStash, 2);
assert.equal(firstPlan.summary.untrackedFilesToStash, 1);
assert.ok(firstPlan.changes.some((item) => item.path === 'obsolete.txt' && item.code === 'D'));
assert.equal(firstPlan.recovery.ignoredFilesPreserved, true);
await fs.writeFile(path.join(working, 'changed-after-preview.txt'), 'forces a stale plan\n');
await assert.rejects(
service.synchronizeWorkspace(working, firstPlan.id),
(error) => error.code === 'WORKSPACE_SYNC_PLAN_STALE'
);
assert.equal(await fs.readFile(path.join(working, 'changed-after-preview.txt'), 'utf8'), 'forces a stale plan\n');
const reviewedPlan = await service.previewWorkspaceSync(working);
const result = await service.synchronizeWorkspace(working, reviewedPlan.id);
assert.equal(result.applied, true);
assert.equal(result.status.clean, true);
assert.equal(result.status.head, reviewedPlan.targetSha);
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
assert.ok(result.stash?.sha);
assert.equal(result.stash.quarantined, true);
assert.equal(result.review.id, reviewedPlan.id);
assert.equal(result.review.status, 'pending-codex-review');
const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8'));
assert.equal(reviewManifest.recoveryBranch, result.backupBranch);
assert.equal(reviewManifest.stashSha, result.stash.sha);
assert.deepEqual(
new Set(reviewManifest.files.map((file) => file.path)),
new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt'])
);
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
await assert.rejects(fs.stat(path.join(working, 'obsolete.txt')), (error) => error.code === 'ENOENT');
await assert.rejects(fs.stat(path.join(working, 'local-commit.txt')), (error) => error.code === 'ENOENT');
await assert.rejects(fs.stat(path.join(working, 'local-notes.txt')), (error) => error.code === 'ENOENT');
assert.equal(await fs.readFile(path.join(working, 'runtime', 'local.db'), 'utf8'), 'ignored runtime state\n');
const stashedPaths = (await git(['stash', 'show', '--include-untracked', '--name-only', result.stash.ref], working)).stdout;
assert.match(stashedPaths, /README\.md/);
assert.match(stashedPaths, /local-notes\.txt/);
assert.match(stashedPaths, /changed-after-preview\.txt/);
await assert.rejects(
service.popStash(working, result.stash.ref),
(error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'
);
await git(['switch', result.backupBranch], working);
await assert.rejects(
service.push(working),
(error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'
);
});
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-diverged-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const remote = path.join(root, 'remote.git');
const working = path.join(root, 'working');
const other = path.join(root, 'other');
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'), 'initial\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Initial'], working);
await git(['branch', '-M', 'main'], working);
await git(['push', '-u', 'origin', 'main'], working);
await git(['clone', remote, other], root);
await git(['config', 'user.name', 'Other Test'], other);
await git(['config', 'user.email', 'other@example.invalid'], other);
await git(['checkout', 'main'], other);
await fs.writeFile(path.join(other, 'remote.txt'), 'remote\n');
await git(['add', '.'], other);
await git(['commit', '-m', 'Remote commit'], other);
await git(['push', 'origin', 'main'], other);
await fs.writeFile(path.join(working, 'local.txt'), 'local\n');
await git(['add', '.'], working);
await git(['commit', '-m', 'Local commit'], working);
const localBefore = (await git(['rev-parse', 'HEAD'], working)).stdout.trim();
const service = new GitService();
const scan = await service.reconcile(working);
assert.equal(scan.status.branch.ahead, 1);
assert.equal(scan.status.branch.behind, 1);
assert.ok(scan.recommendations.some((item) => item.action === 'backup-reset'));
const repaired = await service.repairSync(working, 'backup-reset');
assert.match(repaired.backupBranch, /^forgeflow\/backup-main-/);
assert.equal(repaired.status.branch.ahead, 0);
assert.equal(repaired.status.branch.behind, 0);
const backupSha = (await git(['rev-parse', repaired.backupBranch], working)).stdout.trim();
assert.equal(backupSha, localBefore);
});
test('troubleshooter detects and aborts an interrupted merge without discarding committed history', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-interrupted-merge-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await git(['init'], root);
await git(['config', 'user.name', 'ForgeFlow Test'], root);
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
await fs.writeFile(path.join(root, 'file.txt'), 'base\n');
await git(['add', '.'], root);
await git(['commit', '-m', 'Base'], root);
await git(['checkout', '-b', 'other'], root);
await fs.writeFile(path.join(root, 'file.txt'), 'other\n');
await git(['commit', '-am', 'Other'], root);
await git(['checkout', 'master'], root);
await fs.writeFile(path.join(root, 'file.txt'), 'main\n');
await git(['commit', '-am', 'Main'], root);
await assert.rejects(git(['merge', 'other'], root));
const service = new GitService();
assert.equal(await service.detectInterruptedOperation(root), 'merge');
const result = await service.abortInterruptedOperation(root);
assert.equal(result.aborted, 'merge');
assert.equal(await service.detectInterruptedOperation(root), null);
assert.equal(result.status.clean, true);
const subject = await git(['log', '-1', '--pretty=%s'], root);
assert.equal(subject.stdout.trim(), 'Main');
});
+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');
});
+67
View File
@@ -0,0 +1,67 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
normalizePolicy,
validateSuppression,
applyPolicy,
buildTrend,
exportReport,
} = require("../src/main/git-validator-policy.cjs");
test("Git Validator policies enforce score, blockers and enabled checks", () => {
const policy = normalizePolicy({ id: "organization", label: "ITWorx", requiredScore: 88, enabledChecks: ["security", "readme"], blockingChecks: ["security"] });
const governed = applyPolicy([
{ id: "security", status: "warning", category: "Security", weight: 10 },
{ id: "readme", status: "pass", category: "Collaboration", weight: 5 },
{ id: "ignored", status: "error", category: "Other", weight: 5 },
], policy, []);
assert.equal(governed.policy.requiredScore, 88);
assert.deepEqual(governed.checks.map((check) => check.id), ["security", "readme"]);
assert.equal(governed.checks[0].blocking, true);
});
test("built-in policies enforce their declared blocking severities", () => {
const finding = [{ id: "readme", status: "warning", category: "Documentation", weight: 5 }];
assert.equal(applyPolicy(finding, { id: "minimal" }, []).checks[0].blocking, false);
for (const id of ["standard", "strict", "production"])
assert.equal(applyPolicy(finding, { id }, []).checks[0].blocking, true, `${id} must block active warnings`);
});
test("documented suppressions remove active blockers", () => {
const now = new Date("2026-07-01T00:00:00.000Z");
const suppression = validateSuppression({ checkId: "readme", reason: "Tracked remediation work", author: "Jens", expiresAt: "2026-07-08T00:00:00.000Z", evidence: "ticket:FF-7" }, normalizePolicy({ id: "standard" }), now);
const check = applyPolicy([{ id: "readme", status: "warning", category: "Documentation", weight: 5 }], { id: "standard" }, [suppression], now).checks[0];
assert.equal(check.suppressed, true);
assert.equal(check.blocking, false);
});
test("suppressions require accountable evidence and reactivate after expiry", () => {
const now = new Date("2026-07-01T00:00:00.000Z");
const suppression = validateSuppression({ checkId: "signed-tags", reason: "Tracked under release hardening", author: "Jens", ticket: "FF-42", expiresAt: "2026-07-08T00:00:00.000Z", scope: "repository", evidence: "sha:abc" }, normalizePolicy({ id: "standard" }), now);
let governed = applyPolicy([{ id: "signed-tags", status: "warning", category: "Governance", weight: 5 }], { id: "standard" }, [suppression], now);
assert.equal(governed.checks[0].suppressed, true);
governed = applyPolicy(governed.checks, { id: "standard" }, [suppression], new Date("2026-07-09T00:00:00.000Z"));
assert.equal(governed.checks[0].suppressed, false);
assert.equal(governed.checks[0].expiredSuppression.id, suppression.id);
assert.throws(() => validateSuppression({ checkId: "x", reason: "short", author: "a", expiresAt: "2026-07-02", evidence: "x" }, normalizePolicy(), now), /requires/);
});
test("trends report new, resolved and regressed findings without false precision", () => {
const previous = { checks: [{ id: "a", status: "warning" }, { id: "b", status: "error" }, { id: "c", status: "pass" }] };
const report = { score: 74, categories: { Security: 50 }, checkedAt: "2026-07-02T00:00:00Z", commitSha: "abc", checks: [{ id: "a", status: "error" }, { id: "b", status: "pass" }, { id: "c", status: "warning", suppressed: true }] };
const trend = buildTrend(previous, report);
assert.deepEqual(trend.regressions, ["a"]);
assert.deepEqual(trend.resolved.sort(), ["b"]);
assert.deepEqual(trend.suppressions, ["c"]);
});
test("reports export as JSON, Markdown and standalone escaped HTML", () => {
const report = { repository: "jens/<app>", score: 80, commitSha: "abc", policy: { label: "Production" }, checks: [{ id: "readme", category: "Collaboration", status: "pass", detail: "Safe & ready" }] };
assert.doesNotThrow(() => JSON.parse(exportReport(report, "json").content));
assert.match(exportReport(report, "markdown").content, /\| readme \|/);
const html = exportReport(report, "html").content;
assert.match(html, /<!doctype html>/);
assert.match(html, /jens\/&lt;app&gt;/);
});
+142
View File
@@ -0,0 +1,142 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { createRequire } from "node:module";
const exec = promisify(execFile);
const require = createRequire(import.meta.url);
const { GitService } = require("../src/main/git-service.cjs");
const {
GitValidatorService,
isSensitiveTrackedPath,
sameRemote,
} = require("../src/main/git-validator-service.cjs");
async function git(args, cwd) {
return exec("git", args, { cwd, encoding: "utf8" });
}
test("Git Validator scores repository hygiene and offers bounded safe repairs", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-validator-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(
["remote", "add", "origin", "https://gitea.example.test/jens/app.git"],
root,
);
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({
git: new GitService(),
gitea: {
getBranchProtection: async () => ({
protected: false,
enableForcePush: false,
}),
},
});
const repository = {
fullName: "jens/app",
name: "app",
owner: { login: "jens" },
defaultBranch: "main",
localPath: root,
cloneUrl: "https://gitea.example.test/jens/app.git",
sshUrl: "git@gitea.example.test:jens/app.git",
};
const report = await validator.scan(repository);
assert.ok(report.score > 60);
assert.equal(
report.checks.find((check) => check.id === "origin").status,
"pass",
);
assert.equal(
report.checks.find((check) => check.id === "default-branch-protection")
.fixAction,
"protect-default-branch",
);
const safety = report.checks.find((check) => check.id === "local-safety");
assert.equal(safety.safe, true);
await validator.repair(repository, safety);
const rescanned = await validator.scan(repository);
assert.equal(
rescanned.checks.find((check) => check.id === "local-safety").status,
"pass",
);
});
test("Git Validator creates a reviewable gitignore without committing it", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-ignore-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
const validator = new GitValidatorService({ git: new GitService() });
const repository = { localPath: root };
await validator.repair(repository, { fixAction: "add-gitignore" });
const content = await readFile(path.join(root, ".gitignore"), "utf8");
assert.match(content, /\.env/);
const status = await git(["status", "--short"], root);
assert.match(status.stdout, /\?\? \.gitignore/);
});
test("Git Validator recognizes remote aliases and secret-shaped tracked paths", () => {
assert.equal(
sameRemote(
"git@gitea.example.test:jens/app.git",
"https://gitea.example.test/jens/app",
),
true,
);
assert.equal(isSensitiveTrackedPath(".env.production"), true);
assert.equal(isSensitiveTrackedPath("config/private.pem"), true);
assert.equal(isSensitiveTrackedPath(".env.example"), false);
});
test("Git Validator rejects stale or forged repair requests", async () => {
const validator = new GitValidatorService({ git: new GitService() });
validator.scan = async () => ({
checks: [{ id: "local-safety", fixAction: "configure-local-safety", status: "warning" }],
});
assert.equal(
(await validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "configure-local-safety" })).id,
"local-safety",
);
await assert.rejects(
validator.resolveRepairCheck({}, { id: "local-safety", fixAction: "align-origin" }),
/stale/i,
);
await assert.rejects(
validator.resolveRepairCheck({}, { id: "resolved-check", fixAction: "align-origin" }),
/resolved|no longer repairable/i,
);
});
test("Git Validator reports reproducibility, CI and editor hygiene and creates reviewable defaults", async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), "forgeflow-hygiene-"));
t.after(() => rm(root, { recursive: true, force: true }));
await git(["init", "-b", "main"], root);
await git(["config", "user.name", "ForgeFlow Test"], root);
await git(["config", "user.email", "forgeflow@example.invalid"], root);
await git(["remote", "add", "origin", "https://gitea.example.test/jens/app.git"], root);
await writeFile(path.join(root, "package.json"), '{"name":"app"}\n', "utf8");
await writeFile(path.join(root, "README.md"), "# App\n", "utf8");
await writeFile(path.join(root, ".gitignore"), ".env\n", "utf8");
await git(["add", "."], root);
await git(["commit", "-m", "Initial"], root);
const validator = new GitValidatorService({ git: new GitService(), gitea: { getBranchProtection: async () => ({ protected: true, enableForcePush: false }) } });
const repository = { fullName: "jens/app", name: "app", owner: { login: "jens" }, defaultBranch: "main", localPath: root, cloneUrl: "https://gitea.example.test/jens/app.git" };
const report = await validator.scan(repository);
assert.equal(report.checks.find((check) => check.id === "dependency-lock").status, "warning");
assert.equal(report.checks.find((check) => check.id === "continuous-integration").status, "warning");
for (const action of ["add-gitattributes", "add-editorconfig"])
await validator.repair(repository, { fixAction: action });
assert.match(await readFile(path.join(root, ".gitattributes"), "utf8"), /text=auto/);
assert.match(await readFile(path.join(root, ".editorconfig"), "utf8"), /root = true/);
});
+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);
});
+343
View File
@@ -0,0 +1,343 @@
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);
});
test('creates controlled pull requests and reads branch protection', async () => {
const service = new GiteaService(makeStore());
const calls = [];
service.request = async (pathname, options = {}) => {
calls.push({ pathname, options });
if (pathname.includes('/branches/main')) return { data: { name: 'main', protected: true } };
if (pathname.endsWith('/branch_protections')) return { data: [{ branch_name: 'main', required_approvals: 2, require_signed_commits: true }] };
return { data: { number: 12, html_url: 'https://gitea.test/owner/app/pulls/12' } };
};
const protection = await service.getBranchProtection('owner', 'app', 'main');
assert.equal(protection.protected, true);
assert.equal(protection.requiredApprovals, 2);
const pull = await service.createPullRequest({ owner: 'owner', repo: 'app', head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
assert.equal(pull.number, 12);
const create = calls.find((call) => call.options.method === 'POST');
assert.deepEqual(create.options.body, { head: 'feature', base: 'main', title: 'Release feature', body: 'Summary' });
await assert.rejects(() => service.createPullRequest({ owner: 'owner', repo: 'app', head: 'main', base: 'main', title: 'Invalid' }), /different/);
});
test('resolves release attachment metadata before downloading the actual asset', async () => {
const service = new GiteaService(makeStore());
let metadataPath = '';
let requested = '';
service.request = async (pathname) => {
metadataPath = pathname;
return {
data: {
id: 412,
browser_download_url: 'https://gitea.example.test/attachments/release.exe',
},
};
};
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412);
assert.equal(asset.toString(), 'asset');
assert.equal(
metadataPath,
'/repos/Jens/ForgeFlow/releases/107/assets/412',
);
assert.equal(
requested,
'https://gitea.example.test/attachments/release.exe',
);
await assert.rejects(
() => service.downloadReleaseAsset('Jens', 'ForgeFlow', null, 412),
/invalid release ID/,
);
});
test('uses a release-provided browser download URL without requesting metadata again', async () => {
const service = new GiteaService(makeStore());
service.request = async () => { throw new Error('metadata lookup should not run'); };
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
const asset = await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'https://gitea.example.test/attachments/direct.exe',
});
assert.equal(asset.toString(), 'asset');
assert.equal(requested, 'https://gitea.example.test/attachments/direct.exe');
});
test('rewrites Gitea internal HTTP release URLs to the configured public origin', async () => {
const service = new GiteaService(makeStore());
let requested = '';
service.downloadAuthenticated = async (pathname) => {
requested = pathname;
return Buffer.from('asset');
};
await service.downloadReleaseAsset('Jens', 'ForgeFlow', 107, 412, {
downloadUrl: 'http://192.168.56.10:3000/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe',
});
assert.equal(requested, 'https://gitea.example.test/Jens/ForgeFlow/releases/download/v0.10.1/ForgeFlow.exe');
});
test('creates conservative default branch protection rules', async () => {
const service = new GiteaService(makeStore());
let request = null;
service.request = async (pathname, options) => {
request = { pathname, options };
return { data: { rule_name: 'main' } };
};
const result = await service.createBranchProtection('jens', 'app', 'main');
assert.equal(result.rule_name, 'main');
assert.equal(request.options.method, 'POST');
assert.equal(request.options.body.enable_push, false);
assert.equal(request.options.body.enable_force_push, false);
assert.equal(request.options.body.rule_name, 'main');
});
test('creates repository-scoped read-only deploy keys and reuses only safe matches', async () => {
const service = new GiteaService(makeStore());
const publicKey = `ssh-ed25519 ${Buffer.from('public-key-material').toString('base64')} forgeflow:test`;
const requests = [];
service.request = async (pathname, options = {}) => {
requests.push({ pathname, options });
if (!options.method) return { data: [] };
return { data: { id: 41, key: publicKey, read_only: true } };
};
const created = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(created.created, true);
assert.equal(requests[1].options.body.read_only, true);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: true }] });
const reused = await service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey });
assert.equal(reused.created, false);
service.request = async () => ({ data: [{ id: 41, key: publicKey, read_only: false }] });
await assert.rejects(
() => service.ensureReadOnlyDeployKey({ owner: 'jens', repo: 'app', title: 'ForgeFlow', publicKey }),
(error) => error.code === 'DEPLOY_KEY_NOT_READ_ONLY',
);
});
test('request sends scoped credentials, parses response types and redacts rejected secrets', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const calls = [];
const diagnostics = { debug: async (...args) => calls.push(['debug', ...args]), warning: async (...args) => calls.push(['warning', ...args]) };
const service = new GiteaService(makeStore(), diagnostics);
globalThis.fetch = async (url, options) => {
calls.push([url, options]);
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'x-test': 'yes' } });
};
const json = await service.request('/user', { method: 'POST', body: { hello: 'world' }, headers: { 'X-Extra': 'value' } });
assert.deepEqual(json.data, { ok: true });
assert.equal(calls[0][1].headers.Authorization, 'token demo-token');
assert.equal(calls[0][1].headers['Content-Type'], 'application/json');
assert.equal(calls[0][1].headers['X-Extra'], 'value');
globalThis.fetch = async () => new Response('plain', { status: 200 });
assert.equal((await service.request('/plain', { responseType: 'text', auth: false })).data, 'plain');
globalThis.fetch = async () => new Response(Uint8Array.from([1, 2, 3]), { status: 200 });
assert.deepEqual((await service.request('/binary', { responseType: 'buffer' })).data, Buffer.from([1, 2, 3]));
globalThis.fetch = async () => new Response(null, { status: 204 });
assert.equal((await service.request('/empty')).data, null);
globalThis.fetch = async () => new Response(JSON.stringify({ message: 'bad demo-token' }), { status: 403, statusText: 'Forbidden' });
await assert.rejects(service.request('/denied'), (error) => error.status === 403 && !error.message.includes('demo-token'));
globalThis.fetch = async () => new Response('not found', { status: 404, statusText: 'Not Found' });
await assert.rejects(service.request('/missing'), (error) => error.status === 404 && error.payload === 'not found');
});
test('request rejects absent credentials and wraps network failures', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const warnings = [];
const service = new GiteaService({ data: { gitea: { baseUrl: 'https://gitea.example.test' } }, getToken: () => '' }, { warning: async (...args) => warnings.push(args) });
await assert.rejects(service.request('/user'), /no Gitea access token/i);
globalThis.fetch = async () => { const error = new Error('connect ECONNREFUSED'); error.code = 'ECONNREFUSED'; throw error; };
await assert.rejects(service.request('/version', { auth: false }), (error) => error.code === 'ECONNREFUSED' && /could not reach/i.test(error.message));
assert.equal(warnings[0][0], 'gitea.request.failed');
});
test('repository pagination, connection validation and simple endpoints preserve API data', async () => {
const service = new GiteaService(makeStore());
let pages = 0;
service.request = async (pathname) => {
if (pathname === '/user') return { data: { login: 'jens' } };
if (pathname === '/version') throw Object.assign(new Error('unsupported'), { status: 404 });
if (pathname.includes('/user/repos')) { pages += 1; return { data: pages === 1 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 51 }] }; }
if (pathname.includes('/branches/')) return { data: { name: 'main' } };
return { data: { id: 1 } };
};
const validated = await service.validateConnection('https://gitea.example.test/', 'token');
assert.equal(validated.repositoryCount, 50);
assert.equal(validated.version, null);
pages = 0;
assert.equal((await service.listRepositories()).length, 51);
assert.equal((await service.getRepository('owner space', 'repo/name')).id, 1);
assert.equal((await service.getBranch('owner', 'repo', 'feature/test')).name, 'main');
});
test('branch protection tolerates unsupported APIs but propagates server failures', async () => {
const service = new GiteaService(makeStore());
service.getBranch = async () => ({ protected: false });
service.request = async () => { throw Object.assign(new Error('unsupported'), { status: 404 }); };
const absent = await service.getBranchProtection('owner', 'repo', 'main');
assert.equal(absent.protected, false);
assert.equal(absent.enablePush, null);
service.request = async () => { throw Object.assign(new Error('down'), { status: 500 }); };
await assert.rejects(service.getBranchProtection('owner', 'repo', 'main'), /down/);
});
test('file, release, pull request and deploy-key helpers validate malformed API inputs', async () => {
const service = new GiteaService(makeStore());
service.request = async () => ({ data: [] });
assert.deepEqual(await service.listDeployKeys('owner', 'repo'), []);
assert.deepEqual(await service.listPullRequests({ owner: 'owner', repo: 'repo', limit: 500 }), []);
await assert.rejects(service.ensureReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i);
await assert.rejects(service.createReadOnlyDeployKey({ owner: 'owner', repo: 'repo', publicKey: 'invalid' }), /valid SSH public key/i);
await assert.rejects(service.deleteDeployKey('owner', 'repo', 0), /valid deploy-key ID/i);
await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: '' }), /1-255/);
await assert.rejects(service.createPullRequest({ owner: 'owner', repo: 'repo', head: 'a', base: 'b', title: 'x'.repeat(256) }), /1-255/);
await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'folder' }), /not a file/i);
service.request = async () => ({ data: { encoding: 'base64', content: Buffer.from('hello').toString('base64') } });
assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'hello');
service.request = async () => ({ data: { content: 'plain' } });
assert.equal((await service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' })).decoded, 'plain');
service.request = async () => ({ data: { encoding: 'none' } });
await assert.rejects(service.getRepositoryFile({ owner: 'owner', repo: 'repo', filePath: 'README' }), /readable content/i);
for (const method of ['getLatestRelease', 'getReleaseByTag']) {
service.request = async () => { throw Object.assign(new Error('missing'), { status: 404 }); };
assert.equal(await service[method]('owner', 'repo', 'v1'), null);
service.request = async () => { throw Object.assign(new Error('server'), { status: 500 }); };
await assert.rejects(service[method]('owner', 'repo', 'v1'), /server/);
}
});
test('authenticated downloads keep tokens same-origin and enforce secure redirects', async (context) => {
const originalFetch = globalThis.fetch;
context.after(() => { globalThis.fetch = originalFetch; });
const service = new GiteaService(makeStore());
const calls = [];
globalThis.fetch = async (url, options) => {
calls.push({ url: String(url), options });
if (calls.length === 1) return new Response(null, { status: 302, headers: { location: 'https://cdn.example.test/release.exe' } });
return new Response('asset', { status: 200 });
};
assert.equal((await service.downloadAuthenticated('/attachments/release.exe')).toString(), 'asset');
assert.equal(calls[0].options.headers.Authorization, 'token demo-token');
assert.equal(calls[1].options.headers.Authorization, undefined);
globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: 'http://cdn.example.test/file' } });
await assert.rejects(service.downloadAuthenticated('/file'), /insecure cross-origin/i);
globalThis.fetch = async () => new Response(null, { status: 302 });
await assert.rejects(service.downloadAuthenticated('/file'), /did not contain a destination/i);
globalThis.fetch = async () => new Response('missing', { status: 404 });
await assert.rejects(service.downloadAuthenticated('/file'), /HTTP 404/i);
let redirects = 0;
globalThis.fetch = async () => new Response(null, { status: 302, headers: { location: `/redirect-${redirects += 1}` } });
await assert.rejects(service.downloadAuthenticated('/file'), /redirect limit/i);
});
test('release downloads and workflow dispatch reject inconsistent evidence', async () => {
const service = new GiteaService(makeStore());
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 0), /invalid release asset ID/i);
service.request = async () => ({ data: { id: 2, browser_download_url: 'https://gitea.example/file' } });
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /different release asset/i);
service.request = async () => ({ data: { id: 3, browser_download_url: '' } });
await assert.rejects(service.downloadReleaseAsset('owner', 'repo', 1, 3), /did not provide/i);
service.request = async () => ({ status: 202, data: null });
assert.deepEqual(await service.dispatchWorkflow({ owner: 'owner', repo: 'repo', workflowFile: 'deploy.yml', ref: 'main' }), { accepted: false, status: 202 });
});
+124
View File
@@ -0,0 +1,124 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { classifyInventory } = require("../src/main/inventory-classifier.cjs");
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("../src/main/deployment-identity.cjs");
const { InventoryReviewService } = require("../src/main/inventory-review-service.cjs");
function workload(id, options = {}) {
return {
workloadId: id, serverId: options.serverId || "unraid", displayName: options.name || id,
status: options.status || (options.link ? "linked" : "suggested"), link: options.link || null,
compose: { project: options.project || id, workingDir: options.root || `/mnt/user/appdata/${id}`, configFiles: options.files || [`/mnt/user/appdata/${id}/compose.yml`], services: ["app"] },
containers: options.noContainers ? [] : [{ id: `container-${id}`, name: id, running: options.running !== false }],
runtime: { running: options.running !== false, health: options.health || "healthy" },
metadata: { sourceRepository: options.remote ?? "git@gitea.test:Jens/Portfolio.git", liveRevision: options.sha || "a".repeat(40), branch: options.branch || "main" },
candidates: options.candidates || [{ repositoryFullName: "Jens/Portfolio", score: 100, exact: true }],
remoteFolderCandidate: id,
};
}
test("deployment identity is canonical across SSH and HTTPS remotes", () => {
const ssh = deploymentIdentity({ workload: workload("one") });
const https = deploymentIdentity({ workload: workload("two", { remote: "https://gitea.test/Jens/Portfolio.git" }) });
assert.equal(ssh.repository, https.repository);
assert.equal(deploymentAuthorityKey(ssh), deploymentAuthorityKey({ ...https, serverId: ssh.serverId, environment: ssh.environment }));
});
test("running duplicate is authoritative and historical folder is never linked", () => {
const active = workload("portfolio-current", { link: { profileId: "profile", repositoryFullName: "Jens/Portfolio" } });
const historical = workload("portfolio-old", { running: false, root: "/mnt/user/appdata/portfolio/releases/old", link: { profileId: "profile-old", repositoryFullName: "Jens/Portfolio" } });
const result = classifyInventory([historical, active], [{ id: "profile", environment: "production" }, { id: "profile-old", environment: "production" }]);
assert.equal(result.find((item) => item.workloadId === "portfolio-current").authoritative, true);
assert.equal(result.find((item) => item.workloadId === "portfolio-old").classification.type, "backup");
assert.equal(result.find((item) => item.workloadId === "portfolio-old").shadowedLink.profileId, "profile-old");
assert.equal(result.find((item) => item.workloadId === "portfolio-old").link, null);
});
for (const [label, item, expected] of [
["backup Compose folder", workload("backup", { root: "/mnt/user/appdata/portfolio-backup", running: false }), "backup"],
["release directory", workload("release", { root: "/mnt/user/appdata/portfolio/releases/a1", running: false }), "release-folder"],
["staging workload", workload("stage", { root: "/mnt/user/appdata/portfolio-staging" }), "staging"],
["candidate runtime", workload("candidate", { name: "portfolio-candidate-039" }), "temporary-runtime"],
["stopped legitimate app", workload("stopped", { running: false }), "stopped-application"],
["Compose without container", workload("historical", { noContainers: true, running: false }), "historical-compose"],
["external container without Git provenance", workload("external", { remote: "", candidates: [], status: "unmatched" }), "external-container"],
["external container with inaccessible repository provenance", workload("external-git", { remote: "https://github.com/vendor/image.git", candidates: [], status: "unmatched" }), "external-container"],
["system container", workload("infra", { name: "watchtower", remote: "", candidates: [] }), "system-container"],
["ambiguous exact matches", workload("ambiguous", { status: "ambiguous", candidates: [{ repositoryFullName: "Jens/A", score: 100, exact: true }, { repositoryFullName: "Jens/B", score: 100, exact: true }] }), "ambiguous"],
["profile whose server workload disappeared", { ...workload("stale", { running: false, noContainers: true, link: { profileId: "profile-stale", repositoryFullName: "Jens/Portfolio" } }), metadata: { sourceRepository: "git@gitea.test:Jens/Portfolio.git", branch: "main", staleLink: true } }, "stale-link"],
]) test(`inventory classifies ${label}`, () => {
assert.equal(classifyInventory([item])[0].classification.type, expected);
});
test("multi-instance environments remain separate authority groups", () => {
const a = workload("instance-a", { link: { profileId: "a", repositoryFullName: "Jens/Portfolio" } });
const b = workload("instance-b", { link: { profileId: "b", repositoryFullName: "Jens/Portfolio" } });
const result = classifyInventory([a, b], [{ id: "a", environment: "production" }, { id: "b", environment: "staging" }]);
assert.equal(result.filter((item) => item.classification.type === "duplicate").length, 0);
});
test("stored review decision becomes stale when remote evidence changes", () => {
const original = classifyInventory([workload("review")])[0];
const decision = { workloadId: original.workloadId, evidenceHash: original.evidenceHash, action: "ignore", reason: "Known external workload" };
const unchanged = classifyInventory([workload("review")], [], [decision])[0];
const changed = classifyInventory([workload("review", { remote: "git@gitea.test:Jens/Renamed.git" })], [], [decision])[0];
assert.equal(unchanged.reviewDecision.action, "ignore");
assert.equal(changed.reviewDecision, null);
assert.equal(changed.reviewDecisionStale, true);
});
test("review decisions drive classification and explicit authority", () => {
const primary = classifyInventory([workload("primary")])[0];
const secondary = classifyInventory([workload("secondary")])[0];
const decisions = [
{ workloadId: primary.workloadId, evidenceHash: primary.evidenceHash, action: "mark-historical", reason: "Retained rollback definition" },
{ workloadId: secondary.workloadId, evidenceHash: secondary.evidenceHash, action: "select-authoritative", reason: "Verified production instance" },
];
const result = classifyInventory([workload("primary"), workload("secondary")], [], decisions);
assert.equal(result.find((item) => item.workloadId === "primary").classification.type, "historical-compose");
assert.equal(result.find((item) => item.workloadId === "secondary").authoritative, true);
});
test("ignore and monitor-only decisions stay evidence-bound", () => {
const ignored = classifyInventory([workload("ignored")])[0];
const monitoredSource = workload("monitored", { remote: "git@gitea.test:Jens/Monitored.git", candidates: [{ repositoryFullName: "Jens/Monitored", score: 100, exact: true }] });
const monitored = classifyInventory([monitoredSource])[0];
const result = classifyInventory([workload("ignored"), monitoredSource], [], [
{ workloadId: ignored.workloadId, evidenceHash: ignored.evidenceHash, action: "ignore", reason: "Managed by another platform" },
{ workloadId: monitored.workloadId, evidenceHash: monitored.evidenceHash, action: "monitor-only", reason: "Visibility without deployment ownership" },
]);
assert.equal(result.find((item) => item.workloadId === "ignored").classification.type, "manually-excluded");
assert.equal(result.find((item) => item.workloadId === "monitored").classification.type, "monitor-only");
});
test("review service requires reason, exact plan and recovery snapshot", async () => {
const decisions = [];
const store = { getInventoryReviewDecisions: () => decisions, createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), saveInventoryReviewDecision: async (_server, decision) => { decisions.push(decision); return decision; } };
const service = new InventoryReviewService({ store });
const item = classifyInventory([workload("review")])[0];
assert.throws(() => service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "no" }), (error) => error.code === "INVENTORY_REVIEW_REASON_REQUIRED");
const plan = service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "Managed outside ForgeFlow" });
await assert.rejects(service.apply({ plan }), (error) => error.code === "INVENTORY_REVIEW_PLAN_REQUIRED");
const result = await service.apply({ plan, expectedPlanId: plan.id });
assert.equal(result.snapshot.filePath, "snapshot.json");
assert.equal(decisions[0].evidenceHash, item.evidenceHash);
});
test("large inventory classification is deterministic and bounded", () => {
const input = Array.from({ length: 1200 }, (_, index) => workload(`app-${index}`, { remote: `git@gitea.test:Jens/App-${index}.git`, candidates: [{ repositoryFullName: `Jens/App-${index}`, score: 100, exact: true }] }));
const started = Date.now();
const result = classifyInventory(input);
assert.equal(result.length, 1200);
assert.ok(Date.now() - started < 2000);
assert.equal(new Set(result.map((item) => item.evidenceHash)).size, 1200);
});
test("evidence hash changes for runtime, Compose and candidate changes", () => {
const identity = deploymentIdentity({ workload: workload("hash") });
const one = deploymentEvidenceHash(identity, { running: true, files: ["compose.yml"] });
const two = deploymentEvidenceHash(identity, { running: false, files: ["compose.yml"] });
assert.notEqual(one, two);
});
+49
View File
@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
async function rendererSource() {
return (await Promise.all(["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"].map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
}
test("every preload invoke channel has a registered IPC handler", async () => {
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
const ipc = (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
const invokes = [...preload.matchAll(/invoke\(\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1],
);
const handlers = new Set(
[...ipc.matchAll(/register\(\s*['"]([^'"]+)['"]/g)].map(
(match) => match[1],
),
);
assert.ok(invokes.length > 40, "expected the complete renderer API surface");
assert.deepEqual(
invokes.filter((channel) => !handlers.has(channel)),
[],
);
});
test("every renderer bridge call is exposed by the preload contract", async () => {
const renderer = await rendererSource();
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
const calls = new Set(
[...renderer.matchAll(/window\.forgeflow\.([A-Za-z0-9_]+)\s*\(/g)].map(
(match) => match[1],
),
);
const exposed = new Set(
[...preload.matchAll(/^\s+([A-Za-z0-9_]+):/gm)].map((match) => match[1]),
);
assert.ok(calls.size > 40, "expected the complete renderer bridge surface");
assert.deepEqual(
[...calls].filter((method) => !exposed.has(method)),
[],
);
});
+54
View File
@@ -0,0 +1,54 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import redaction from '../src/main/log-redaction.cjs';
const { redactSecrets, sanitizeForDiagnostics, pathAlias, stableAlias, redactPrivateInfrastructure } = 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\\example-user\\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', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { strictIdentifiers: true });
const second = sanitizeForDiagnostics({ fullName: 'jens/private-project', login: 'jens', host: '192.168.10.20', basePath: '/mnt/user/appdata' }, { 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.notEqual(first.host, '192.168.10.20');
assert.notEqual(first.basePath, '/mnt/user/appdata');
assert.equal(stableAlias('same', 'repo'), stableAlias('same', 'repo'));
});
test('strict privacy redacts private addresses, infrastructure URLs and server paths in log text', () => {
const result = redactPrivateInfrastructure('host 192.168.10.20 url https://internal.example.test/status path /mnt/user/appdata/example');
assert.doesNotMatch(result, /192\.168\.10\.20|internal\.example\.test|\/mnt\/user\/appdata/);
});
test('path aliasing handles slash variants', () => {
const result = pathAlias('C:\\Users\\example-user\\src and C:/Users/example-user/src', { homeDir: 'C:\\Users\\example-user', cwd: 'D:\\ForgeFlow' });
assert.doesNotMatch(result, /Users[\\/]Jens/);
assert.match(result, /<HOME>/);
});
+44
View File
@@ -0,0 +1,44 @@
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 git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' });
const { GitService, parseUnifiedDiff } = gitModule;
test('unified diff parser separates selectable hunks', () => {
const parsed = parseUnifiedDiff('diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-old\n+new\n@@ -10 +10 @@\n-x\n+y\n');
assert.equal(parsed.hunks.length, 2);
assert.equal(parsed.hunks[0].additions, 1);
assert.equal(parsed.hunks[1].deletions, 1);
});
test('stages only selected hunks using a server-generated patch', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-hunks-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await git(['init'], root);
await git(['config', 'user.name', 'ForgeFlow Test'], root);
await git(['config', 'user.email', 'forgeflow@example.invalid'], root);
const original = [...Array(20)].map((_, index) => `line ${index + 1}`).join('\n') + '\n';
await fs.writeFile(path.join(root, 'file.txt'), original);
await git(['add', '.'], root); await git(['commit', '-m', 'Initial'], root);
const lines = original.trimEnd().split('\n'); lines[0] = 'first changed'; lines[19] = 'last changed';
await fs.writeFile(path.join(root, 'file.txt'), `${lines.join('\n')}\n`);
const service = new GitService();
const hunks = await service.diffHunks(root, 'file.txt');
assert.equal(hunks.hunks.length, 2);
await service.stageHunks(root, 'file.txt', [0]);
const staged = (await git(['diff', '--cached'], root)).stdout;
const unstaged = (await git(['diff'], root)).stdout;
assert.match(staged, /first changed/); assert.doesNotMatch(staged, /last changed/);
assert.match(unstaged, /last changed/); assert.doesNotMatch(unstaged, /first changed/);
await service.commitStaged(root, 'Commit reviewed hunk');
const afterCommit = (await git(['diff'], root)).stdout;
assert.match(afterCommit, /last changed/);
assert.doesNotMatch(afterCommit, /first changed/);
});
+177
View File
@@ -0,0 +1,177 @@
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);
});
test('system preflight reports unavailable Git, storage, roots and rejected Gitea credentials', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-failures-'));
t.after(() => rm(root, { recursive: true, force: true }));
const ordinaryFile = path.join(root, 'not-a-directory');
await writeFile(ordinaryFile, 'file');
const events = [];
const service = new PreflightService({
store: { data: { gitea: { baseUrl: 'https://stored.test' } }, getToken: () => 'stored-token' },
git: { isAvailable: async () => ({ available: false, error: 'git missing' }) },
gitea: { validateConnection: async () => { throw new Error('token rejected'); } },
deployments: {}, diagnostics: { logDirectory: path.join(root, 'logs'), info: async (...args) => events.push(args) },
userDataPath: path.join(root, 'data'), secureStorageAvailable: () => false
});
service.writableDirectory = async (directory) => {
if (directory.endsWith('data')) throw new Error('read only');
return true;
};
const result = await service.runSystem({ roots: [ordinaryFile, path.join(root, 'missing'), ordinaryFile, ''] });
assert.equal(result.checks.find((item) => item.id === 'git.available').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'storage.userdata').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'storage.diagnostics').status, 'pass');
assert.equal(result.checks.find((item) => item.id === 'storage.credentials').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'workspace.root.0').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'workspace.root.1').status, 'fail');
assert.equal(result.checks.find((item) => item.id === 'gitea.connection').status, 'fail');
assert.equal(result.summary.ready, false);
assert.equal(events[0][0], 'preflight.system.completed');
});
test('system preflight warns on incomplete Git identity and accepts unknown Gitea version', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-identity-'));
t.after(() => rm(root, { recursive: true, force: true }));
const service = new PreflightService({
store: { data: { gitea: { baseUrl: '' } }, getToken: () => '' },
git: { isAvailable: async () => ({ available: true, version: 'git' }) },
gitea: { validateConnection: async () => ({ version: null, user: null, repositoryCount: 0 }) }, deployments: {},
diagnostics: { logDirectory: path.join(root, 'logs'), info: async () => {} }, userDataPath: path.join(root, 'data')
});
service.gitIdentity = async () => ({ name: '', email: '' });
const result = await service.runSystem({ baseUrl: 'https://gitea.test', token: 'token', roots: [] });
assert.equal(result.checks.find((item) => item.id === 'git.identity').status, 'warning');
assert.match(result.checks.find((item) => item.id === 'gitea.connection').detail, /unknown version.*user/i);
assert.equal(result.checks.find((item) => item.id === 'gitea.repositories').status, 'pass');
assert.equal(result.checks.find((item) => item.id === 'workspace.roots').status, 'warning');
service.gitIdentity = async () => { throw new Error('identity lookup failed'); };
const second = await service.runSystem();
assert.match(second.checks.find((item) => item.id === 'git.identity').detail, /lookup failed/i);
});
test('deployment preflight fails fast for invalid identity, profile and missing local link', async () => {
const diagnostics = [];
const service = new PreflightService({
store: { getDeploymentProfile: (_name, id) => id === 'known' ? { id: 'known', name: 'Production' } : null },
git: {}, gitea: {}, deployments: {}, diagnostics: { info: async (...args) => diagnostics.push(args) }, userDataPath: ''
});
await assert.rejects(service.runDeployment({ repository: null, profileId: 'known' }), /identity is required/i);
await assert.rejects(service.runDeployment({ repository: { fullName: 'owner/app' }, profileId: 'missing' }), /profile not found/i);
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: '' }, profileId: 'known' });
assert.deepEqual(result.summary.blocking, ['repository.linked']);
assert.equal(diagnostics[0][0], 'preflight.deployment.completed');
});
test('deployment preflight preserves actionable evidence across Git, workflow and endpoint failures', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-degraded-'));
t.after(() => rm(root, { recursive: true, force: true }));
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', rollbackWorkflowFile: 'rollback.yml' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: {
status: async () => ({ root, head: 'b'.repeat(40), clean: false, counts: { changed: 4 }, branch: { head: '', upstream: '', ahead: 2, behind: 3 } }),
verifyCommitOnRemoteBranch: async () => { throw new Error('commit not published'); }
},
gitea: { repositoryFileExists: async () => false, listWorkflowRuns: async () => { throw new Error('Actions disabled'); } },
deployments: {}, diagnostics: { info: async () => {} }, userDataPath: root
});
const result = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
for (const id of ['git.branch', 'git.clean', 'git.upstream', 'git.sync', 'git.remote-sha', 'workflow.deploy.local', 'workflow.deploy.remote', 'gitea.actions', 'server.status.configured']) {
assert.equal(result.checks.find((item) => item.id === id).status, 'fail', id);
}
assert.equal(result.checks.find((item) => item.id === 'workflow.rollback.local').status, 'warning');
assert.equal(result.checks.find((item) => item.id === 'server.health').status, 'warning');
assert.equal(result.head, 'b'.repeat(40));
});
test('deployment preflight distinguishes unreachable and mismatched status evidence', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-preflight-status-'));
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');
const profile = { id: 'production', name: 'Production', environment: 'production', branch: 'main', workflowFile: 'deploy.yml', statusUrl: 'https://app/status', healthcheckUrl: 'https://app/health' };
let status = { reachable: false, ok: false, status: 503, error: '' };
const service = new PreflightService({
store: { getDeploymentProfile: () => profile },
git: { status: async () => { throw new Error('checkout corrupt'); } },
gitea: { repositoryFileExists: async () => { throw new Error('Gitea offline'); } },
deployments: { readStatusEndpoint: async () => status, checkHealth: async () => ({ healthy: false, status: 500, error: '' }) },
diagnostics: { info: async () => {} }, userDataPath: root
});
const unreachable = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
assert.match(unreachable.checks.find((item) => item.id === 'server.status.reachable').detail, /HTTP 503/i);
assert.match(unreachable.checks.find((item) => item.id === 'server.health').detail, /HTTP 500/i);
assert.match(unreachable.checks.find((item) => item.id === 'git.repository').detail, /checkout corrupt/i);
status = { reachable: true, ok: true, repository: 'other/app', environment: 'staging', liveSha: null };
const mismatch = await service.runDeployment({ repository: { fullName: 'owner/app', localPath: root }, profileId: profile.id });
const identity = mismatch.checks.find((item) => item.id === 'server.status.identity');
assert.equal(identity.status, 'fail');
assert.equal(identity.required, true);
assert.match(mismatch.checks.find((item) => item.id === 'server.status.reachable').detail, /no live SHA/i);
});
+26
View File
@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import policyModule from '../src/main/process-error-policy.cjs';
const { installOutputPipeGuards, isBrokenPipeError } = policyModule;
test('broken output pipes are recognized without treating unrelated failures as EPIPE', () => {
assert.equal(isBrokenPipeError(Object.assign(new Error('closed'), { code: 'EPIPE' })), true);
assert.equal(isBrokenPipeError(Object.assign(new Error('denied'), { code: 'EACCES' })), false);
assert.equal(isBrokenPipeError(null), false);
});
test('output pipe guard absorbs EPIPE and can be cleanly removed', () => {
const stdout = new EventEmitter();
const stderr = new EventEmitter();
const observed = [];
const remove = installOutputPipeGuards({ stdout, stderr, onBrokenPipe: (error) => observed.push(error.code) });
stdout.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
stderr.emit('error', Object.assign(new Error('closed'), { code: 'EPIPE' }));
assert.deepEqual(observed, ['EPIPE', 'EPIPE']);
remove();
assert.equal(stdout.listenerCount('error'), 0);
assert.equal(stderr.listenerCount('error'), 0);
});
+129
View File
@@ -0,0 +1,129 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFile, rm, writeFile } from "node:fs/promises";
const require = createRequire(import.meta.url);
const { ProductionAcceptanceHarness } = require("../src/main/production-acceptance-harness.cjs");
async function fixture(t) {
const harness = await ProductionAcceptanceHarness.create();
t.after(() => harness.cleanup());
return harness;
}
test("production harness proves clean install, portable start and configuration migration", async (t) => {
const harness = await fixture(t);
assert.equal((await harness.install("0.10.0", "portable")).mode, "portable");
const migration = await harness.migrate("1.0.0-rc.1");
assert.equal(migration.previousVersion, "0.10.0");
assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).schemaVersion, 13);
assert.equal(JSON.parse(await readFile(migration.backup, "utf8")).schemaVersion, 12);
});
test("authentication fixtures cover token rotation, password, SSH keys and changed host keys", async (t) => {
const harness = await fixture(t);
assert.equal(harness.rotateToken().tokenVersion, 2);
assert.equal(harness.authenticate("password").authenticated, true);
assert.equal(harness.authenticate("ssh-key", { hostFingerprint: "SHA256:fixture-host" }).authenticated, true);
assert.throws(() => harness.authenticate("ssh-key", { hostFingerprint: "SHA256:changed" }), /fingerprint changed/);
});
test("new repositories deploy by server pull and Direct Copy at the exact Gitea SHA", async (t) => {
const harness = await fixture(t);
await harness.install();
harness.authenticate("ssh-key", { hostFingerprint: harness.state.hostFingerprint });
const serverSha = await harness.createCommit();
assert.equal((await harness.deploy(harness.plan(serverSha, "server-git"))).status, "success");
const copySha = await harness.createCommit("fix: direct copy fixture");
assert.equal((await harness.deploy(harness.plan(copySha, "push-bundle"))).status, "success");
assert.equal(harness.state.liveSha, copySha);
});
test("existing deployments are adopted, externally updated and reconciled without replacement", async (t) => {
const harness = await fixture(t);
assert.deepEqual(harness.adoptExisting(), { linked: true, liveSha: harness.initialSha, preserved: true });
const sha = await harness.createCommit();
assert.equal(harness.externalUpdate(sha).liveSha, sha);
assert.equal(harness.state.healthy, true);
});
test("deploy key rotation, revocation, restore and writable-key rejection fail closed", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
assert.equal(harness.rotateDeployKey().rotated, true);
assert.equal(harness.revokeDeployKey().deploymentBlocked, true);
assert.equal(harness.restoreDeployKey().restored, true);
harness.setKeyAccess(false);
assert.throws(() => harness.rotateDeployKey(), /writable/);
await assert.rejects(() => harness.deploy(harness.plan(harness.initialSha)), /Writable deploy key/);
});
test("unhealthy activation rolls back only to the exact recorded previous SHA", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
harness.adoptExisting();
const sha = await harness.createCommit();
assert.equal((await harness.deploy(harness.plan(sha), "unhealthy")).status, "failed");
assert.throws(() => harness.rollback("0".repeat(40)), /exact recorded/);
const rollback = harness.rollback(harness.initialSha);
assert.equal(rollback.status, "rolled-back");
assert.equal(rollback.liveSha, harness.initialSha);
});
test("network failures distinguish fetch from partial activation and preserve recovery", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
const sha = await harness.createCommit();
let outcome = await harness.deploy(harness.plan(sha), "fetch-network");
assert.deepEqual(outcome.failure, { message: "Network interrupted during fetch", partial: false });
outcome = await harness.deploy(harness.plan(sha), "activation-network");
assert.equal(outcome.failure.partial, true);
assert.ok(harness.state.recovery);
});
test("application shutdown is recoverable and stale plans cannot mutate state", async (t) => {
const harness = await fixture(t);
harness.authenticate("password");
const plan = harness.plan(harness.initialSha);
harness.state.liveSha = "1".repeat(40);
await assert.rejects(() => harness.deploy(plan), /Stale reconciliation plan/);
const current = harness.plan(harness.initialSha);
assert.equal((await harness.deploy(current, "shutdown")).status, "interrupted");
assert.equal(harness.recover().status, "failed");
});
test("corrupt configuration is recoverable from the migration backup", async (t) => {
const harness = await fixture(t);
await harness.install();
await harness.migrate();
const backup = `${harness.paths.config}.backup`;
await writeFile(harness.paths.config, "{broken", "utf8");
await assert.rejects(() => readFile(harness.paths.config, "utf8").then(JSON.parse));
await writeFile(harness.paths.config, await readFile(backup));
assert.equal(JSON.parse(await readFile(harness.paths.config, "utf8")).version, "0.10.0");
});
test("release verification rejects checksum failures, missing assets and drafts without requiring paid signing", async (t) => {
const harness = await fixture(t);
await harness.publishRelease("1.0.0-ok");
assert.equal((await harness.verifyRelease("1.0.0-ok")).verified, true);
for (const [version, options, error] of [
["1.0.0-checksum", { badChecksum: true }, /checksum/],
["1.0.0-missing", { missingAsset: true }, /asset is missing/],
["1.0.0-draft", { draft: true }, /draft release/],
]) {
await harness.publishRelease(version, options);
await assert.rejects(() => harness.verifyRelease(version), error);
}
});
test("large and partial inventory fixtures expose duplicates without touching production data", async (t) => {
const harness = await fixture(t);
const inventory = harness.inventory(24, true);
assert.equal(inventory.workloads.length, 24);
assert.equal(inventory.workloads.filter((item) => item.classification === "duplicate").length, 1);
assert.equal(inventory.partial, true);
assert.match(inventory.warnings[0], /unavailable/);
await rm(harness.paths.server, { recursive: true, force: true });
});
+308
View File
@@ -0,0 +1,308 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const rendererFiles = ["app.js", "diff-view.js", "views.js", "dialogs.js", "operations.js", "actions/shell.js", "actions/inventory.js", "actions/deployment-profile.js", "actions/deployment-operation.js", "actions/setup-and-settings.js", "actions/recovery.js", "actions/command.js", "events.js"];
async function rendererSource() {
return (await Promise.all(rendererFiles.map((file) => readFile(new URL(`../src/renderer/${file}`, import.meta.url), "utf8")))).join("\n");
}
async function ipcSource() {
return (await Promise.all(["ipc.cjs", "ipc/repository-handlers.cjs", "ipc/deployment-handlers.cjs", "ipc/operations-handlers.cjs"].map((file) => readFile(new URL(`../src/main/${file}`, import.meta.url), "utf8")))).join("\n");
}
test("desktop shell serializes ForgeFlow to one configuration writer", async () => {
const main = await readFile(new URL("../main.cjs", import.meta.url), "utf8");
assert.match(main, /requestSingleInstanceLock\(\)/);
assert.match(main, /second-instance/);
assert.match(main, /showMainWindow\(\)/);
});
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 rendererSource();
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, /data-action="commit-push"/);
assert.match(renderer, /Commit staged hunks/);
});
test("ITWorx branding is integrated into titlebar and setup", async () => {
const renderer = await rendererSource();
assert.match(renderer, /itworx-mark\.png/);
assert.match(renderer, /itworx-wordmark-(?:light|dark)\.png/);
});
test("all modal content stays inside the viewport with a persistent action footer", async () => {
const css = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(
css,
/\.modal\s*\{[^}]*max-height:\s*calc\(100dvh[^}]*display:\s*flex[^}]*flex-direction:\s*column/,
);
assert.match(
css,
/\.modal-body\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/,
);
assert.match(css, /\.modal-footer\s*\{[^}]*flex:\s*0 0 auto/);
});
test("settings provides one-click normalization for legacy Gitea origins", async () => {
const renderer = await rendererSource();
assert.match(renderer, /data-action="normalize-origins"/);
assert.match(renderer, /Normalize all origins/);
});
test("Git mutations are serialized per repository and expose repair actions", async () => {
const ipc = await ipcSource();
const renderer = await rendererSource();
assert.match(ipc, /repositoryMutations = new Map/);
assert.match(ipc, /withRepositoryMutation/);
assert.match(ipc, /GIT_LOCKS_RECENT/);
assert.match(ipc, /setTimeout\(resolve, 2_500\)/);
assert.match(renderer, /data-action="repair-git-locks"/);
assert.match(renderer, /Repository troubleshooting/);
assert.match(renderer, /data-action="repair-origin"/);
assert.match(renderer, /Open guided repository repair/);
});
test("SSH secrets are captured before the loading render clears password inputs", async () => {
const renderer = await rendererSource();
const passwordCapture = renderer.search(
/const password = document\.querySelector\(["']#server-password["']\)/,
);
const loading = renderer.search(
/setLoading\(true, ["']Saving encrypted SSH configuration/,
);
assert.ok(passwordCapture >= 0 && loading > passwordCapture);
});
test("SSH deployments are polled in the background and Portfolio casing is preserved", async () => {
const renderer = await rendererSource();
assert.match(renderer, /function startOperationPolling\(\)/);
assert.match(renderer, /startOperationPolling\(\);/);
assert.match(renderer, /Visible container name/);
assert.match(renderer, /Compose services to verify/);
});
test("deployment profiles expose built-in/uploaded DockerMan icons and automatic metadata repair", async () => {
const renderer = await rendererSource();
assert.match(renderer, /Built-in high-contrast ITWorx mark/);
assert.match(renderer, /profile-icon-mode/);
assert.match(renderer, /Repair DockerMan integration/);
assert.match(renderer, /reconcile-deployment/);
});
test("repository troubleshooting offers personalized synchronization repair actions", async () => {
const renderer = await rendererSource();
const ipc = await ipcSource();
assert.match(renderer, /repair-repository-sync/);
assert.match(renderer, /safety branch/);
assert.match(ipc, /repository:repair-sync/);
});
test("Gitea workspace sync is preview-driven, recoverable and never deletes ignored runtime data", async () => {
const renderer = await rendererSource();
const preload = await readFile(new URL("../preload.cjs", import.meta.url), "utf8");
const ipc = await ipcSource();
assert.match(renderer, /Gitea workspace sync/);
assert.match(renderer, /preview-workspace-sync/);
assert.match(renderer, /confirm-workspace-sync/);
assert.match(renderer, /Ignored runtime files remain in place/);
assert.match(renderer, /recovery branch/);
assert.match(renderer, /named Git stash/);
assert.match(renderer, /Gitea fetch interval/);
assert.match(preload, /previewWorkspaceSync/);
assert.match(preload, /applyWorkspaceSync/);
assert.match(ipc, /repository:workspace-sync-preview/);
assert.match(ipc, /repository:workspace-sync-apply/);
});
test("demo bridge implements the complete Git recovery flow", async () => {
const source = await readFile(
new URL("../src/renderer/mock-repository-bridge.js", import.meta.url),
"utf8",
);
for (const method of [
"gitRecoveryStatus",
"reconcileRepository",
"repairGitLocks",
"repairRepositorySync",
]) {
assert.match(source, new RegExp(`async ${method}\\(`));
}
});
test("Git tools rows retain their content height inside the scrollable tab", async () => {
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(
styles,
/\.git-tools-grid\s*\{[^}]*grid-auto-rows:\s*max-content/s,
);
});
test("advanced Git, desktop, backup, policy and audit workflows are exposed in the renderer", async () => {
const renderer = await rendererSource();
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
for (const phrase of [
"Stage hunks",
"Conflict guide",
"Create pull request",
"Open pull requests",
"load-pull-requests",
"Check branch protection",
"Encrypted configuration backup",
"Deployment policy",
"Operational audit log",
])
assert.match(renderer, new RegExp(phrase, "i"));
for (const method of [
"stageHunks",
"resolveConflict",
"createPullRequest",
"branchProtection",
"openEditor",
"openTerminal",
"exportConfigurationBackup",
"listAuditEvents",
])
assert.match(preload, new RegExp(`${method}:`));
});
test("one-click troubleshooting excludes destructive or publishing Git actions", async () => {
const renderer = await rendererSource();
const ipc = await readFile(
new URL("../src/main/ipc.cjs", import.meta.url),
"utf8",
);
assert.match(ipc, /action:\s*["']abort-operation["'],\s*safe:\s*false/);
assert.match(ipc, /action:\s*["']push["'],\s*safe:\s*false/);
assert.match(ipc, /const stale = lock\.ageMs >= 10_000/);
assert.match(
ipc,
/\[\s*["']fast-forward["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/,
);
assert.doesNotMatch(
ipc,
/\[\s*["']fast-forward["'],\s*["']push["'],\s*["']fetch["']\s*\]\.includes\(issue\.action\)/,
);
assert.match(
renderer,
/trouble\?\.issues\?\.some\(\(item\) => item\.repairable && item\.safe\)/,
);
});
test("premium repository workspace groups variable context above a stable tab row", async () => {
const renderer = await rendererSource();
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(
styles,
/\.repo-workspace\s*\{[^}]*grid-template-rows:\s*auto auto 39px minmax\(0, 1fr\)/s,
);
assert.match(renderer, /<div class="repo-context">/);
assert.match(styles, /\.tabs\s*\{[^}]*overflow-x:\s*auto/s);
assert.match(styles, /\.tab\s*\{[^}]*white-space:\s*nowrap/s);
assert.match(styles, /prefers-reduced-motion/);
assert.match(styles, /ForgeFlow 0\.8 premium visual system/);
});
test("help center explains core workflows and supports contextual searchable guidance", async () => {
const renderer = await rendererSource();
assert.match(renderer, /function renderHelp\(\)/);
assert.match(renderer, /id: "workspace-sync"/);
assert.match(renderer, /id: "deployment-linking"/);
assert.match(renderer, /id: "deploy-keys"/);
assert.match(renderer, /id: "git-validator"/);
assert.match(renderer, /id="help-search"/);
assert.match(renderer, /data-action="open-context-help" data-topic="workspace-sync"/);
assert.match(renderer, /ui\.currentView === "help"/);
});
test("interactive project illustrations are semantic, responsive and motion-safe", async () => {
const renderer = await rendererSource();
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(renderer, /function projectIllustration/);
assert.match(renderer, /data-project-illustration/);
assert.match(renderer, /document\.addEventListener\("pointermove"/);
assert.match(styles, /\.project-illustration/);
assert.match(styles, /@keyframes signal-travel/);
assert.match(styles, /prefers-reduced-motion/);
assert.match(styles, /transform: none !important/);
});
test("the diff canvas uses a contextual and motion-safe code illustration", async () => {
const renderer = await rendererSource();
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
assert.match(renderer, /function diffAtmosphere/);
assert.match(renderer, /data-diff-atmosphere/);
assert.match(renderer, /--diff-tilt-x/);
assert.match(styles, /\.diff-atmosphere/);
assert.match(styles, /@keyframes code-packet-travel/);
assert.match(styles, /prefers-reduced-motion/);
});
test("Git Validator exposes scored best-practice checks and bounded repairs", async () => {
const renderer = await rendererSource();
const styles = await readFile(
new URL("../src/renderer/styles.css", import.meta.url),
"utf8",
);
const preload = await readFile(
new URL("../preload.cjs", import.meta.url),
"utf8",
);
assert.match(renderer, /function renderGitValidator/);
assert.match(renderer, /gitValidatorPreviewRepair/);
assert.match(renderer, /git-validator-suppress/);
assert.match(renderer, /git-validator-policy/);
assert.match(styles, /\.validator-score/);
assert.match(styles, /@container \(max-width: 900px\)/);
assert.match(preload, /gitValidatorScan/);
assert.match(preload, /gitValidatorRepair/);
assert.match(preload, /gitValidatorExport/);
});
test("renderer guards accessible names, labels and uncertain inventory evidence", async () => {
const renderer = await rendererSource();
const styles = await readFile(new URL("../src/renderer/styles.css", import.meta.url), "utf8");
assert.match(renderer, /button\.icon-button:not\(\[aria-label\]\)/);
assert.match(renderer, /\.field > label:not\(\[for\]\)/);
assert.match(renderer, /topCandidate\.confidence \|\| topCandidate\.status \|\| "review required"/);
assert.match(styles, /\.action-panel-body \.panel-callout > h2/);
assert.match(styles, /@media \(max-height: 760px\)/);
});
+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');
});
+152
View File
@@ -0,0 +1,152 @@
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);
});
test('repository monitor checks multiple repositories concurrently with a bounded worker pool', async () => {
let active = 0;
let peak = 0;
const git = {
status: async (localPath) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return { localPath, revision: 1 };
},
statusFingerprint: (status) => String(status.revision)
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2 } } };
const monitor = new RepositoryMonitor({ store, git });
monitor.setPaths(Array.from({ length: 10 }, (_, index) => `/repo-${index}`));
await monitor.tick();
assert.equal(peak, 4);
assert.equal(active, 0);
assert.equal(monitor.fingerprints.size, 10);
});
test('a watched repository is read on filesystem activity instead of on every interval', async (context) => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const os = await import('node:os');
const path = await import('node:path');
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-watch-'));
context.after(() => rm(root, { recursive: true, force: true }));
await mkdir(path.join(root, '.git'), { recursive: true });
let revision = 1;
const reads = [];
const changes = [];
const git = {
status: async (localPath) => { reads.push(localPath); return { 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) });
context.after(() => monitor.stop());
monitor.restart();
monitor.setPaths([root]);
if (!monitor.watchers.has(root)) {
context.skip('this platform does not support recursive directory watching');
return;
}
await monitor.tick();
assert.equal(reads.length, 1, 'the baseline is established once');
// Without filesystem activity the interval must not spawn another read.
await monitor.tick();
assert.equal(reads.length, 1);
revision = 2;
await writeFile(path.join(root, 'feature.txt'), 'changed\n');
// Exercise the monitor's filesystem-activity boundary deterministically.
// Native fs.watch delivery is platform/overlay specific and is covered by
// the product's safety interval rather than by this unit test.
monitor.noteFilesystemChange(root);
// The watcher debounce and the per-repository cooldown both apply here.
const deadline = Date.now() + 5_000;
while (changes.length === 0 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
assert.ok(reads.length > 1, 'filesystem activity triggers a read');
assert.equal(changes.length, 1);
assert.equal(changes[0].reason, 'working-tree-changed');
const readsAfterChange = reads.length;
await new Promise((resolve) => setTimeout(resolve, 800));
assert.equal(reads.length, readsAfterChange, 'a quiet repository is not read again');
monitor.stop();
assert.equal(monitor.watchers.size, 0, 'stopping releases every watcher');
});
test('background Gitea awareness fetches read-only remote state with bounded concurrency', async () => {
let active = 0;
let peak = 0;
const changes = [];
const git = {
fetch: async (localPath) => {
active += 1;
peak = Math.max(peak, active);
await new Promise((resolve) => setTimeout(resolve, 15));
active -= 1;
return { status: { localPath, revision: 2, branch: { head: 'main', ahead: 0, behind: 1 }, counts: {} } };
},
statusFingerprint: (status) => String(status.revision),
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 1 } } };
const monitor = new RepositoryMonitor({ store, git, onChange: (change) => changes.push(change) });
const paths = Array.from({ length: 6 }, (_, index) => `/repo-${index}`);
monitor.setPaths(paths);
for (const localPath of paths) {
monitor.fingerprints.set(localPath, '1');
monitor.lastFetchedAt.set(localPath, Date.now() - 61_000);
}
await monitor.fetchRemoteUpdates();
assert.equal(peak, 2);
assert.equal(active, 0);
assert.equal(changes.length, paths.length);
assert.ok(changes.every((change) => change.reason === 'remote-state-changed'));
});
test('a zero remote fetch interval disables background network access', async () => {
let fetches = 0;
const git = {
fetch: async () => { fetches += 1; return { status: { revision: 2 } }; },
statusFingerprint: (status) => String(status.revision),
};
const store = { data: { preferences: { autoRefresh: true, repositoryPollSeconds: 2, fetchIntervalMinutes: 0 } } };
const monitor = new RepositoryMonitor({ store, git });
monitor.setPaths(['/repo']);
monitor.lastFetchedAt.set('/repo', 0);
await monitor.fetchRemoteUpdates(Date.now() + 24 * 60 * 60_000);
assert.equal(fetches, 0);
});
+290
View File
@@ -0,0 +1,290 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, symlink } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import repositoryModule from '../src/main/repository-service.cjs';
const { RepositoryService } = repositoryModule;
function status(head = 'a'.repeat(40)) {
return {
head,
shortHead: head.slice(0, 7),
clean: true,
counts: { changed: 0, conflicts: 0 },
branch: { head: 'main', upstream: 'origin/main', ahead: 0, behind: 0 }
};
}
const remote = {
id: 1,
name: 'Portfolio',
full_name: 'Jens/Portfolio',
owner: { login: 'Jens' },
private: true,
default_branch: 'main',
html_url: 'https://gitea.example/Jens/Portfolio',
clone_url: 'https://gitea.example/Jens/Portfolio.git',
ssh_url: 'git@gitea.example:Jens/Portfolio.git'
};
function service() {
return new RepositoryService({ data: { preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] } }, {}, {});
}
test('a synchronized commit is deployable when the server is unknown or older', () => {
const current = status();
const unknown = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: null, healthy: null } }
]);
assert.equal(unknown.readyToDeploy, true);
const older = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: 'b'.repeat(40), healthy: true } }
]);
assert.equal(older.readyToDeploy, true);
});
test('a healthy commit already live on the server is not offered for deployment again', () => {
const current = status();
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: true } }
]);
assert.equal(repository.readyToDeploy, false);
});
test('an unhealthy live commit remains eligible for a controlled redeploy', () => {
const current = status();
const repository = service().decorate(remote, { localPath: 'C:/Projects/Portfolio', status: current }, [
{ id: 'prod', branch: 'main', state: { liveSha: current.head, healthy: false } }
]);
assert.equal(repository.readyToDeploy, true);
});
test('repository discovery is bounded, skips generated trees and ignores inaccessible roots', async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-repositories-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
await mkdir(path.join(root, 'group', 'app', '.git'), { recursive: true });
await mkdir(path.join(root, 'node_modules', 'ignored', '.git'), { recursive: true });
await mkdir(path.join(root, 'too', 'deep', 'repository', '.git'), { recursive: true });
try { await symlink(path.join(root, 'group'), path.join(root, 'linked'), 'junction'); } catch {}
const instance = service();
const found = await instance.discoverInRoot(root, 2);
assert.deepEqual(found, [await import('node:fs/promises').then(({ realpath }) => realpath(path.join(root, 'group', 'app')))]);
assert.deepEqual(await instance.discoverInRoot(path.join(root, 'missing')), []);
const all = await instance.discoverAll([root, root, '', null]);
assert.equal(all.length, 2);
assert.equal(new Set(all).size, 2);
assert.ok(all.includes(found[0]));
});
test('a repository reached through a directory junction is discovered once', async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-junction-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
const elsewhere = path.join(root, 'elsewhere', 'service');
await mkdir(path.join(elsewhere, '.git'), { recursive: true });
await mkdir(path.join(root, 'workspace'), { recursive: true });
try {
await symlink(elsewhere, path.join(root, 'workspace', 'linked-service'), 'junction');
} catch {
context.skip('this platform does not allow creating directory links');
return;
}
const { realpath } = await import('node:fs/promises');
const found = await service().discoverInRoot(path.join(root, 'workspace'), 3);
assert.deepEqual(found, [await realpath(elsewhere)]);
});
test('local descriptors preserve Git failures and watch paths are defensive copies', async () => {
const instance = new RepositoryService({ data: {} }, {
status: async (localPath) => {
if (localPath.endsWith('bad')) throw new Error('not a repository');
return { ...status(), root: `${localPath}/canonical`, remoteUrl: remote.clone_url };
}
}, {});
const descriptors = await instance.getLocalDescriptors(['good', 'bad']);
assert.equal(descriptors[0].localPath, 'good/canonical');
assert.equal(descriptors[1].error, 'not a repository');
instance.lastKnownLocalPaths = ['one'];
const watched = instance.getWatchPaths();
watched.push('two');
assert.deepEqual(instance.getWatchPaths(), ['one']);
});
test('refresh links explicit and remote-matched repositories and retains unmatched locals', async () => {
const diagnostics = [];
const store = {
data: {
gitea: { baseUrl: 'https://gitea.example' },
workspaceRoots: ['root'],
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
preferences: { preferredCloneProtocol: 'https' },
favorites: ['jens/portfolio']
},
getToken: () => 'token',
getDeploymentProfiles: (name) => name === remote.full_name ? [{ id: 'prod', branch: 'main' }] : [],
getDeploymentState: () => ({ liveSha: null, healthy: null })
};
const secondRemote = { ...remote, id: 2, name: 'Other', full_name: 'Jens/Other', clone_url: 'https://gitea.example/Jens/Other.git' };
const instance = new RepositoryService(store, {
status: async (localPath) => ({
...status(localPath.includes('unmatched') ? 'b'.repeat(40) : 'a'.repeat(40)),
root: localPath,
remoteUrl: localPath.includes('matched') ? secondRemote.clone_url : remote.clone_url
})
}, { listRepositories: async () => [remote, secondRemote] }, { debug: async (...args) => diagnostics.push(args) });
instance.discoverAll = async () => ['C:/matched', 'C:/unmatched'];
const repositories = await instance.refresh();
const explicit = repositories.find((item) => item.fullName === remote.full_name);
const matched = repositories.find((item) => item.fullName === secondRemote.full_name);
const unmatched = repositories.find((item) => item.linkState === 'unmatched-local');
assert.equal(explicit.localPath, 'C:/explicit');
assert.equal(explicit.favorite, true);
assert.equal(explicit.preferredCloneUrl, remote.clone_url);
assert.equal(matched.localPath, 'C:/matched');
assert.equal(unmatched.localPath, 'C:/unmatched');
assert.deepEqual(instance.getWatchPaths().sort(), ['C:/explicit', 'C:/matched', 'C:/unmatched'].sort());
assert.equal(diagnostics[0][0], 'repositories.refresh.completed');
});
test('resolving one repository reads only that repository, not the whole workspace', async () => {
const scanned = [];
const store = {
data: {
gitea: { baseUrl: 'https://gitea.example' },
workspaceRoots: ['root'],
repositoryMappings: { 'jens/portfolio': 'C:/explicit' },
preferences: { preferredCloneProtocol: 'https' },
favorites: []
},
getToken: () => 'token',
getDeploymentProfiles: () => [{ id: 'prod', branch: 'main' }],
getDeploymentState: () => ({ liveSha: null, healthy: null })
};
const instance = new RepositoryService(store, {
status: async (localPath) => {
scanned.push(localPath);
return { ...status(), root: localPath, remoteUrl: remote.clone_url };
}
}, { listRepositories: async () => [remote, { ...remote, id: 2, full_name: 'Jens/Other', name: 'Other' }] });
instance.discoverAll = async () => ['C:/explicit', 'C:/other', 'C:/third'];
await instance.refresh();
const duringRefresh = scanned.length;
assert.equal(duringRefresh, 3);
scanned.length = 0;
const resolved = await instance.resolveByFullName(remote.full_name);
assert.equal(resolved.fullName, remote.full_name);
assert.equal(resolved.localPath, 'C:/explicit');
assert.equal(resolved.deploymentProfiles[0].id, 'prod');
assert.deepEqual(scanned, ['C:/explicit']);
assert.equal(await instance.resolveByFullName(''), null);
});
test('resolving an unknown repository still falls back to a full refresh', async () => {
const store = {
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'https' }, favorites: [] },
getToken: () => 'token',
getDeploymentProfiles: () => [],
getDeploymentState: () => null
};
const instance = new RepositoryService(store, {
status: async (localPath) => ({ ...status(), root: localPath, remoteUrl: '' })
}, { listRepositories: async () => [remote] });
instance.discoverAll = async () => ['C:/loose-checkout'];
const local = await instance.resolveByFullName('loose-checkout');
assert.equal(local.linkState, 'unmatched-local');
assert.equal(await instance.resolveByFullName('Jens/Missing'), null);
});
test('refresh remains local-only without configured Gitea credentials', async () => {
const store = {
data: { gitea: { baseUrl: '' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
getToken: () => '', getDeploymentProfiles: () => [], getDeploymentState: () => null
};
const instance = new RepositoryService(store, {}, { listRepositories: async () => { throw new Error('must not call'); } });
instance.discoverAll = async () => [];
assert.deepEqual(await instance.refresh(), []);
});
test('refresh uses last-known Gitea repositories after a transient remote failure', async () => {
const warnings = [];
let remoteAvailable = true;
const store = {
data: {
gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {},
preferences: { preferredCloneProtocol: 'ssh' }, favorites: []
},
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
};
const instance = new RepositoryService(store, {}, {
listRepositories: async () => {
if (!remoteAvailable) throw new Error('Gitea timed out');
return [remote];
}
}, { debug: async () => {}, warning: async (...args) => warnings.push(args) });
instance.discoverAll = async () => [];
const fresh = await instance.refresh();
remoteAvailable = false;
const degraded = await instance.refresh({ force: true });
assert.equal(fresh[0].remoteStale, false);
assert.equal(degraded[0].fullName, remote.full_name);
assert.equal(degraded[0].remoteStale, true);
assert.equal(degraded[0].remoteRefreshError, 'Gitea timed out');
assert.ok(degraded[0].remoteLastRefreshedAt);
assert.equal(warnings[0][0], 'repositories.remote-refresh.degraded');
});
test('initial Gitea failure remains visible when no safe cache exists', async () => {
const store = {
data: { gitea: { baseUrl: 'https://gitea.example' } },
getToken: () => 'token'
};
const instance = new RepositoryService(store, {}, {
listRepositories: async () => { throw new Error('Gitea unavailable'); }
});
await assert.rejects(() => instance.refresh(), /Gitea unavailable/);
});
test('refresh coalesces concurrent work and briefly reuses remote and discovery results', async () => {
let remoteCalls = 0;
let discoveryCalls = 0;
const store = {
data: { gitea: { baseUrl: 'https://gitea.example' }, workspaceRoots: [], repositoryMappings: {}, preferences: { preferredCloneProtocol: 'ssh' }, favorites: [] },
getToken: () => 'token', getDeploymentProfiles: () => [], getDeploymentState: () => null
};
const instance = new RepositoryService(store, {}, { listRepositories: async () => { remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 10)); return [remote]; } });
instance.discoverAll = async () => { discoveryCalls += 1; return []; };
const [first, second] = await Promise.all([instance.refresh(), instance.refresh()]);
assert.deepEqual(first, second);
await instance.refresh();
assert.equal(remoteCalls, 1);
assert.equal(discoveryCalls, 1);
await instance.refresh({ force: true });
assert.equal(remoteCalls, 2);
assert.equal(discoveryCalls, 2);
});
test('decoration reports conflicts, behind branches, errors and remote-only repositories', () => {
const instance = service();
const conflicted = instance.decorate(remote, { localPath: 'repo', status: { ...status(), counts: { changed: 1, conflicts: 2 }, branch: { ...status().branch, behind: 3 } } }, []);
assert.equal(conflicted.attentionReason, 'Merge conflict');
assert.equal(conflicted.readyToDeploy, false);
const behind = instance.decorate(remote, { localPath: 'repo', status: { ...status(), branch: { ...status().branch, behind: 1 } } }, []);
assert.equal(behind.attentionReason, '1 commit behind remote');
const broken = instance.decorate(remote, { localPath: 'repo', status: null, error: 'broken checkout' }, []);
assert.equal(broken.attentionReason, 'broken checkout');
const remoteOnly = instance.decorate({ ...remote, ssh_url: '', clone_url: '' }, null, []);
assert.equal(remoteOnly.linkState, 'remote-only');
assert.equal(remoteOnly.preferredCloneUrl, '');
});
+68
View File
@@ -0,0 +1,68 @@
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(() => normalizeBaseUrl('http://gitea.example.test'), /must use HTTPS/i);
assert.equal(normalizeBaseUrl('http://127.0.0.1:3000/'), 'http://127.0.0.1:3000');
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);
});
+150
View File
@@ -0,0 +1,150 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const {
parseServerInventory, buildWorkloadInventory, inventoryContainerMatch, remoteIdentity,
stableWorkloadId, profileMatchesWorkload, sanitizeLegacyContainer, safeRelativeToBase,
canonicalServerAppdataPath, deploymentRootCandidate,
} = require('../src/main/server-inventory.cjs');
const b64 = (value) => Buffer.from(String(value)).toString('base64');
test('inventory parser handles every evidence record and ignores malformed payloads', () => {
const legacy = { Id: 'legacy', Name: '/App', Config: { Image: 'app:1', Labels: { 'com.docker.compose.project': 'app' } }, State: { Running: true, Status: 'running', Health: { Status: 'healthy' } }, Mounts: null };
const safe = { id: 'safe', name: '/Safe', running: false, labels: null, mounts: null, ports: null, networks: null };
const projects = [{ Name: 'app', Status: 'running(1)', ConfigFiles: '/mnt/user/appdata/App/compose.yml,/mnt/user/appdata/App/extra.yml' }, { name: '', configFiles: [] }];
const output = [
'noise', '__FORGEFLOW_INVENTORY__',
`H\ttrue\tfalse\ttrue\ttrue\tfalse\ttrue\t${b64('Compose v2')}\t${b64('Linux')}`,
`R\t${b64('/mnt/user/appdata/App')}\t${b64('git@gitea.test:Owner/App.git')}\t${'a'.repeat(40)}\t${b64('main')}`,
`C\t${b64(JSON.stringify([legacy, null]))}`,
`C\t${b64(JSON.stringify(safe))}`,
`C\t${b64('{bad json')}`,
`D\t${b64('App')}\t${b64('/templates/App.xml')}\t${b64('http://app')}\t${b64('/icon.png')}\t${b64('/bin/bash')}\t${b64('app:1')}\t${b64('bridge')}`,
`P\t${b64(JSON.stringify(projects))}`,
`P\t${b64(JSON.stringify({ name: 'single', status: 'exited', config_files: ['single.yml'] }))}`,
`Y\t${b64('/mnt/user/appdata/App/')}\t${b64('compose.yml\ncompose.prod.yml\n')}\t${b64('app')}\t${b64('web\nworker')}\t${b64('app:1')}\ttrue\t${b64('')}`,
`W\t${b64('partial docker inspect failure')}`,
'UNKNOWN\tignored',
].join('\n');
const parsed = parseServerInventory(output);
assert.deepEqual(parsed.capabilities, { docker: true, compose: false, git: true, tar: true, checksum: false, baseWritable: true, composeVersion: 'Compose v2', platform: 'Linux' });
assert.equal(parsed.checkouts.length, 1);
assert.equal(parsed.containers.length, 2);
assert.equal(parsed.containers[0].health, 'healthy');
assert.deepEqual(parsed.containers[1].labels, {});
assert.equal(parsed.dockerMan[0].templatePath, '/templates/App.xml');
assert.equal(parsed.composeProjects.length, 2);
assert.deepEqual(parsed.composeProjects[0].configFiles, ['/mnt/user/appdata/App/compose.yml', '/mnt/user/appdata/App/extra.yml']);
assert.deepEqual(parsed.composeDefinitions[0].services, ['web', 'worker']);
assert.deepEqual(parsed.warnings, ['partial docker inspect failure']);
assert.throws(() => parseServerInventory('ordinary output'), /did not return/i);
});
test('server path normalization keeps deployments inside canonical appdata', () => {
assert.equal(safeRelativeToBase('/mnt/user/appdata/', '/mnt/user/appdata/App/'), 'App');
for (const value of ['', '/mnt/user/appdata', '/mnt/user/appdata/../etc', '/other/App']) assert.equal(safeRelativeToBase('/mnt/user/appdata', value), '');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/cache/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/mnt/user/appdata', '/mnt/disk2/appdata/App/data'), '/mnt/user/appdata/App/data');
assert.equal(canonicalServerAppdataPath('', '/mnt/user/appdata/App'), '/mnt/user/appdata/App');
assert.equal(canonicalServerAppdataPath('/custom', '/outside/path'), '/outside/path');
assert.equal(canonicalServerAppdataPath('/custom', ''), '');
assert.equal(deploymentRootCandidate('App/source-pre-abcdef1/source'), 'App');
assert.equal(deploymentRootCandidate('App/.forgeflow/incoming'), 'App');
});
test('profile matching requires the same server and accepts each stable identity form', () => {
const workload = { serverId: 'server', workloadId: 'workload', selector: { kind: 'compose', composeProject: 'app' }, compose: { project: 'app', workingDir: '/apps/App' }, remoteFolderCandidate: 'App', containers: [{ name: 'app-web' }] };
assert.equal(profileMatchesWorkload(null, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'gitea-actions', serverId: 'server' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { workloadId: 'workload' } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', workloadIdentity: { selector: workload.selector } }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', composeWorkingDir: '/other' }, workload), false);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', remoteFolder: 'App' }, workload), true);
assert.equal(profileMatchesWorkload({ provider: 'ssh-unraid', serverId: 'server', containerName: 'app-web' }, workload), true);
assert.equal(stableWorkloadId('server', workload.selector), stableWorkloadId('server', workload.selector));
});
test('container matching prioritizes working directory, mounts, provenance and stable names', () => {
const checkout = { root: '/apps/App', remote: 'git@gitea.test:Owner/App.git' };
const repository = { name: 'App' };
const base = { running: true, labels: {}, mounts: [], name: 'different' };
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project.working_dir': '/apps/App/' } }), 100);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, mounts: [{ Source: '/apps/App/data' }] }), 90);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'org.opencontainers.image.source': 'https://gitea.test/Owner/App' } }), 85);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, labels: { 'com.docker.compose.project': 'app' } }), 70);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, name: '/APP' }), 60);
assert.equal(inventoryContainerMatch(checkout, repository, { ...base, running: false }), 0);
assert.equal(inventoryContainerMatch(checkout, repository, base), 0);
assert.equal(remoteIdentity(''), '');
});
test('workload builder merges runtime, Compose file and DockerMan evidence without backups', () => {
const labels = {
'com.docker.compose.project': 'app',
'com.docker.compose.project.working_dir': '/mnt/cache/appdata/App',
'com.docker.compose.project.config_files': '/mnt/cache/appdata/App/compose.yml',
'com.docker.compose.service': 'web',
'tech.itworx.forgeflow.repository': 'git@gitea.test:Owner/App.git',
'tech.itworx.forgeflow.commit': 'b'.repeat(40),
'tech.itworx.forgeflow.branch': 'main',
};
const inventory = {
containers: [
{ id: 'web', name: 'app-web', image: 'registry/app:1', imageId: 'image', running: true, status: 'running', health: 'unhealthy', labels, ports: { '8080/tcp': null, '3000/udp': [{ HostIp: '0.0.0.0', HostPort: '3000' }] }, mounts: [{ Type: 'bind', Source: '/mnt/disk1/appdata/App/data', Destination: '/data', RW: false }], networks: { frontend: {} }, restartPolicy: 'always' },
{ id: 'worker', name: 'app-worker', image: 'registry/worker:1', imageId: 'worker', running: false, status: 'exited', health: null, labels: { ...labels, 'com.docker.compose.service': 'worker' }, ports: {}, mounts: [], networks: {}, restartPolicy: '' },
],
checkouts: [{ root: '/mnt/user/appdata/App', remote: 'git@gitea.test:Owner/App.git', liveSha: 'c'.repeat(40), branch: 'release' }],
composeProjects: [{ name: 'app', status: 'running', configFiles: [] }, { name: 'headless', status: 'exited', configFiles: ['/mnt/user/appdata/Headless/compose.yml'] }],
composeDefinitions: [
{ workingDir: '/mnt/user/appdata/App', configFiles: ['/mnt/user/appdata/App/compose.yml'], projectName: 'app', services: ['web', 'worker'], images: ['registry/app:1'], valid: true, error: '' },
{ workingDir: '/mnt/user/appdata/Backup/.forgeflow/releases/one', configFiles: ['compose.yml'], projectName: 'backup', services: [], images: [], valid: true },
{ workingDir: '/mnt/user/appdata/Standalone', configFiles: ['/mnt/user/appdata/Standalone/compose.yml'], projectName: '', services: ['api'], images: ['standalone:1'], valid: false, error: 'invalid compose' },
],
dockerMan: [
{ name: 'app-web', templatePath: '/templates/app.xml', webUiUrl: 'http://app', iconUrl: '/app.png', shell: '/bin/bash', repository: 'registry/app:1', network: 'frontend' },
{ name: 'template-only', templatePath: '/templates/template.xml', webUiUrl: '', iconUrl: '', shell: '', repository: 'template:1', network: 'bridge' },
], warnings: [], capabilities: {},
};
const repository = { fullName: 'Owner/App', name: 'App', cloneUrl: 'https://gitea.test/Owner/App.git' };
const workloads = buildWorkloadInventory({ inventory, server: { id: 'server', name: 'Unraid', basePath: '/mnt/user/appdata' }, repositories: [repository], profiles: [{ id: 'profile', provider: 'ssh-unraid', serverId: 'server', composeProject: 'app', repositoryFullName: 'Owner/App', adoptedFromServer: true }] });
assert.equal(workloads.some((workload) => workload.displayName === 'backup'), false);
const app = workloads.find((workload) => workload.displayName === 'app');
assert.equal(app.status, 'linked');
assert.equal(app.runtime.running, true);
assert.equal(app.runtime.allRunning, false);
assert.equal(app.runtime.health, 'unhealthy');
assert.equal(app.runtime.ports.length, 2);
assert.equal(app.containers[0].mounts[0].readOnly, true);
assert.equal(app.remoteFolderCandidate, 'App');
assert.equal(app.candidates[0].exact, true);
assert.equal(app.link.source, 'automatic');
const standalone = workloads.find((workload) => workload.displayName === 'Standalone');
assert.equal(standalone.metadata.composeDefinitionValid, false);
assert.equal(standalone.metadata.composeDefinitionError, 'invalid compose');
const template = workloads.find((workload) => workload.displayName === 'template-only');
assert.equal(template.kind, 'dockerman-container');
assert.equal(template.runtime.running, false);
assert.equal(template.metadata.shell, '/bin/sh');
});
test('legacy container sanitizer applies safe defaults to partial Docker inspect data', () => {
assert.deepEqual(sanitizeLegacyContainer(null), {
id: '', name: '', image: '', imageId: '', running: false, status: '', health: null,
labels: {
'com.docker.compose.project': '', 'com.docker.compose.project.working_dir': '', 'com.docker.compose.project.config_files': '', 'com.docker.compose.service': '',
'org.opencontainers.image.source': '', 'org.opencontainers.image.revision': '', 'tech.itworx.forgeflow.repository': '', 'tech.itworx.forgeflow.commit': '',
'tech.itworx.forgeflow.branch': '', 'net.unraid.docker.webui': '', 'net.unraid.docker.icon': '', 'net.unraid.docker.shell': '', 'net.unraid.docker.managed': '',
}, ports: {}, mounts: [], networks: {}, restartPolicy: '',
});
assert.equal(sanitizeLegacyContainer({
Id: 'no-healthcheck',
Name: '/NoHealthcheck',
State: { Running: true, Status: 'running' },
Config: { Image: 'example/no-healthcheck:latest', Labels: null },
}).health, null);
});
+96
View File
@@ -0,0 +1,96 @@
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, bashSyntaxCheckFromTextInvocation, normalizeRelativePosixPath, validateShellScriptStructure, shouldRunExternalBash } = shellVerification;
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);
assert.equal(invocation.options.cwd, tempBase);
assert.deepEqual(invocation.args, ['-n']);
assert.equal(invocation.options.input.includes('\r'), false);
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;
}
}
});
test('Bash syntax validation from text does not depend on a Windows working directory', () => {
const invocation = bashSyntaxCheckFromTextInvocation('#!/usr/bin/env bash\nset -euo pipefail\necho ok\n');
assert.equal(invocation.command, 'bash');
assert.deepEqual(invocation.args, ['-n']);
assert.equal(invocation.options.cwd, undefined);
assert.match(invocation.options.input, /set -euo pipefail/);
});
test('Bash syntax validation from text detects malformed scripts', (t) => {
if (spawnSync('bash', ['--version'], { encoding: 'utf8' }).status !== 0) {
t.skip('Bash is not available in this environment.');
return;
}
const invocation = bashSyntaxCheckFromTextInvocation('if true; then\n echo missing fi\n');
const result = spawnSync(invocation.command, invocation.args, invocation.options);
assert.notEqual(result.status, 0);
});
test('portable server-script validation does not require a local Bash executable', () => {
const script = `#!/usr/bin/env bash
set -Eeuo pipefail
readonly CONFIG_FILE="/etc/forgeflow/targets.conf"
echo "Target configuration must be owned by root"
APP_DIR=/tmp/app
SHA=0123456789012345678901234567890123456789
COMPOSE_FILE=docker-compose.yml
write_status() { :; }
exec 9>/tmp/test.lock
flock -n 9
git -C "$APP_DIR" fetch origin main
git -C "$APP_DIR" reset --hard "$SHA"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
write_status "healthy"
write_status "unhealthy"
`;
assert.equal(validateShellScriptStructure(script), true);
});
test('portable server-script validation refuses missing deployment safety markers', () => {
assert.throws(() => validateShellScriptStructure('#!/usr/bin/env bash\nset -Eeuo pipefail\necho unsafe\n'), /missing required safety marker/);
});
test('Windows publication never depends on an external Bash shim', () => {
assert.equal(shouldRunExternalBash('win32'), false);
assert.equal(shouldRunExternalBash('linux'), true);
assert.equal(shouldRunExternalBash('darwin'), true);
});
+255
View File
@@ -0,0 +1,255 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const ssh2Path = require.resolve("ssh2");
const realSsh2 = require("ssh2");
// SshService resolves ssh2 lazily and after an await, so the replacement has to
// stay in place until the whole operation settles.
async function withFakeSsh2(Client, operation) {
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } };
try {
return await operation();
} finally {
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 };
}
}
const { SshService } = require("../src/main/ssh-service.cjs");
function makeStore(overrides = {}) {
const server = {
id: "unraid",
host: "tower",
port: 22,
username: "root",
authType: "password",
basePath: "/mnt/user/appdata",
hostFingerprint: "SHA256:trusted",
...overrides,
};
return {
server,
getServer: () => server,
getServerCredentials: () => ({ password: "secret", passphrase: "" }),
};
}
// A client that reports what the pool does to it: how often it connected, how
// many channels it opened, and whether it was closed.
function fakeClientFactory({ execBehaviour = () => ({ ok: true }) } = {}) {
const state = { connects: 0, execs: 0, ends: 0, instances: [] };
class FakeClient extends EventEmitter {
constructor() {
super();
this.ended = false;
state.instances.push(this);
}
connect(options) {
state.connects += 1;
options.hostVerifier(Buffer.from("host key"));
setImmediate(() => this.emit("ready"));
}
exec(command, callback) {
state.execs += 1;
const outcome = execBehaviour(state.execs, this);
if (outcome.channelError) {
setImmediate(() => callback(outcome.channelError));
return;
}
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
// A channel that closes without an exit status reports null, which is how
// a connection lost mid-command surfaces. That is not the same as 0.
const closeCode = Object.hasOwn(outcome, "exitCode") ? outcome.exitCode : 0;
setImmediate(() => {
stream.emit("data", Buffer.from(outcome.stdout ?? "ok"));
stream.emit("close", closeCode, null);
});
callback(null, stream);
}
end() {
if (this.ended) return;
this.ended = true;
state.ends += 1;
setImmediate(() => this.emit("close"));
}
}
return { FakeClient, state };
}
function service(store, options = {}) {
return new SshService({ store, diagnostics: null, ...options });
}
const run = withFakeSsh2;
test("a sequence of commands to one server shares a single connection", async () => {
const { FakeClient, state } = fakeClientFactory();
const store = makeStore();
const ssh = service(store);
for (let index = 0; index < 5; index += 1) {
await run(FakeClient, () => ssh.exec("unraid", `echo ${index}`));
}
assert.equal(state.execs, 5);
assert.equal(state.connects, 1, "five commands, one handshake");
ssh.closeAll();
});
test("concurrent commands share the connection and it survives until the last one finishes", async () => {
const { FakeClient, state } = fakeClientFactory();
const ssh = service(makeStore());
await run(FakeClient, () => Promise.all([
ssh.exec("unraid", "one"),
ssh.exec("unraid", "two"),
ssh.exec("unraid", "three"),
]));
assert.equal(state.connects, 1);
assert.equal(state.execs, 3);
assert.equal(state.ends, 0, "the shared connection is not closed while it is idle in the pool");
ssh.closeAll();
assert.equal(state.ends, 1);
});
test("a connection that died while pooled is replaced and the command runs once", async () => {
const { FakeClient, state } = fakeClientFactory({
execBehaviour: (call, client) => (call === 2 && !client.reopened
? { channelError: Object.assign(new Error("channel open failure"), { code: "ERR_CHANNEL" }) }
: { ok: true }),
});
const ssh = service(makeStore());
await run(FakeClient, () => ssh.exec("unraid", "first"));
const result = await run(FakeClient, () => ssh.exec("unraid", "second"));
assert.equal(result.stdout, "ok");
assert.equal(state.connects, 2, "the stale connection is replaced");
assert.equal(state.execs, 3, "the failed attempt never reached the server, so it is retried once");
ssh.closeAll();
});
test("a command that reached the server is never retried, not even on a reused connection", async () => {
let deployAttempts = 0;
const { FakeClient, state } = fakeClientFactory({
execBehaviour: (call) => {
if (call === 1) return { ok: true };
deployAttempts += 1;
return { exitCode: 1, stdout: "docker compose failed" };
},
});
const ssh = service(makeStore());
// The first command establishes the pooled connection, so the deployment below
// runs on a reused one - the case where a retry would be tempting.
await run(FakeClient, () => ssh.exec("unraid", "true"));
await assert.rejects(() => run(FakeClient, () => ssh.exec("unraid", "docker compose up -d")), (error) => {
assert.equal(error.code, "SSH_COMMAND_FAILED");
return true;
});
assert.equal(deployAttempts, 1, "a deployment command is never repeated by the pool");
assert.equal(state.connects, 1);
ssh.closeAll();
});
test("a connection lost while a command was running is not retried either", async () => {
let attempts = 0;
const { FakeClient, state } = fakeClientFactory({
execBehaviour: (call) => {
if (call === 1) return { ok: true };
attempts += 1;
// The stream opened, so the server may already be acting on this command.
return { exitCode: null, stdout: "" };
},
});
const ssh = service(makeStore());
await run(FakeClient, () => ssh.exec("unraid", "true"));
await assert.rejects(() => run(FakeClient, () => ssh.exec("unraid", "docker compose up -d")), (error) => {
assert.equal(error.code, "SSH_COMMAND_FAILED");
return true;
});
assert.equal(attempts, 1);
assert.equal(state.connects, 1);
ssh.closeAll();
});
test("a first connection that cannot be established is reported without a retry", async () => {
class RefusingClient extends EventEmitter {
connect() {
setImmediate(() => this.emit("error", Object.assign(new Error("ECONNREFUSED"), { code: "ECONNREFUSED" })));
}
end() {}
}
const ssh = service(makeStore());
await assert.rejects(() => run(RefusingClient, () => ssh.exec("unraid", "true")), /SSH connection failed/);
assert.equal(ssh.sessions.size, 0, "a failed connection is not pooled");
});
test("a trust-on-first-use connection is never pooled or reused", async () => {
const { FakeClient, state } = fakeClientFactory();
const ssh = service(makeStore({ hostFingerprint: "" }));
await run(FakeClient, () => ssh.test("unraid", { trustOnFirstUse: true }));
await run(FakeClient, () => ssh.test("unraid", { trustOnFirstUse: true }));
assert.equal(state.connects, 2, "an unverified connection is opened fresh every time");
assert.equal(ssh.sessions.size, 0);
assert.equal(state.ends, 2, "and closed immediately afterwards");
});
test("changing the server identity or credentials invalidates the pooled connection", async () => {
const { FakeClient, state } = fakeClientFactory();
const store = makeStore();
const ssh = service(store);
await run(FakeClient, () => ssh.exec("unraid", "before"));
assert.equal(state.connects, 1);
store.server.hostFingerprint = "SHA256:rotated";
await run(FakeClient, () => ssh.exec("unraid", "after"));
assert.equal(state.connects, 2, "the previous connection is not reused across an identity change");
ssh.closeAll();
});
test("an idle connection is closed after its lifetime and reopened on demand", async () => {
const { FakeClient, state } = fakeClientFactory();
const ssh = service(makeStore(), { idleConnectionMs: 40 });
await run(FakeClient, () => ssh.exec("unraid", "one"));
assert.equal(state.ends, 0);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.equal(state.ends, 1, "the idle connection is released");
assert.equal(ssh.sessions.size, 0);
await run(FakeClient, () => ssh.exec("unraid", "two"));
assert.equal(state.connects, 2);
ssh.closeAll();
});
test("an error on an idle pooled connection is absorbed instead of terminating the process", async () => {
const { FakeClient, state } = fakeClientFactory();
const ssh = service(makeStore());
await run(FakeClient, () => ssh.exec("unraid", "one"));
const pooled = state.instances.at(-1);
pooled.emit("error", new Error("read ECONNRESET"));
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(ssh.sessions.size, 0, "the dead connection leaves the pool");
await run(FakeClient, () => ssh.exec("unraid", "two"));
assert.equal(state.connects, 2);
ssh.closeAll();
});
+98
View File
@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
// SshService resolves ssh2 lazily, so replacing the cached module is enough to
// drive a real connection lifecycle without a server.
const ssh2Path = require.resolve("ssh2");
const realSsh2 = require("ssh2");
function withFakeSsh2(Client, run) {
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: { ...realSsh2, Client } };
try {
return run();
} finally {
require.cache[ssh2Path] = { id: ssh2Path, filename: ssh2Path, loaded: true, exports: realSsh2 };
}
}
const { SshService } = require("../src/main/ssh-service.cjs");
function store(server = {}) {
return {
getServer: () => ({ id: "unraid", host: "tower", port: 22, username: "root", authType: "password", basePath: "/mnt/user/appdata", hostFingerprint: "SHA256:trusted", ...server }),
getServerCredentials: () => ({ password: "secret", passphrase: "" }),
};
}
test("a connection that fails twice rejects once and never terminates the process", async () => {
class DoubleFailingClient extends EventEmitter {
connect() {
setImmediate(() => this.emit("error", Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" })));
}
end() {
// The socket resets shortly after teardown. An unhandled 'error' event on
// an EventEmitter takes the whole main process down.
setImmediate(() => this.emit("error", new Error("read ECONNRESET")));
}
}
const service = withFakeSsh2(DoubleFailingClient, () => new SshService({ store: store(), diagnostics: null }));
await assert.rejects(
() => withFakeSsh2(DoubleFailingClient, () => service.exec("unraid", "true")),
(error) => {
assert.equal(error.code, "ECONNREFUSED");
assert.match(error.message, /SSH connection failed/);
return true;
},
);
// Give the delayed teardown error time to land while the test is still running.
await new Promise((resolve) => setTimeout(resolve, 50));
});
test("a host key that does not match the trusted fingerprint is reported as an identity change", async () => {
class MismatchingClient extends EventEmitter {
connect(options) {
options.hostVerifier(Buffer.from("a different host key"));
setImmediate(() => this.emit("error", new Error("handshake failed")));
}
end() {}
}
const service = withFakeSsh2(MismatchingClient, () => new SshService({ store: store(), diagnostics: null }));
await assert.rejects(
() => withFakeSsh2(MismatchingClient, () => service.exec("unraid", "true")),
(error) => {
assert.equal(error.code, "SSH_HOST_KEY_MISMATCH");
assert.match(error.message, /SSH host identity changed/);
assert.equal(error.expectedFingerprint, "SHA256:trusted");
assert.ok(error.observedFingerprint.startsWith("SHA256:"));
return true;
},
);
});
test("running a command requires a trusted host fingerprint", async () => {
const service = new SshService({ store: store({ hostFingerprint: "" }), diagnostics: null });
await assert.rejects(() => service.exec("unraid", "true"), (error) => {
assert.equal(error.code, "SSH_HOST_NOT_TRUSTED");
return true;
});
await assert.rejects(() => service.uploadBuffer("unraid", "/mnt/user/appdata/x", "data"), (error) => {
assert.equal(error.code, "SSH_HOST_NOT_TRUSTED");
return true;
});
});
test("a remote upload path may not escape into an arbitrary location", () => {
const service = new SshService({ store: store(), diagnostics: null });
assert.equal(service.ensureUploadTarget("/mnt/user/appdata/app/file.tar"), "/mnt/user/appdata/app/file.tar");
assert.equal(service.ensureUploadTarget("\\mnt\\user\\appdata\\app"), "/mnt/user/appdata/app");
for (const value of ["relative/path", "/mnt/../etc/passwd", "/mnt/user/../../etc", "", null]) {
assert.throws(() => service.ensureUploadTarget(value), /absolute safe Unix path/);
}
});
+157
View File
@@ -0,0 +1,157 @@
import test from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import { createRequire } from "node:module";
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { SshService, parseCapabilityOutput, shellQuote, fingerprintKey } = require("../src/main/ssh-service.cjs");
test("SSH capability parsing keeps Git optional and reports deployment prerequisites separately", () => {
const b64 = (value) => Buffer.from(value).toString("base64");
const parsed = parseCapabilityOutput(`noise\n__FORGEFLOW_SERVER_TEST__\nplatform=${b64("Linux Unraid")}\ndocker=true\ndockerReady=true\ncompose=true\ncomposeVersion=${b64("Docker Compose version v2.40.0")}\ngit=false\ntar=true\nchecksum=true\nbaseWritable=true\n`);
assert.equal(parsed.dockerReady, true);
assert.equal(parsed.compose, true);
assert.equal(parsed.git, false);
assert.equal(parsed.tar, true);
assert.equal(parsed.checksum, true);
assert.equal(parsed.baseWritable, true);
});
test("SSH execution rejects truncated output instead of using an incomplete inventory", async () => {
const service = new SshService({ store: {}, diagnostics: null });
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
const client = {
exec(_command, callback) {
callback(null, stream);
queueMicrotask(() => {
stream.emit("data", Buffer.from("x".repeat(64)));
stream.emit("close", 0, null);
});
},
};
await assert.rejects(
service.execClient(client, "inventory", { maxOutput: 16, timeout: 1_000 }),
(error) => error?.code === "SSH_OUTPUT_TRUNCATED" && /incomplete result/.test(error.message),
);
});
test("SSH helpers quote shell values, fingerprint keys and parse absent capability markers", () => {
assert.equal(shellQuote("it's safe"), "'it'\\''s safe'");
assert.equal(fingerprintKey(Buffer.from('key')), fingerprintKey('key'));
assert.match(fingerprintKey('key'), /^SHA256:/);
assert.deepEqual(parseCapabilityOutput('plain server banner'), {
platform: 'plain server banner', docker: false, dockerReady: false, compose: false, git: false, tar: false, checksum: false
});
});
test("server validation handles password and missing or non-file private keys", async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-ssh-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
const service = new SshService({ store: {}, diagnostics: null });
assert.deepEqual(await service.validateServerConfiguration({ authType: 'password' }), { valid: true, method: 'password' });
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: '' }), /select a private key/i);
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'missing') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND');
await mkdir(path.join(root, 'directory'));
await assert.rejects(service.validateServerConfiguration({ authType: 'privateKey', privateKeyPath: path.join(root, 'directory') }), (error) => error.code === 'SSH_PRIVATE_KEY_NOT_FOUND');
});
test("connection options enforce host identity and support password credentials", async () => {
const key = Buffer.from('server-key');
const fingerprint = fingerprintKey(key);
const store = { getServerCredentials: () => ({ password: 'secret' }) };
const service = new SshService({ store, diagnostics: null });
const trusted = await service.connectionOptions({ id: 'one', host: 'server', port: 2222, username: 'root', authType: 'password', hostFingerprint: fingerprint });
assert.equal(trusted.options.password, 'secret');
assert.equal(trusted.options.port, 2222);
assert.equal(trusted.options.hostVerifier(key), true);
assert.equal(trusted.getObservedFingerprint(), fingerprint);
assert.equal(trusted.options.hostVerifier(Buffer.from('changed')), false);
const firstUse = await service.connectionOptions({ id: 'one', host: 'server', username: 'root', authType: 'password' }, { trustOnFirstUse: true });
assert.equal(firstUse.options.port, 22);
assert.equal(firstUse.options.hostVerifier(key), true);
const previewBound = await service.connectionOptions(
{ id: 'one', host: 'server', username: 'root', authType: 'password' },
{ expectedFingerprint: fingerprint },
);
assert.equal(previewBound.options.hostVerifier(key), true);
assert.equal(previewBound.options.hostVerifier(Buffer.from('changed')), false);
});
test("SSH host fingerprint preview rejects the handshake before credentials are requested", async () => {
const key = Buffer.from("untrusted-server-key");
let connectedOptions = null;
class ProbeClient extends EventEmitter {
connect(options) {
connectedOptions = options;
assert.equal(options.hostVerifier(key), false);
queueMicrotask(() => this.emit("error", Object.assign(new Error("host rejected"), { code: "HOST_VERIFIER_REJECTED" })));
}
end() {}
}
const store = {
getServer: () => ({ id: "server", name: "Unraid", host: "192.0.2.10", port: 2222, username: "root", authType: "password" }),
getServerCredentials: () => { throw new Error("credentials must not be read during a fingerprint preview"); },
};
const service = new SshService({ store, diagnostics: null, clientFactory: () => ProbeClient });
const result = await service.probeHostFingerprint("server");
assert.equal(result.fingerprint, fingerprintKey(key));
assert.deepEqual(result.server, { id: "server", name: "Unraid", host: "192.0.2.10", port: 2222 });
assert.equal("password" in connectedOptions, false);
assert.equal("privateKey" in connectedOptions, false);
});
test("connection options report unreadable private keys without leaking credentials", async () => {
const service = new SshService({ store: { getServerCredentials: () => ({ passphrase: 'secret' }) }, diagnostics: null });
await assert.rejects(
service.connectionOptions({ id: 'key', host: 'server', username: 'root', authType: 'privateKey', privateKeyPath: 'Z:/missing/key' }),
(error) => error.code === 'SSH_PRIVATE_KEY_READ_FAILED' && !error.message.includes('secret')
);
});
test("SSH execution distinguishes startup errors, command failures, success and timeout", async () => {
const service = new SshService({ store: {}, diagnostics: null });
const clientFor = (start) => ({ exec(_command, callback) { start(callback); } });
await assert.rejects(service.execClient(clientFor((callback) => callback(new Error('exec unavailable'))), 'x'), /exec unavailable/);
const commandClient = clientFor((callback) => {
const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream);
queueMicrotask(() => { stream.stderr.emit('data', Buffer.from('permission denied')); stream.emit('close', 23, 'TERM'); });
});
await assert.rejects(service.execClient(commandClient, 'x'), (error) => error.code === 'SSH_COMMAND_FAILED' && error.exitCode === 23 && error.signal === 'TERM');
const successClient = clientFor((callback) => {
const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream);
queueMicrotask(() => { stream.emit('data', Buffer.from('ok')); stream.stderr.emit('data', Buffer.from('warning')); stream.emit('close', 0, null); });
});
assert.deepEqual(await service.execClient(successClient, 'x'), { stdout: 'ok', stderr: 'warning', exitCode: 0, truncated: false });
const hangingClient = clientFor((callback) => { const stream = new EventEmitter(); stream.stderr = new EventEmitter(); callback(null, stream); });
await assert.rejects(service.execClient(hangingClient, 'x', { timeout: 5 }), /timed out/i);
});
test("upload and execution reject unsafe paths and untrusted hosts", async () => {
const service = new SshService({ store: { getServer: () => ({ id: 'one' }) }, diagnostics: null });
assert.equal(service.ensureUploadTarget('\\srv\\apps\\file'), '/srv/apps/file');
for (const target of ['', 'relative/file', '/srv/../secret', `/srv/${String.fromCharCode(0)}bad`]) {
assert.throws(() => service.ensureUploadTarget(target), /absolute safe Unix path/i);
}
await assert.rejects(service.withSftp('one', '/srv/file', () => {}), (error) => error.code === 'SSH_HOST_NOT_TRUSTED');
await assert.rejects(service.exec('one', 'true'), (error) => error.code === 'SSH_HOST_NOT_TRUSTED');
});
test("uploadFile rejects directories before connecting", async (context) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'forgeflow-upload-'));
context.after(() => import('node:fs/promises').then(({ rm }) => rm(root, { recursive: true, force: true })));
const service = new SshService({ store: {}, diagnostics: null });
await assert.rejects(service.uploadFile('one', root, '/srv/file'), /not a file/i);
const file = path.join(root, 'file');
await writeFile(file, 'content');
service.withSftp = async (_id, remotePath, action) => action({ fastPut(_local, _target, options, callback) { options.step(7, 7, 7); callback(null); } }, remotePath);
let progress = null;
assert.deepEqual(await service.uploadFile('one', file, '/srv/file', { onProgress: (value) => { progress = value; } }), { remotePath: '/srv/file', size: 7 });
assert.deepEqual(progress, { transferred: 7, total: 7 });
});
+49
View File
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import toolInvocation from '../src/shared/tool-invocation.cjs';
import processRunner from '../src/main/process-runner.cjs';
const { npmProbeCandidates } = toolInvocation;
const { run } = processRunner;
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' }
]);
});
test('process runner accepts stdin for Git pathspec transport', async () => {
const result = await run(process.execPath, ['-e', 'process.stdin.pipe(process.stdout)'], { input: 'a\0b\0' });
assert.equal(result.stdout, 'a\0b\0');
});
File diff suppressed because it is too large. Load diff
+799
View File
@@ -0,0 +1,799 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { createHash, generateKeyPairSync, sign } from "node:crypto";
import { execFile, spawn } from "node:child_process";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
const require = createRequire(import.meta.url);
const execFileAsync = promisify(execFile);
const {
UpdateService,
verifyReleaseManifest,
waitForUpdaterStarted,
windowsUpdaterSpawnOptions,
} = require("../src/main/update-service.cjs");
function createSignedReleaseFixture({
version,
remoteSha,
assetName,
binary,
}) {
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
const sha256 = createHash("sha256").update(binary).digest("hex");
const manifest = {
schemaVersion: 1,
product: "ForgeFlow",
version,
tag: `v${version}`,
commit: remoteSha,
buildId: "test-build",
signature: { algorithm: "Ed25519", keyId: "SHA256:test" },
artifacts: [{ name: assetName, bytes: binary.length, sha256 }],
};
const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
const signatureBytes = Buffer.from(
`${sign(null, manifestBytes, privateKey).toString("base64")}\n`,
);
return { publicKey, sha256, manifestBytes, signatureBytes };
}
test("Windows updater uses a hidden non-detached PowerShell child", () => {
assert.deepEqual(windowsUpdaterSpawnOptions("C:\\updates"), {
detached: false,
stdio: "ignore",
windowsHide: true,
cwd: "C:\\updates",
});
});
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 });
});
test("source updater refuses an unsigned archive before launching a helper", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-handshake-"),
);
const source = path.join(temp, "source");
const scripts = path.join(source, "scripts");
const archive = path.join(temp, "update.zip");
await mkdir(scripts, { recursive: true });
await writeFile(
path.join(scripts, "apply-source-update.ps1"),
"# test helper",
);
await writeFile(archive, "PK fake archive");
let capturedArgs = null;
const spawnProcess = (_command, args) => {
capturedArgs = args;
const child = new EventEmitter();
child.pid = 4321;
child.unref = () => {};
queueMicrotask(() => child.emit("spawn"));
const statusIndex = args.indexOf("-StatusPath");
const statusPath = args[statusIndex + 1];
const updateIdIndex = args.indexOf("-UpdateId");
const updateId = args[updateIdIndex + 1];
setTimeout(
() =>
writeFile(
statusPath,
JSON.stringify({
state: "started",
expectedVersion: "0.5.3",
updateId,
}),
),
30,
);
return child;
};
const service = new UpdateService({
store: {
data: { updates: {}, gitea: { baseUrl: "https://example.test" } },
save: async () => {},
},
gitea: {},
diagnostics: null,
appInfo: { version: "0.5.2", packaged: false },
sourcePath: source,
userDataPath: temp,
platform: "win32",
spawnProcess,
powershellPath:
"C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
handshakeTimeoutMs: 1000,
handshakePollMs: 10,
});
service.staged = {
archivePath: archive,
remoteVersion: "0.5.3",
remoteSha: "a".repeat(40),
sha256: "b".repeat(64),
};
await assert.rejects(
service.apply(),
(error) => error.code === "SIGNED_SOURCE_UPDATE_REQUIRED",
);
assert.equal(capturedArgs, null);
await rm(temp, { recursive: true, force: true });
});
test("source updater leaves ForgeFlow open when no STARTED marker arrives", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-timeout-"),
);
const statusPath = path.join(temp, "status.json");
await writeFile(statusPath, JSON.stringify({ state: "launching" }));
await assert.rejects(
() =>
waitForUpdaterStarted(statusPath, {
timeoutMs: 80,
pollMs: 10,
childState: { exited: false, error: null },
}),
(error) => error.code === "UPDATE_HELPER_START_TIMEOUT",
);
await rm(temp, { recursive: true, force: true });
});
test("completed source update result is returned once and acknowledged", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-result-"),
);
const updates = path.join(temp, "updates");
await mkdir(updates, { recursive: true });
const statusPath = path.join(updates, "apply-test.status.json");
await writeFile(
statusPath,
JSON.stringify({
state: "success",
expectedVersion: "0.5.3",
installedVersion: "0.5.3",
restartLaunched: false,
message: "installed",
logPath: "C:\\log.txt",
updatedAt: new Date().toISOString(),
}),
);
const service = new UpdateService({
store: { data: { updates: {} }, save: async () => {} },
gitea: {},
diagnostics: null,
appInfo: { version: "0.5.3", packaged: false },
sourcePath: temp,
userDataPath: temp,
});
const first = await service.consumeLatestResult();
const second = await service.consumeLatestResult();
assert.equal(first.state, "success");
assert.equal(first.restartLaunched, false);
assert.equal(second, null);
const persisted = JSON.parse(await readFile(statusPath, "utf8"));
assert.ok(persisted.acknowledgedAt);
await rm(temp, { recursive: true, force: true });
});
test("PowerShell update helper writes lifecycle status before waiting for ForgeFlow exit", async () => {
const script = await readFile(
new URL("../scripts/apply-source-update.ps1", import.meta.url),
"utf8",
);
assert.match(script, /\[string\]\$StatusPath/);
assert.match(script, /Write-UpdateState -State "started"/);
assert.match(script, /Write-UpdateState -State "success"/);
assert.match(script, /Write-UpdateState -State "rolled-back"/);
assert.match(script, /UTF8Encoding\(\$false\)/);
assert.match(script, /WriteAllText/);
});
test("PowerShell update helper starts with param and has no BOM or stray leading slash", async () => {
const bytes = await readFile(
new URL("../scripts/apply-source-update.ps1", import.meta.url),
);
assert.notDeepEqual([...bytes.subarray(0, 3)], [0xef, 0xbb, 0xbf]);
const text = bytes.toString("utf8");
assert.match(text.trimStart(), /^param\(/);
assert.doesNotMatch(text.trimStart(), /^\\/);
assert.match(text, /node_modules\\electron\\dist\\electron\.exe/);
assert.match(text, /npm ci --no-audit --no-fund/);
assert.match(text, /package-lock\.json/);
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
assert.match(text, /Integrated source update refuses to overwrite a Git working tree/);
assert.ok(
text.indexOf("Handshake-only verification completed successfully") <
text.indexOf("Integrated source update refuses to overwrite a Git working tree"),
);
assert.ok(
text.indexOf("$actualHash = Get-Sha256") <
text.indexOf("Source update preflight passed"),
);
assert.ok(
text.indexOf('Write-UpdateState -State "success"') <
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
);
});
test("release publisher verifies Gitea and bootstraps the installed updater service and helper", async () => {
const script = await readFile(
new URL("../Publish-ForgeFlow-Release.ps1", import.meta.url),
"utf8",
);
assert.match(script, /npm install --no-audit --no-fund/);
assert.match(script, /package-lock\.json/);
assert.match(script, /non-reproducible update/);
assert.match(script, /npm run check/);
assert.match(script, /npm run dist:win/);
assert.match(script, /npm run release:binary/);
assert.match(script, /SkipBinaryRelease/);
assert.match(script, /ForgeFlow-Setup-\$version-win-x64\.exe/);
assert.match(script, /ForgeFlow-Portable-\$version-win-x64\.exe/);
assert.match(script, /git ls-remote origin/);
assert.match(script, /publishedCommit -ne \$localCommit/);
assert.match(script, /scripts\\apply-source-update\.ps1/);
assert.match(script, /src\\main\\update-service\.cjs/);
assert.match(script, /expectedUpdateId/);
assert.match(script, /readLogTail/);
assert.match(script, /HandshakeOnly/);
assert.match(script, /handshakeResult\.state -ne "started"/);
assert.match(script, /Windows-tested/);
assert.match(script, /without changing its version/);
assert.doesNotMatch(script, /Copy-Item[^\n]+package\.json/);
});
test("one-click Windows release wrapper invokes the atomic publisher", async () => {
const script = await readFile(
new URL("../PUBLISH-AND-ENABLE-UPDATE.cmd", import.meta.url),
"utf8",
);
assert.match(script, /ExecutionPolicy Bypass/);
assert.match(script, /Publish-ForgeFlow-Release\.ps1/);
assert.match(script, /older ForgeFlow updater can now install/);
assert.match(script, /exit \/b %forgeflowExitCode%/);
});
test("missing binary release recovery script builds the exact Gitea commit and uploads all assets", async () => {
const script = await readFile(
new URL("../Publish-Missing-Binary-Release.ps1", import.meta.url),
"utf8",
);
assert.match(script.trimStart(), /^param\(/);
assert.match(script, /git clone --branch \$Branch --single-branch/);
assert.match(script, /git -C \$clone ls-remote origin/);
assert.match(script, /npm ci --no-audit --no-fund/);
assert.match(script, /npm run check/);
assert.match(script, /npm run dist:win/);
assert.match(script, /npm run release:binary/);
assert.match(script, /FORGEFLOW_USER_DATA/);
assert.match(script, /ForgeFlow-Setup-\$version-win-x64\.exe/);
assert.match(script, /ForgeFlow-Portable-\$version-win-x64\.exe/);
});
test("binary publisher derives repository coordinates from ForgeFlow settings", async () => {
const script = await readFile(
new URL("../scripts/publish-binary-release.cjs", import.meta.url),
"utf8",
);
assert.match(script, /config\.updates\?\.owner/);
assert.match(script, /config\.updates\?\.repo/);
assert.match(script, /config\.updates\?\.branch/);
assert.match(script, /encodeURIComponent\(owner\)/);
assert.match(script, /encodeURIComponent\(repo\)/);
assert.doesNotMatch(script, /\/repos\/Jens\/ForgeFlow\/releases/);
});
test("packaged updater passes Gitea browser download URLs to the asset downloader", async () => {
const source = await readFile(
new URL("../src/main/update-service.cjs", import.meta.url),
"utf8",
);
assert.match(source, /downloadUrl: asset\.browser_download_url/);
assert.match(source, /downloadUrl: checksumAsset\.browser_download_url/);
assert.match(source, /downloadUrl: manifestAsset\.browser_download_url/);
assert.match(source, /downloadUrl: signatureAsset\.browser_download_url/);
assert.match(source, /RELEASE_ASSET_METADATA_RECEIVED/);
});
test("PowerShell helper replaces an existing launching status with a Windows-safe file API", async () => {
const script = await readFile(
new URL("../scripts/apply-source-update.ps1", import.meta.url),
"utf8",
);
assert.match(
script,
/System\.IO\.File\]::Replace\(\$temporary, \$StatusPath, \$backup\)/,
);
assert.match(
script,
/System\.IO\.File\]::Copy\(\$temporary, \$StatusPath, \$true\)/,
);
assert.doesNotMatch(
script,
/Move-Item -LiteralPath \$temporary -Destination \$StatusPath -Force/,
);
assert.match(script, /\[switch\]\$HandshakeOnly/);
assert.match(script, /Handshake-only verification completed successfully/);
});
test("binary helper confirms startup through real Windows PowerShell", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-handshake-"));
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
await writeFile(statusPath, JSON.stringify({ state: "launching", updateId: "binary-handshake" }));
const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url));
const { stdout, stderr } = await execFileAsync(powershell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
"-BinaryPath", path.join(temp, "unused.exe"), "-ExpectedSha256", "0".repeat(64),
"-ExpectedVersion", "9.9.9", "-CurrentExecutable", path.join(temp, "unused-current.exe"),
"-Portable", "False", "-ParentPid", "999999", "-LogPath", logPath,
"-StatusPath", statusPath, "-UpdateId", "binary-handshake", "-HandshakeOnly"
], { windowsHide: true });
assert.equal(stdout, "");
assert.equal(stderr, "");
const status = JSON.parse(await readFile(statusPath, "utf8"));
assert.equal(status.updateId, "binary-handshake");
assert.equal(status.state, "started");
assert.match(await readFile(logPath, "utf8"), /Handshake-only verification completed successfully/);
await rm(temp, { recursive: true, force: true });
});
test("binary helper confirms startup through the production Node spawn options", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-node-spawn-"));
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url));
const updateId = "binary-node-spawn";
await writeFile(statusPath, JSON.stringify({ state: "launching", updateId }));
const child = spawn(powershell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
"-BinaryPath", path.join(temp, "unused.exe"), "-ExpectedSha256", "0".repeat(64),
"-ExpectedVersion", "9.9.9", "-CurrentExecutable", path.join(temp, "unused-current.exe"),
"-Portable", "False", "-ParentPid", String(process.pid), "-LogPath", logPath,
"-StatusPath", statusPath, "-UpdateId", updateId, "-HandshakeOnly",
], windowsUpdaterSpawnOptions(temp));
const childState = { exited: false, code: null, error: null };
child.once("error", (error) => { childState.error = error; });
child.once("exit", (code) => { childState.exited = true; childState.code = code; });
const status = await waitForUpdaterStarted(statusPath, {
timeoutMs: 5000,
pollMs: 25,
childState,
expectedUpdateId: updateId,
logPath,
});
assert.equal(status.state, "started");
let log = "";
for (let attempt = 0; attempt < 40 && !log.includes("Handshake-only verification completed successfully"); attempt += 1) {
await delay(25);
log = await readFile(logPath, "utf8").catch(() => "");
}
assert.match(log, /Handshake-only verification completed successfully/);
if (child.exitCode === null) {
await new Promise((resolve, reject) => {
child.once("exit", resolve);
child.once("error", reject);
});
}
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
test("binary helper verifies SHA-256 without PowerShell module autoloading", { skip: process.platform !== "win32" }, async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-binary-dotnet-sha-"));
const binaryPath = path.join(temp, "update.exe");
const currentPath = path.join(temp, "current.exe");
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "helper.log");
const bytes = Buffer.from("verified update bytes");
await writeFile(binaryPath, bytes);
await writeFile(currentPath, "current");
const expectedSha256 = createHash("sha256").update(bytes).digest("hex");
const powershell = path.join(process.env.SystemRoot || process.env.WINDIR, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
const scriptPath = fileURLToPath(new URL("../scripts/apply-binary-update.ps1", import.meta.url));
const { stderr } = await execFileAsync(powershell, [
"-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", scriptPath,
"-BinaryPath", binaryPath, "-ExpectedSha256", expectedSha256, "-ExpectedVersion", "9.9.9",
"-CurrentExecutable", currentPath, "-Portable", "False", "-ParentPid", String(process.pid),
"-LogPath", logPath, "-StatusPath", statusPath, "-UpdateId", "dotnet-sha", "-VerifyOnly",
], { windowsHide: true, env: { ...process.env, PSModulePath: "" } });
assert.equal(stderr, "");
assert.match(await readFile(logPath, "utf8"), /Verification-only SHA-256 check completed successfully/);
await rm(temp, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
test("early helper exit reports the helper log instead of only an exit code", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-update-log-tail-"),
);
const statusPath = path.join(temp, "status.json");
const logPath = path.join(temp, "apply.log");
await writeFile(
statusPath,
JSON.stringify({ state: "launching", updateId: "request-1" }),
);
await writeFile(logPath, "first line\nactual helper failure\n");
await assert.rejects(
() =>
waitForUpdaterStarted(statusPath, {
timeoutMs: 100,
pollMs: 5,
childState: { exited: true, code: 0, error: null },
expectedUpdateId: "request-1",
logPath,
}),
(error) =>
error.code === "UPDATE_HELPER_EXITED_EARLY" &&
/actual helper failure/.test(error.message),
);
await rm(temp, { recursive: true, force: true });
});
test("updater handshake rejects a stale status from another update request", async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-id-"));
const statusPath = path.join(temp, "status.json");
await writeFile(
statusPath,
JSON.stringify({ state: "started", updateId: "old-request" }),
);
await assert.rejects(
() =>
waitForUpdaterStarted(statusPath, {
timeoutMs: 50,
pollMs: 5,
childState: { exited: false, code: null, error: null },
expectedUpdateId: "new-request",
}),
(error) => error.code === "UPDATE_HELPER_START_TIMEOUT",
);
await rm(temp, { recursive: true, force: true });
});
test("packaged updater downloads only a publisher-signed Windows asset", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-binary-update-"),
);
const binary = Buffer.alloc(1_100_000, 0x5a);
binary[0] = 0x4d;
binary[1] = 0x5a;
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
const remoteSha = "a".repeat(40);
const signed = createSignedReleaseFixture({
version: "0.8.2",
remoteSha,
assetName,
binary,
});
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
const gitea = {
async getReleaseByTag(_owner, _repo, tag) {
if (tag !== "v0.8.2") return null;
return {
id: 82,
tag_name: tag,
draft: false,
prerelease: false,
assets: [
{
id: 41,
name: assetName,
browser_download_url: "http://wrong-origin.test/setup",
},
{
name: `${assetName}.sha256`,
id: 42,
browser_download_url: "http://wrong-origin.test/checksum",
},
{
name: manifestName,
id: 43,
browser_download_url: "http://wrong-origin.test/manifest",
},
{
name: `${manifestName}.sig`,
id: 44,
browser_download_url: "http://wrong-origin.test/signature",
},
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
assert.equal(releaseId, 82);
const downloads = {
41: ["http://wrong-origin.test/setup", binary],
42: [
"http://wrong-origin.test/checksum",
Buffer.from(`${signed.sha256} ${assetName}\n`),
],
43: ["http://wrong-origin.test/manifest", signed.manifestBytes],
44: ["http://wrong-origin.test/signature", signed.signatureBytes],
};
assert.equal(options.downloadUrl, downloads[assetId][0]);
return downloads[assetId][1];
},
};
const service = new UpdateService({
store: {
data: { gitea: { baseUrl: "https://gitea.test" } },
save: async () => {},
},
gitea,
diagnostics: null,
appInfo: {
version: "0.8.1",
packaged: true,
executablePath: "C:\\ForgeFlow\\ForgeFlow.exe",
},
sourcePath: temp,
userDataPath: temp,
platform: "win32",
updatePublicKey: signed.publicKey,
});
const result = await service.downloadPackaged({
owner: "Jens",
repo: "ForgeFlow",
remoteVersion: "0.8.2",
remoteSha,
});
assert.equal(result.downloaded, true);
assert.equal(result.sha256, signed.sha256);
assert.equal(result.publisherKeyId, "SHA256:test");
assert.equal(result.portable, false);
assert.equal((await readFile(result.binaryPath)).length, binary.length);
await rm(temp, { recursive: true, force: true });
});
test("packaged updater rejects a binary whose checksum does not match", async () => {
const temp = await mkdtemp(
path.join(os.tmpdir(), "forgeflow-binary-mismatch-"),
);
const binary = Buffer.alloc(1_100_000, 0x5a);
binary[0] = 0x4d;
binary[1] = 0x5a;
const assetName = "ForgeFlow-Portable-0.8.2-win-x64.exe";
const remoteSha = "b".repeat(40);
const signed = createSignedReleaseFixture({
version: "0.8.2",
remoteSha,
assetName,
binary,
});
const manifestName = "ForgeFlow-0.8.2-release-manifest.json";
const service = new UpdateService({
store: { data: { gitea: {} }, save: async () => {} },
gitea: {
async getReleaseByTag() {
return {
id: 83,
tag_name: "v0.8.2",
assets: [
{
id: 51,
name: assetName,
browser_download_url: "http://wrong-origin.test/portable",
},
{
id: 52,
name: `${assetName}.sha256`,
browser_download_url: "http://wrong-origin.test/checksum",
},
{ id: 53, name: manifestName },
{ id: 54, name: `${manifestName}.sig` },
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
assert.equal(releaseId, 83);
if (assetId === 51) return binary;
if (assetId === 52)
return Buffer.from(`${"0".repeat(64)} ${assetName}`);
if (assetId === 53) return signed.manifestBytes;
return signed.signatureBytes;
},
},
diagnostics: null,
appInfo: {
version: "0.8.1",
packaged: true,
portableExecutablePath: "C:\\ForgeFlow-Portable.exe",
},
sourcePath: temp,
userDataPath: temp,
platform: "win32",
updatePublicKey: signed.publicKey,
});
await assert.rejects(
() =>
service.downloadPackaged({
owner: "Jens",
repo: "ForgeFlow",
remoteVersion: "0.8.2",
remoteSha,
}),
/does not match the signed publisher manifest/,
);
await rm(temp, { recursive: true, force: true });
});
test("release manifest verification rejects a different publisher key", () => {
const binary = Buffer.alloc(1_100_000, 0x5a);
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
const fixture = createSignedReleaseFixture({
version: "0.8.2",
remoteSha: "c".repeat(40),
assetName,
binary,
});
const otherKey = generateKeyPairSync("ed25519").publicKey;
assert.throws(
() =>
verifyReleaseManifest({
manifestBytes: fixture.manifestBytes,
signatureBytes: fixture.signatureBytes,
publicKey: otherKey,
update: {
remoteVersion: "0.8.2",
remoteSha: "c".repeat(40),
},
assetName,
}),
(error) => error.code === "RELEASE_SIGNATURE_INVALID",
);
});
test("Windows release pipeline emits signed provenance, manifest and SBOM evidence", async () => {
const [pkgSource, signatureSource, checksumSource, manifestSigner, releaseWorkflow] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/verify-release-signatures.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/write-release-checksums.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
readFile(new URL("../.gitea/workflows/release.yml", import.meta.url), "utf8"),
]);
assert.match(pkgSource, /verify-release-signatures\.mjs/);
assert.match(signatureSource, /FORGEFLOW_SIGNED_RELEASE/);
assert.match(signatureSource, /FORGEFLOW_EXPECTED_PUBLISHER/);
assert.match(signatureSource, /TimestampSubject/);
assert.match(signatureSource, /Signed release verification failed/);
assert.match(signatureSource, /Authenticode inspection unavailable/);
assert.match(checksumSource, /provenance\.json/);
assert.match(checksumSource, /sbom\.cdx\.json/);
assert.match(checksumSource, /CycloneDX/);
assert.match(checksumSource, /publisherManifestSignature/);
assert.match(manifestSigner, /Ed25519/);
assert.match(manifestSigner, /release-manifest\.json/);
assert.match(pkgSource, /sign-release-manifest\.mjs/);
assert.doesNotMatch(releaseWorkflow, /checkout@v\d|setup-node@v\d/);
assert.match(releaseWorkflow, /checkout@[a-f0-9]{40}/);
assert.match(releaseWorkflow, /setup-node@[a-f0-9]{40}/);
assert.ok(
releaseWorkflow.indexOf("Validate version bump and build release artifacts") <
releaseWorkflow.indexOf("FORGEFLOW_RELEASE_SIGNING_KEY_PEM"),
"signing secrets must not be present during dependency installation and quality checks",
);
assert.match(releaseWorkflow, /finally \{/);
assert.match(releaseWorkflow, /Remove-Item -LiteralPath \$privateKeyPath -Force/);
const publisher = await readFile(new URL("../scripts/publish-binary-release.cjs", import.meta.url), "utf8");
assert.match(publisher, /draft: true/);
assert.match(publisher, /requiredAssets/);
assert.match(publisher, /Release remains draft because required assets are missing/);
assert.match(publisher, /sbom\.cdx\.json/);
});
test("the supported Windows build uses free offline Ed25519 publisher signing", async () => {
const [pkg, keySetup, manifestSigner, publicKey] = await Promise.all([
readFile(new URL("../package.json", import.meta.url), "utf8"),
readFile(new URL("../scripts/setup-update-signing-key.mjs", import.meta.url), "utf8"),
readFile(new URL("../scripts/sign-release-manifest.mjs", import.meta.url), "utf8"),
readFile(new URL("../build/update-signing-public.pem", import.meta.url), "utf8"),
]);
assert.doesNotMatch(pkg, /dist:win:signed/);
assert.match(pkg, /dist:win/);
assert.match(pkg, /signing:setup/);
assert.match(keySetup, /release-signing-private\.pem/);
assert.match(manifestSigner, /sign\(null, manifestBytes, privateKey\)/);
assert.match(publicKey, /BEGIN PUBLIC KEY/);
assert.doesNotMatch(publicKey, /PRIVATE KEY/);
});
test("binary update helper verifies, waits, applies and records restart state", async () => {
const helper = await readFile(
new URL("../scripts/apply-binary-update.ps1", import.meta.url),
"utf8",
);
for (const marker of [
"Security.Cryptography.SHA256",
"Wait-Process",
'Write-UpdateState -State "started"',
'Write-UpdateState -State "waiting-for-exit"',
'Write-UpdateState -State "applying"',
'Write-UpdateState -State "success"',
'Start-Process -FilePath $BinaryPath -ArgumentList "/S"',
"Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable",
]) {
assert.ok(
helper.includes(marker),
`missing binary updater marker: ${marker}`,
);
}
assert.doesNotMatch(helper, /Get-FileHash/);
assert.match(helper, /function Start-ForgeFlowAndVerify/);
assert.match(helper, /Start-Sleep -Milliseconds 1500/);
assert.match(helper, /Updated portable executable failed its restart probe/);
assert.ok(
helper.indexOf("$actualSha256 = Get-Sha256") <
helper.indexOf("Binary preflight passed"),
);
assert.ok(
helper.indexOf("Binary preflight passed") <
helper.indexOf('Write-UpdateState -State "waiting-for-exit"'),
);
});
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import validation from '../src/shared/validation.cjs';
const { normalizeBaseUrl, assertCommitMessage, assertDeploymentRequest, assertHttpUrl } = 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);
});
test('accepts Unraid DockerMan WebUI placeholders only when explicitly enabled', () => {
assert.equal(assertHttpUrl('http://[IP]:[PORT:1223]/', { allowUnraidTemplate: true }), 'http://[IP]:[PORT:1223]/');
assert.throws(() => assertHttpUrl('http://[IP]:[PORT:1223]/'));
});
+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');
});