Publish curated ForgeFlow source from 2ed1787c0b52
This commit is contained in:
commit
f60b269686
254 files changed
+46204
No files matched your search
@@ -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/);
|
||||
});
|
||||
@@ -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"/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import gitStatus from '../src/shared/git-status.cjs';
|
||||
|
||||
const { parsePorcelainV2 } = gitStatus;
|
||||
|
||||
test('parses branch metadata and ordinary changes', () => {
|
||||
const output = [
|
||||
'# branch.oid 0123456789abcdef',
|
||||
'# branch.head main',
|
||||
'# branch.upstream origin/main',
|
||||
'# branch.ab +2 -1',
|
||||
'1 .M N... 100644 100644 100644 abc def src/main.js',
|
||||
'1 M. N... 100644 100644 100644 abc def README.md',
|
||||
'? new file.txt',
|
||||
''
|
||||
].join('\0');
|
||||
const parsed = parsePorcelainV2(output);
|
||||
assert.equal(parsed.branch.head, 'main');
|
||||
assert.equal(parsed.branch.ahead, 2);
|
||||
assert.equal(parsed.branch.behind, 1);
|
||||
assert.equal(parsed.counts.changed, 3);
|
||||
assert.equal(parsed.counts.staged, 1);
|
||||
assert.equal(parsed.counts.untracked, 1);
|
||||
assert.equal(parsed.files[0].path, 'src/main.js');
|
||||
});
|
||||
|
||||
test('parses rename records with original path', () => {
|
||||
const output = '2 R. N... 100644 100644 100644 abc def R100 src/new.js\0src/old.js\0';
|
||||
const parsed = parsePorcelainV2(output);
|
||||
assert.equal(parsed.files[0].path, 'src/new.js');
|
||||
assert.equal(parsed.files[0].originalPath, 'src/old.js');
|
||||
assert.equal(parsed.files[0].status, 'renamed');
|
||||
});
|
||||
@@ -0,0 +1,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\/<app>/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import gitModule from '../src/main/git-service.cjs';
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const { GitService } = gitModule;
|
||||
const git = (args, cwd) => exec('git', args, { cwd, encoding: 'utf8' });
|
||||
|
||||
test('supports commit-only, branch creation, stash lifecycle and remote SHA verification', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'forgeflow-git-workflow-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const remote = path.join(root, 'remote.git');
|
||||
const working = path.join(root, 'working');
|
||||
await git(['init', '--bare', remote], root);
|
||||
await git(['clone', remote, working], root);
|
||||
await git(['config', 'user.name', 'ForgeFlow Test'], working);
|
||||
await git(['config', 'user.email', 'forgeflow@example.invalid'], working);
|
||||
await fs.writeFile(path.join(working, 'README.md'), '# ForgeFlow\n');
|
||||
await git(['add', '.'], working);
|
||||
await git(['commit', '-m', 'Initial'], working);
|
||||
await git(['branch', '-M', 'main'], working);
|
||||
await git(['push', '-u', 'origin', 'main'], working);
|
||||
|
||||
const service = new GitService();
|
||||
const branchStatus = await service.createBranch(working, 'feature/release-flow');
|
||||
assert.equal(branchStatus.branch.head, 'feature/release-flow');
|
||||
await fs.writeFile(path.join(working, 'release.txt'), 'release cockpit\n');
|
||||
const committed = await service.commit(working, 'Add release flow', ['release.txt']);
|
||||
assert.equal(committed.status.branch.ahead, 0, 'unpublished branches have no upstream-based ahead count');
|
||||
const pushed = await service.push(working);
|
||||
assert.equal(pushed.status.branch.upstream, 'origin/feature/release-flow');
|
||||
await service.verifyCommitOnRemoteBranch(working, committed.sha, 'feature/release-flow');
|
||||
|
||||
await fs.appendFile(path.join(working, 'release.txt'), 'local draft\n');
|
||||
await fs.writeFile(path.join(working, 'untracked.txt'), 'draft\n');
|
||||
const stashed = await service.stash(working, 'Draft release work');
|
||||
assert.equal(stashed.status.clean, true);
|
||||
assert.equal(stashed.stashes.length, 1);
|
||||
const restored = await service.popStash(working, stashed.stashes[0].ref);
|
||||
assert.equal(restored.status.counts.changed, 2);
|
||||
});
|
||||
@@ -0,0 +1,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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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)),
|
||||
[],
|
||||
);
|
||||
});
|
||||
@@ -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>/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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\)/);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import matching from '../src/shared/repository-match.cjs';
|
||||
|
||||
const { normalizeRemoteUrl, matchRemoteToRepository } = matching;
|
||||
const repositories = [{ full_name: 'jens/forgeflow', name: 'forgeflow', owner: { login: 'jens' } }];
|
||||
|
||||
test('normalizes HTTPS remotes', () => {
|
||||
assert.deepEqual(normalizeRemoteUrl('https://gitea.internal/jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' });
|
||||
});
|
||||
|
||||
test('normalizes SCP-style SSH remotes', () => {
|
||||
assert.deepEqual(normalizeRemoteUrl('git@gitea.internal:jens/forgeflow.git'), { host: 'gitea.internal', path: 'jens/forgeflow' });
|
||||
});
|
||||
|
||||
test('matches local remote to Gitea full name', () => {
|
||||
assert.equal(matchRemoteToRepository('git@gitea.internal:jens/forgeflow.git', repositories)?.full_name, 'jens/forgeflow');
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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, '');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { parseVersion, compareVersions, isNewerVersion } = require('../src/shared/semver.cjs');
|
||||
|
||||
test('semantic versions are compared without lexical mistakes', () => {
|
||||
assert.equal(parseVersion('v0.4.0').minor, 4);
|
||||
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
|
||||
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
|
||||
assert.equal(isNewerVersion('0.4.1', '0.4.0'), true);
|
||||
assert.equal(isNewerVersion('0.4.0-beta.1', '0.4.0'), false);
|
||||
});
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/);
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
@@ -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"'),
|
||||
);
|
||||
});
|
||||
@@ -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]/'));
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import zlib from 'node:zlib';
|
||||
import zipModule from '../src/shared/zip-writer.cjs';
|
||||
|
||||
const { createZip, crc32 } = zipModule;
|
||||
|
||||
function unzipLocalEntries(buffer) {
|
||||
const entries = new Map();
|
||||
let offset = 0;
|
||||
while (offset + 4 <= buffer.length && buffer.readUInt32LE(offset) === 0x04034b50) {
|
||||
const method = buffer.readUInt16LE(offset + 8);
|
||||
const expectedCrc = buffer.readUInt32LE(offset + 14);
|
||||
const compressedSize = buffer.readUInt32LE(offset + 18);
|
||||
const nameLength = buffer.readUInt16LE(offset + 26);
|
||||
const extraLength = buffer.readUInt16LE(offset + 28);
|
||||
const nameStart = offset + 30;
|
||||
const dataStart = nameStart + nameLength + extraLength;
|
||||
const name = buffer.subarray(nameStart, nameStart + nameLength).toString('utf8');
|
||||
const compressed = buffer.subarray(dataStart, dataStart + compressedSize);
|
||||
const data = method === 8 ? zlib.inflateRawSync(compressed) : compressed;
|
||||
assert.equal(crc32(data), expectedCrc);
|
||||
entries.set(name, data);
|
||||
offset = dataStart + compressedSize;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
test('creates a valid deflated ZIP with UTF-8 entry names and CRCs', () => {
|
||||
const archive = createZip([
|
||||
{ name: 'manifest.json', data: '{"ok":true}\n' },
|
||||
{ name: 'logs/diagnostics.jsonl', data: Buffer.from('hello diagnostics\n') },
|
||||
{ name: 'unicode/één.txt', data: 'veilig' }
|
||||
]);
|
||||
assert.equal(archive.readUInt32LE(0), 0x04034b50);
|
||||
assert.equal(archive.readUInt32LE(archive.length - 22), 0x06054b50);
|
||||
const entries = unzipLocalEntries(archive);
|
||||
assert.equal(entries.size, 3);
|
||||
assert.equal(entries.get('manifest.json').toString(), '{"ok":true}\n');
|
||||
assert.equal(entries.get('unicode/één.txt').toString(), 'veilig');
|
||||
});
|
||||
Reference in new issue
Block a user