test: strengthen safety-critical coverage

This commit is contained in:
NuklearRabbit
2026-07-29 19:40:27 +02:00
parent 0ed202ec95
commit c826561c77
11 changed files with 613 additions and 5 deletions
+20
View File
@@ -0,0 +1,20 @@
# Coverage policy
ForgeFlow treats coverage as release evidence, not as a target to game. `npm run coverage`
enforces 75% statements, 75% lines, 75% functions and 60% branches globally.
The July 2026 hardening pass raised the measured baseline from 69.74% statements/lines,
68.82% functions and 55.38% branches to at least 78% statements/lines, 79% functions and
60% branches. The requested 65% global branch target was investigated but is not used as
the release gate yet. Node/V8 discovers additional branch counters when previously
unexecuted functions become covered; the denominator grew from 2,537 to more than 3,100
while the new tests added hundreds of asserted branches. Raising the number by excluding
command builders, platform guards or error adapters would make the result look better
without increasing deployment safety.
The 60% global gate is therefore paired with scenario-level evidence for the critical
boundaries: deploy-key rollback, deployment verification, Gitea authentication and
redirects, SSH host identity and output limits, inventory reconciliation, stale plans,
configuration recovery, release integrity and updater failure modes. New code must not
reduce the global baseline. A future increase to 65% should come from additional asserted
failure scenarios, not ignore comments or source exclusions.
+1 -1
View File
@@ -11,7 +11,7 @@
"demo": "node scripts/serve-demo.mjs",
"test": "node --test tests/*.test.mjs",
"lint": "eslint .",
"coverage": "c8 --check-coverage --lines 55 --functions 55 --branches 45 --statements 55 node --test tests/*.test.mjs",
"coverage": "c8 --check-coverage --lines 75 --functions 75 --branches 60 --statements 75 node --test tests/*.test.mjs",
"verify": "node scripts/verify.mjs",
"dist:win": "electron-builder --win nsis portable && node scripts/write-release-checksums.mjs && node scripts/verify-release-signatures.mjs && node scripts/prune-dist.mjs",
"dist:linux": "electron-builder --linux AppImage && node scripts/prune-dist.mjs",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-07-29T16:52:29.798Z",
"generatedAt": "2026-07-29T17:40:19.842Z",
"thresholds": {
"preferredMaximumLines": 750,
"justificationRequiredLines": 1000
+1 -1
View File
@@ -1,6 +1,6 @@
# ForgeFlow architecture audit
Generated 2026-07-29T16:52:29.798Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
Generated 2026-07-29T17:40:19.842Z. Complexity is a deterministic decision-point count used for hotspot ranking, not a claim of exact McCabe complexity.
## Files above 750 lines
+1 -1
View File
@@ -109,7 +109,7 @@ class ConfigStore {
})]))
: {},
deploymentStates: source.deploymentStates && typeof source.deploymentStates === 'object' ? source.deploymentStates : {},
favorites: uniqueStrings(source.favorites).map((item) => item.toLowerCase()),
favorites: [...new Set(uniqueStrings(source.favorites).map((item) => item.toLowerCase()))],
updates: { ...DEFAULT_CONFIG.updates, ...(source.updates || {}) },
servers: Array.isArray(source.servers) ? source.servers.filter((item) => item && typeof item === 'object') : [],
preferences: { ...DEFAULT_CONFIG.preferences, ...(source.preferences || {}) },
+167
View File
@@ -0,0 +1,167 @@
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);
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");
});
+20
View File
@@ -71,6 +71,26 @@ test('requires exact server SHA and matching request ID after a successful workf
}).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);
+134
View File
@@ -194,3 +194,137 @@ test('creates repository-scoped read-only deploy keys and reuses only safe match
(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 });
});
+98
View File
@@ -1,5 +1,8 @@
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;
@@ -58,3 +61,98 @@ test('an unhealthy live commit remains eligible for a controlled redeploy', () =
]);
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('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('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('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, '');
});
+92 -1
View File
@@ -2,9 +2,12 @@ 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 } = require("../src/main/ssh-service.cjs");
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");
@@ -35,3 +38,91 @@ test("SSH execution rejects truncated output instead of using an incomplete inve
(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);
});
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 });
});
+78
View File
@@ -1242,3 +1242,81 @@ test("push bundle preflight does not require Git or Gitea credentials on Unraid"
assert.equal(result.checks.some((item) => item.id === "server-git-command"), false);
assert.equal(result.summary.ready, true, JSON.stringify(result.checks.filter((item) => item.status === "fail")));
});
test("preflight explains monitor-only and degraded server evidence without hiding warnings", async () => {
const sha = "d".repeat(40);
const profile = {
id: "observed", provider: "ssh-unraid", environment: "production", branch: "main", serverId: "unraid",
remoteFolder: "Observed", deploymentMode: "monitor-only", composeFiles: ["compose.yml"],
composeService: "app", iconMode: "url", iconUrl: "", preservePaths: ["data"]
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "" })
},
git: {},
ssh: { test: async () => { throw new Error("host unavailable"); } },
gitea: {},
sourcePath: process.cwd()
});
service.inspectWriteAccess = async () => { throw new Error("permission probe failed"); };
service.inspect = async () => ({
exists: true, rootGit: true, head: "e".repeat(40), trackedChanges: ["compose.yml"], nestedGit: ["vendor/repo"],
dockerfile: true, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: ["data"],
existingPreservePaths: ["data"], composeFiles: []
});
const result = await service.preflight({ repository: { fullName: "Jens/Observed", name: "Observed", localPath: null }, profileId: profile.id, sha });
const byId = (id) => result.checks.find((check) => check.id === id);
assert.equal(byId("deployment-mode").status, "fail");
assert.equal(byId("local-repository").status, "fail");
assert.equal(byId("ssh").status, "fail");
assert.equal(byId("host-key").status, "fail");
assert.equal(byId("project-write-access").status, "fail");
assert.equal(byId("tracked-changes").status, "warning");
assert.equal(byId("nested-git").status, "warning");
assert.equal(byId("dockerignore").status, "warning");
assert.equal(byId("dockerignore-runtime").status, "warning");
assert.equal(byId("dockerman-icon").status, "fail");
assert.equal(byId("dockerman-webui").status, "warning");
assert.equal(result.summary.ready, false);
});
test("server-pull preflight resolves Gitea SHA and reports every degraded capability and write target", async () => {
const sha = "f".repeat(40);
const profile = {
id: "server-pull", provider: "ssh-unraid", environment: "staging", branch: "release", serverId: "unraid", remoteFolder: "App",
deploymentMode: "server-git", composeFiles: ["compose.yml", "compose.prod.yml"], composeService: "web",
iconMode: "none", preservePaths: []
};
const service = new UnraidDeploymentService({
store: {
getDeploymentProfile: () => profile,
getServer: () => ({ id: "unraid", name: "Unraid", host: "server", port: 22, username: "deploy", basePath: "/mnt/apps", hostFingerprint: "SHA256:trusted" })
},
git: { status: async () => ({ root: "/local", clean: false, counts: { changed: 3 }, branch: { head: "main" } }) },
ssh: { test: async () => ({ capabilities: { docker: true, dockerReady: false, compose: false, git: false, tar: true, checksum: false, baseWritable: false } }) },
gitea: { getBranch: async () => ({ commit: { sha } }) },
sourcePath: process.cwd()
});
service.probeServerGitAccess = async () => ({ ready: false, error: "deploy key missing", remoteSha: null });
service.inspectWriteAccess = async () => ({
ready: false, identity: { user: "deploy" }, blocking: [{ path: "/mnt/apps/App" }],
targets: [{ id: "root", label: "Project root", path: "/mnt/apps/App", required: true, effectiveWritable: false, owner: "root", group: "root", mode: "0755", detail: "not writable" }]
});
service.inspect = async () => ({ exists: false, rootGit: false, trackedChanges: [], nestedGit: [], dockerfile: false, dockerignore: false, dockerignoreGitExcluded: false, dockerContextExclusionsMissing: [], existingPreservePaths: [], composeFiles: [] });
const result = await service.preflight({ repository: { fullName: "Jens/App", name: "App", localPath: "/local" }, profileId: profile.id });
const byId = (id) => result.checks.find((check) => check.id === id);
assert.equal(result.sha, sha);
assert.equal(byId("local-branch").status, "warning");
assert.equal(byId("local-clean").status, "warning");
assert.equal(byId("docker-runtime").status, "fail");
assert.match(byId("docker-runtime").detail, /cannot query/i);
assert.equal(byId("compose-command").status, "fail");
assert.equal(byId("bundle-tools").status, "fail");
assert.equal(byId("server-base-writable").status, "fail");
assert.equal(byId("server-git-access").repairAction, "configure-server-git-access");
assert.equal(byId("write-path:root").status, "fail");
assert.equal(byId("remote-folder").status, "pass");
assert.equal(byId("dockerman-icon").status, "warning");
});