Files
ForgeFlow/tests/update-service.test.mjs
T
NuklearRabbit 6ef4620388
ForgeFlow quality gate / quality (push) Canceled after 0s
fix: make packaged updates certificate-free
2026-07-29 23:11:24 +02:00

565 lines
19 KiB
JavaScript

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 } from "node:crypto";
const require = createRequire(import.meta.url);
const {
UpdateService,
waitForUpdaterStarted,
} = require("../src/main/update-service.cjs");
test("update check pins version to an exact branch commit", async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-"));
const saved = [];
const store = {
data: {
gitea: { baseUrl: "https://gitea.example.test" },
updates: {
owner: "Jens",
repo: "ForgeFlow",
branch: "main",
autoCheck: true,
},
},
async save() {
saved.push(true);
},
};
const calls = [];
const gitea = {
async getBranch(owner, repo, branch) {
calls.push(["branch", owner, repo, branch]);
return { commit: { id: "a".repeat(40) } };
},
async getRepositoryFile(input) {
calls.push(["file", input]);
return {
decoded: JSON.stringify({ name: "forgeflow", version: "0.4.1" }),
};
},
};
const service = new UpdateService({
store,
gitea,
diagnostics: null,
appInfo: { version: "0.4.0", packaged: false },
sourcePath: temp,
userDataPath: temp,
});
const result = await service.check();
assert.equal(result.available, true);
assert.equal(result.remoteSha, "a".repeat(40));
assert.equal(calls[1][1].ref, "a".repeat(40));
assert.equal(saved.length, 1);
await rm(temp, { recursive: true, force: true });
});
test("update repository parts reject path injection", async () => {
const temp = await mkdtemp(path.join(os.tmpdir(), "forgeflow-update-test-"));
const service = new UpdateService({
store: {
data: {
updates: { owner: "../Jens", repo: "ForgeFlow", branch: "main" },
},
save: async () => {},
},
gitea: {},
diagnostics: null,
appInfo: { version: "0.4.0", packaged: false },
sourcePath: temp,
userDataPath: temp,
});
await assert.rejects(() => service.check(), /unsupported characters/);
await rm(temp, { recursive: true, force: true });
});
test("source updater confirms an external STARTED marker before ForgeFlow may close", 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),
};
const result = await service.apply();
assert.equal(result.confirmed, true);
assert.ok(capturedArgs.includes("-StatusPath"));
assert.ok(capturedArgs.includes("-UpdateId"));
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.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, /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, \$null\)/,
);
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("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 published checksum-matched 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 sha256 = createHash("sha256").update(binary).digest("hex");
const assetName = "ForgeFlow-Setup-0.8.2-win-x64.exe";
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",
},
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId, options) {
assert.equal(releaseId, 82);
assert.equal(
options.downloadUrl,
assetId === 42
? "http://wrong-origin.test/checksum"
: "http://wrong-origin.test/setup",
);
return assetId === 42 ? Buffer.from(`${sha256} ${assetName}\n`) : binary;
},
};
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",
});
const result = await service.downloadPackaged({
owner: "Jens",
repo: "ForgeFlow",
remoteVersion: "0.8.2",
});
assert.equal(result.downloaded, true);
assert.equal(result.sha256, sha256);
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 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",
},
],
};
},
async downloadReleaseAsset(_owner, _repo, releaseId, assetId) {
assert.equal(releaseId, 83);
return assetId === 52
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
: binary;
},
},
diagnostics: null,
appInfo: {
version: "0.8.1",
packaged: true,
portableExecutablePath: "C:\\ForgeFlow-Portable.exe",
},
sourcePath: temp,
userDataPath: temp,
platform: "win32",
});
await assert.rejects(
() =>
service.downloadPackaged({
owner: "Jens",
repo: "ForgeFlow",
remoteVersion: "0.8.2",
}),
/SHA-256 verification/,
);
await rm(temp, { recursive: true, force: true });
});
test("Windows release pipeline preserves optional signing checks and emits provenance plus SBOM", async () => {
const [pkgSource, signatureSource, checksumSource] = 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"),
]);
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(checksumSource, /provenance\.json/);
assert.match(checksumSource, /sbom\.cdx\.json/);
assert.match(checksumSource, /CycloneDX/);
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 is free, checksum-protected and updater-compatible", async () => {
const [pkg, signatureCheck, checksumWriter] = 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"),
]);
assert.doesNotMatch(pkg, /dist:win:signed/);
assert.match(pkg, /dist:win/);
assert.match(pkg, /write-release-checksums\.mjs/);
assert.match(signatureCheck, /checksum-protected unsigned artifact/);
assert.match(checksumWriter, /sha256/);
});
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 [
"Get-FileHash",
"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}`,
);
}
});