788 lines
29 KiB
JavaScript
788 lines
29 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, 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 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.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] = 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"),
|
|
]);
|
|
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/);
|
|
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"'),
|
|
);
|
|
});
|