test: strengthen safety-critical coverage
This commit is contained in:
@@ -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
@@ -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,5 +1,5 @@
|
||||
{
|
||||
"generatedAt": "2026-07-29T16:52:29.798Z",
|
||||
"generatedAt": "2026-07-29T17:40:19.842Z",
|
||||
"thresholds": {
|
||||
"preferredMaximumLines": 750,
|
||||
"justificationRequiredLines": 1000
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 || {}) },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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, '');
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user