From c826561c770317a4cd5740d89e97d753f46fee14 Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:40:27 +0200 Subject: [PATCH] test: strengthen safety-critical coverage --- docs/COVERAGE_POLICY.md | 20 ++++ package.json | 2 +- reports/architecture-audit.json | 2 +- reports/architecture-audit.md | 2 +- src/main/config-store.cjs | 2 +- tests/config-store.test.mjs | 167 ++++++++++++++++++++++++++++++ tests/deployment-status.test.mjs | 20 ++++ tests/gitea-actions.test.mjs | 134 ++++++++++++++++++++++++ tests/repository-service.test.mjs | 98 ++++++++++++++++++ tests/ssh-service.test.mjs | 93 ++++++++++++++++- tests/unraid-deployment.test.mjs | 78 ++++++++++++++ 11 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 docs/COVERAGE_POLICY.md create mode 100644 tests/config-store.test.mjs diff --git a/docs/COVERAGE_POLICY.md b/docs/COVERAGE_POLICY.md new file mode 100644 index 0000000..5cec910 --- /dev/null +++ b/docs/COVERAGE_POLICY.md @@ -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. diff --git a/package.json b/package.json index 4056b8e..f552e5c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/reports/architecture-audit.json b/reports/architecture-audit.json index 64a5831..23d4a95 100644 --- a/reports/architecture-audit.json +++ b/reports/architecture-audit.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-29T16:52:29.798Z", + "generatedAt": "2026-07-29T17:40:19.842Z", "thresholds": { "preferredMaximumLines": 750, "justificationRequiredLines": 1000 diff --git a/reports/architecture-audit.md b/reports/architecture-audit.md index 0656a88..71d8957 100644 --- a/reports/architecture-audit.md +++ b/reports/architecture-audit.md @@ -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 diff --git a/src/main/config-store.cjs b/src/main/config-store.cjs index 432735a..f0942c9 100644 --- a/src/main/config-store.cjs +++ b/src/main/config-store.cjs @@ -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 || {}) }, diff --git a/tests/config-store.test.mjs b/tests/config-store.test.mjs new file mode 100644 index 0000000..09fb983 --- /dev/null +++ b/tests/config-store.test.mjs @@ -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"); +}); diff --git a/tests/deployment-status.test.mjs b/tests/deployment-status.test.mjs index 5406fad..cc424e8 100644 --- a/tests/deployment-status.test.mjs +++ b/tests/deployment-status.test.mjs @@ -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); diff --git a/tests/gitea-actions.test.mjs b/tests/gitea-actions.test.mjs index f1be095..dcbb49e 100644 --- a/tests/gitea-actions.test.mjs +++ b/tests/gitea-actions.test.mjs @@ -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 }); +}); diff --git a/tests/repository-service.test.mjs b/tests/repository-service.test.mjs index b59b34c..7cbdd9b 100644 --- a/tests/repository-service.test.mjs +++ b/tests/repository-service.test.mjs @@ -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, ''); +}); diff --git a/tests/ssh-service.test.mjs b/tests/ssh-service.test.mjs index 0ede260..a1bab2b 100644 --- a/tests/ssh-service.test.mjs +++ b/tests/ssh-service.test.mjs @@ -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 }); +}); diff --git a/tests/unraid-deployment.test.mjs b/tests/unraid-deployment.test.mjs index d6578c1..ea56e61 100644 --- a/tests/unraid-deployment.test.mjs +++ b/tests/unraid-deployment.test.mjs @@ -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"); +});