feat: add safe Gitea sync and signed updates
This commit is contained in:
@@ -110,6 +110,8 @@ test("configuration mutations persist mappings, favorites, reviews, trends, oper
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,35 @@ test('GitService reads changes and commits/pushes selected files to a real bare
|
||||
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 }));
|
||||
@@ -204,6 +233,81 @@ test('detects and removes a stale HEAD.lock while skipping Git object storage',
|
||||
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((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/);
|
||||
});
|
||||
|
||||
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 }));
|
||||
|
||||
@@ -121,6 +121,49 @@ test("repository troubleshooting offers personalized synchronization repair acti
|
||||
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(
|
||||
|
||||
@@ -102,3 +102,47 @@ test('a watched repository is read on filesystem activity instead of on every in
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -19,6 +19,8 @@ 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);
|
||||
});
|
||||
|
||||
|
||||
@@ -73,6 +73,35 @@ test("connection options enforce host identity and support password credentials"
|
||||
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 () => {
|
||||
|
||||
@@ -320,6 +320,86 @@ test("low-level inventory scan is read-only and user discovery auto-links exact
|
||||
assert.deepEqual(discovery.refreshedProfileIds, [profiles[0].id]);
|
||||
});
|
||||
|
||||
test("a stale deployment link cannot block adoption of its running replacement", async () => {
|
||||
const repository = {
|
||||
fullName: "Jens/DevRunBook",
|
||||
name: "DevRunBook",
|
||||
defaultBranch: "main",
|
||||
sshUrl: "git@gitea.test:Jens/DevRunBook.git",
|
||||
};
|
||||
const stale = {
|
||||
workloadId: "old-devrunbook",
|
||||
status: "stale",
|
||||
classification: { type: "stale-link" },
|
||||
runtime: { running: false, health: "missing" },
|
||||
link: { profileId: "old-profile", repositoryFullName: repository.fullName },
|
||||
candidates: [{ repositoryFullName: repository.fullName, score: 100, exact: true }],
|
||||
};
|
||||
const replacement = {
|
||||
workloadId: "devrunbook-runtime",
|
||||
serverId: "unraid",
|
||||
displayName: "DevRunBook",
|
||||
status: "suggested",
|
||||
classification: { type: "active-application" },
|
||||
runtime: { running: true, health: "healthy" },
|
||||
link: null,
|
||||
candidates: [{
|
||||
repositoryFullName: repository.fullName,
|
||||
score: 85,
|
||||
exact: false,
|
||||
identityExact: true,
|
||||
}],
|
||||
compose: {
|
||||
project: "devrunbook",
|
||||
workingDir: "/mnt/user/appdata/DevRunBook",
|
||||
configFiles: ["/mnt/user/appdata/DevRunBook/compose.yml"],
|
||||
services: ["app"],
|
||||
},
|
||||
containers: [{ name: "DevRunBook", running: true, mounts: [], ports: [] }],
|
||||
metadata: { branch: "main" },
|
||||
remoteFolderCandidate: "DevRunBook",
|
||||
};
|
||||
const saved = [];
|
||||
const service = new UnraidDeploymentService({
|
||||
store: {
|
||||
data: {
|
||||
deploymentProfiles: {
|
||||
[repository.fullName]: [{
|
||||
id: "old-profile",
|
||||
provider: "ssh-unraid",
|
||||
serverId: "unraid",
|
||||
workloadIdentity: { workloadId: stale.workloadId, linkSource: "automatic" },
|
||||
}],
|
||||
},
|
||||
},
|
||||
createRecoverySnapshot: async () => ({}),
|
||||
saveDeploymentProfile: async (_fullName, profile) => {
|
||||
saved.push(profile);
|
||||
return profile;
|
||||
},
|
||||
saveDeploymentState: async () => ({}),
|
||||
},
|
||||
});
|
||||
service.collectServerInventory = async () => ({
|
||||
server: { id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata" },
|
||||
inventory: { capabilities: {}, warnings: [] },
|
||||
workloads: [stale, replacement],
|
||||
});
|
||||
|
||||
const plan = service.reconciliationPlan(
|
||||
{ id: "unraid" },
|
||||
[stale, replacement],
|
||||
[repository],
|
||||
{ autoLink: true },
|
||||
);
|
||||
assert.deepEqual(plan.additions.map((item) => item.workloadId), [replacement.workloadId]);
|
||||
|
||||
const result = await service.scanServerInventory("unraid", [repository], { autoLink: true });
|
||||
assert.equal(result.adopted, 1);
|
||||
assert.equal(replacement.link.repositoryFullName, repository.fullName);
|
||||
assert.equal(saved.length, 1);
|
||||
});
|
||||
|
||||
|
||||
|
||||
test("server inventory includes stopped DockerMan containers without Git and keeps name matches manual", () => {
|
||||
|
||||
+124
-24
@@ -5,7 +5,7 @@ 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";
|
||||
import { createHash, generateKeyPairSync, sign } from "node:crypto";
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -14,10 +14,36 @@ 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,
|
||||
@@ -324,6 +350,8 @@ test("packaged updater passes Gitea browser download URLs to the asset downloade
|
||||
);
|
||||
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 () => {
|
||||
@@ -480,15 +508,22 @@ test("updater handshake rejects a stale status from another update request", asy
|
||||
await rm(temp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("packaged updater downloads only a published checksum-matched Windows asset", async () => {
|
||||
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 sha256 = createHash("sha256").update(binary).digest("hex");
|
||||
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;
|
||||
@@ -508,18 +543,32 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
||||
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);
|
||||
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 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({
|
||||
@@ -537,14 +586,17 @@ test("packaged updater downloads only a published checksum-matched Windows asset
|
||||
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, sha256);
|
||||
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 });
|
||||
@@ -558,6 +610,14 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
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: {
|
||||
@@ -576,14 +636,18 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
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);
|
||||
return assetId === 52
|
||||
? Buffer.from(`${"0".repeat(64)} ${assetName}`)
|
||||
: binary;
|
||||
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,
|
||||
@@ -595,6 +659,7 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
sourcePath: temp,
|
||||
userDataPath: temp,
|
||||
platform: "win32",
|
||||
updatePublicKey: signed.publicKey,
|
||||
});
|
||||
await assert.rejects(
|
||||
() =>
|
||||
@@ -602,17 +667,45 @@ test("packaged updater rejects a binary whose checksum does not match", async ()
|
||||
owner: "Jens",
|
||||
repo: "ForgeFlow",
|
||||
remoteVersion: "0.8.2",
|
||||
remoteSha,
|
||||
}),
|
||||
/SHA-256 verification/,
|
||||
/does not match the signed publisher manifest/,
|
||||
);
|
||||
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([
|
||||
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/);
|
||||
@@ -623,6 +716,10 @@ test("Windows release pipeline preserves optional signing checks and emits prove
|
||||
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/);
|
||||
@@ -630,17 +727,20 @@ test("Windows release pipeline preserves optional signing checks and emits prove
|
||||
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([
|
||||
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/verify-release-signatures.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/write-release-checksums.mjs", 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, /write-release-checksums\.mjs/);
|
||||
assert.match(signatureCheck, /checksum-protected unsigned artifact/);
|
||||
assert.match(checksumWriter, /sha256/);
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user