import test from "node:test"; import assert from "node:assert/strict"; import { createRequire } from "node:module"; const require = createRequire(import.meta.url); const { UnraidDeploymentService, safeRemoteFolder, safeRelativeRemoteFile, parseInspection, dockerIgnoreHasPath, checksSummary, xmlEscape, bash, } = require("../src/main/unraid-deployment-service.cjs"); const { fingerprintKey, shellQuote } = require("../src/main/ssh-service.cjs"); test("Unraid remote paths cannot escape appdata project folder", () => { assert.equal(safeRemoteFolder("lumaops"), "lumaops"); assert.throws(() => safeRemoteFolder("../lumaops")); assert.equal( safeRelativeRemoteFile("deploy/docker-compose.yml"), "deploy/docker-compose.yml", ); assert.throws(() => safeRelativeRemoteFile("../../etc/passwd")); }); test("server inspection key-value payload is decoded safely", () => { const b64 = (value) => Buffer.from(value).toString("base64"); const parsed = parseInspection( `noise\n__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=${b64(" M docker-compose.yml\n")}\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, ); assert.equal(parsed.rootGit, true); assert.deepEqual(parsed.composeFiles, ["docker-compose.yml"]); assert.deepEqual(parsed.nestedGit, ["source"]); assert.equal(parsed.trackedChanges.length, 1); assert.match(parsed.dockerignoreContent, /\.git/); assert.deepEqual(parsed.existingPreservePaths, ["data", "logs"]); }); test("Docker ignore checks identify exact runtime and Git context exclusions", () => { const rules = "# build context\n.git\ndata/\nlogs/**\n!logs/keep.txt\n"; assert.equal(dockerIgnoreHasPath(rules, ".git"), true); assert.equal(dockerIgnoreHasPath(rules, "data"), true); assert.equal(dockerIgnoreHasPath(rules, "logs"), true); assert.equal(dockerIgnoreHasPath(rules, "source"), false); }); test("server inspection detects preserved runtime paths and missing Docker context exclusions", async () => { const b64 = (value) => Buffer.from(value).toString("base64"); let receivedCommand = ""; const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "lumaops", preservePaths: ["data", "logs"], }), getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), }; const ssh = { exec: async (_serverId, command) => { receivedCommand = command; return { stdout: `__FORGEFLOW_KV__\nexists=true\nrootGit=true\nhead=${"a".repeat(40)}\nbranch=main\nremote=${b64("ssh://git@gitea/Jens/LumaOps.git")}\ntrackedChanges=\ncomposeFiles=${b64("docker-compose.yml\n")}\nnestedGit=${b64("source\n")}\ndockerfile=true\ndockerignoreContent=${b64(".git\ndata/\n")}\nexistingPreservePaths=${b64("data\nlogs\n")}\n`, stderr: "", exitCode: 0, }; }, }; const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null, }); const inspection = await service.inspect({ repository: { fullName: "Jens/LumaOps", name: "LumaOps" }, profileId: "production", }); assert.match(receivedCommand, /base64 -d \| bash$/); assert.equal(inspection.remotePath, "/mnt/user/appdata/lumaops"); assert.equal(inspection.dockerignoreGitExcluded, true); assert.deepEqual(inspection.existingPreservePaths.sort(), ["data", "logs"]); assert.deepEqual(inspection.dockerContextExclusionsMissing.sort(), [ "logs", "source", ]); }); test("preflight summary blocks only failed checks", () => { const result = checksSummary([ { id: "a", status: "pass" }, { id: "b", status: "warning" }, { id: "c", status: "fail" }, ]); assert.equal(result.ready, false); assert.deepEqual(result.blocking, ["c"]); }); test("SSH helpers produce pinned fingerprints and quoted commands", () => { assert.match(fingerprintKey(Buffer.from("host-key")), /^SHA256:/); assert.equal(shellQuote("a'b"), "'a'\\''b'"); const wrapped = bash("git fetch origin main"); assert.match(wrapped, /base64 -d \| bash$/); assert.equal(wrapped.includes("\n"), false); const encoded = wrapped.match(/printf '%s' '([A-Za-z0-9+/=]+)'/)[1]; const decoded = Buffer.from(encoded, "base64").toString("utf8"); assert.match(decoded, /GIT_TERMINAL_PROMPT=0/); assert.match(decoded, /BatchMode=yes/); assert.match(decoded, /git fetch origin main/); }); test("SSH rollback refuses any SHA other than the exact recorded previous deployment", async () => { const previousSha = "a".repeat(40); const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "lumaops", composeFile: "docker-compose.yml", branch: "main", environment: "production", }), getServer: () => ({ id: "unraid", basePath: "/mnt/user/appdata" }), getDeploymentState: () => ({ liveSha: "b".repeat(40), previousSha }), }; const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null, }); await assert.rejects( service.rollback({ repository: { fullName: "Jens/LumaOps", name: "LumaOps", localPath: "/tmp/lumaops", }, profileId: "production", targetSha: "c".repeat(40), }), (error) => error.code === "ROLLBACK_TARGET_NOT_PREVIOUS_SHA", ); }); test("successful SSH rollback records the formerly live SHA as the new rollback target", async () => { const previousSha = "a".repeat(40); const liveSha = "b".repeat(40); const savedStates = []; const operations = []; const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "lumaops", composeFile: "docker-compose.yml", branch: "main", environment: "production", healthcheckUrl: "", iconMode: "none", }), getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), getDeploymentState: () => ({ liveSha, previousSha }), addOperation: async (operation) => { operations.push(operation); return operation; }, saveDeploymentState: async (_profileId, state) => { savedStates.push(state); return state; }, }; const git = { verifyCommitOnRemoteBranch: async () => true }; const ssh = { exec: async () => ({ stdout: "", stderr: "", exitCode: 0 }) }; const service = new UnraidDeploymentService({ store, ssh, git, diagnostics: null, }); service.inspect = async () => ({ rootGit: true, trackedChanges: [], head: liveSha, }); service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null, }); const result = await service.rollback({ repository: { fullName: "Jens/LumaOps", name: "LumaOps", localPath: "/tmp/lumaops", }, profileId: "production", targetSha: previousSha, }); assert.equal(result.status, "rolled-back"); assert.equal(savedStates.at(-1).liveSha, previousSha); assert.equal(savedStates.at(-1).previousSha, liveSha); assert.equal(operations.at(-1).previousSha, liveSha); }); test("generated Compose uses a lowercase-safe service while preserving the visible Portfolio container name", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const compose = service.generatedCompose( { composeService: "Portfolio", hostPort: 5150, containerPort: 80 }, { name: "Portfolio" }, ); assert.match(compose, / portfolio:/); assert.match(compose, /image: forgeflow\/portfolio:production/); assert.match(compose, /container_name: Portfolio/); }); test("SSH deployment dispatch returns a running operation while the remote build continues in background", async () => { const sha = "d".repeat(40); const operations = []; let resolveRemote; const store = { getDeploymentProfile: () => ({ id: "production", provider: "ssh-unraid", serverId: "unraid", remoteFolder: "Portfolio", cloneUrl: "forgeflow-gitea:Jens/Portfolio.git", composeFile: "docker-compose.yml", branch: "main", environment: "production", healthcheckUrl: "", iconMode: "none", }), getServer: () => ({ id: "unraid", name: "Unraid", basePath: "/mnt/user/appdata", }), addOperation: async (operation) => { operations.push(structuredClone(operation)); return structuredClone(operation); }, saveDeploymentState: async () => ({}), }; const ssh = { exec: async () => new Promise((resolve) => { resolveRemote = resolve; }), }; const service = new UnraidDeploymentService({ store, ssh, git: {}, diagnostics: null, }); service.preflight = async () => ({ summary: { ready: true, blocking: [] }, inspection: { head: null }, }); service.checkHealth = async () => ({ configured: false, healthy: null, status: null, latencyMs: null, }); const operation = await service.deploy({ repository: { fullName: "Jens/Portfolio", name: "Portfolio" }, profileId: "production", sha, }); assert.equal(operation.status, "running"); assert.match(operation.logs.join("\n"), /background/i); resolveRemote({ stdout: "Container Portfolio started\n", stderr: "", exitCode: 0, }); await new Promise((resolve) => setTimeout(resolve, 10)); assert.equal(operations.at(-1).status, "success"); }); test("Unraid preflight verifies repository access before a deployment can start", async () => { const source = await import("node:fs/promises").then(({ readFile }) => readFile( new URL("../src/main/unraid-deployment-service.cjs", import.meta.url), "utf8", ), ); assert.match(source, /server-git-access/); assert.match(source, /git ls-remote --exit-code/); assert.match(source, /Unraid → Gitea access/); }); test("DockerMan metadata uses dockerman labels, a template WebUI and lowercase-safe service/image names", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const metadata = service.metadataCompose( { composeService: "portfolio", containerName: "Portfolio", remoteFolder: "Portfolio", environment: "production", hostPort: 5150, webUiUrl: "http://192.168.10.150:5150/admin", dockerShell: "/bin/sh", }, { name: "Portfolio" }, "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); assert.match(metadata, / portfolio:/); assert.match(metadata, /image: forgeflow\/portfolio:production/); assert.match(metadata, /container_name: Portfolio/); assert.match(metadata, /net\.unraid\.docker\.managed.*dockerman/); assert.match( metadata, /net\.unraid\.docker\.webui.*http:\/\/\[IP\]:\[PORT:5150\]\/admin/, ); assert.match( metadata, /net\.unraid\.docker\.icon.*file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png/, ); }); test("DockerMan integration writes a persistent template fallback and invalidates cached metadata", () => { const service = new UnraidDeploymentService({ store: {}, ssh: {}, git: {}, diagnostics: null, }); const profile = { composeService: "portfolio", containerName: "Portfolio", remoteFolder: "Portfolio", environment: "production", hostPort: 5150, webUiUrl: "http://192.168.10.150:5150/", dockerShell: "/bin/sh", }; const repository = { name: "Portfolio" }; const icon = "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png"; const template = service.dockerManTemplate(profile, repository, icon); const refresh = service.dockerManRefreshScript(profile, repository, icon); assert.match(template, /Portfolio<\/Name>/); assert.match( template, /forgeflow\/portfolio:production<\/Repository>/, ); assert.match(template, /http:\/\/\[IP\]:\[PORT:5150\]\/<\/WebUI>/); assert.match( template, /file:\/\/\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png<\/Icon>/, ); assert.match(refresh, /templates-user\/my-Portfolio\.xml/); assert.match(refresh, /dynamix\.docker\.manager\/docker\.json/); assert.match( refresh, /cp '\/boot\/config\/plugins\/dockerMan\/images\/Portfolio-icon\.png'/, ); assert.doesNotMatch(refresh, /dockerManRefreshScript/); assert.equal(xmlEscape('A&B<"x">'), "A&B<"x">"); }); test("built-in ITWorx DockerMan icon is uploaded to persistent Unraid storage", async (t) => { const { mkdtemp, mkdir, writeFile, rm } = await import("node:fs/promises"); const os = await import("node:os"); const path = await import("node:path"); const sourcePath = await mkdtemp(path.join(os.tmpdir(), "forgeflow-icon-")); t.after(() => rm(sourcePath, { recursive: true, force: true })); const asset = path.join( sourcePath, "src", "renderer", "assets", "itworx-mark.png", ); await mkdir(path.dirname(asset), { recursive: true }); await writeFile(asset, Buffer.from([137, 80, 78, 71])); const uploads = []; const service = new UnraidDeploymentService({ store: {}, git: {}, diagnostics: null, sourcePath, ssh: { uploadFile: async (...args) => { uploads.push(args); return {}; }, }, }); const icon = await service.prepareIcon( { iconMode: "builtin", remoteFolder: "Portfolio" }, { name: "Portfolio" }, { id: "unraid" }, ); assert.equal( icon, "file:///boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); assert.equal(uploads.length, 1); assert.equal(uploads[0][0], "unraid"); assert.equal(uploads[0][1], asset); assert.equal( uploads[0][2], "/boot/config/plugins/dockerMan/images/Portfolio-icon.png", ); }); test("stuck SSH deployment is reconciled to success when exact SHA and container health are live", async () => { const sha = "f".repeat(40); const saved = []; const operation = { id: "op-1", type: "deployment", provider: "ssh-unraid", action: "deploy", repository: "Jens/Portfolio", profileId: "production", sha, status: "running", logs: [], }; const store = { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; }, }; const service = new UnraidDeploymentService({ store, ssh: {}, git: {}, diagnostics: null, }); service.refreshProfileState = async () => ({ liveSha: sha, containerRunning: true, healthy: true, }); const result = await service.refreshOperation("op-1"); assert.equal(result.status, "success"); assert.match(result.logs.at(-1), /reconciled/i); assert.equal(saved.at(-1).status, "success"); }); test("DockerMan metadata repair refreshes known Unraid icon caches after container recreation", async () => { const source = await import("node:fs/promises").then(({ readFile }) => readFile( new URL("../src/main/unraid-deployment-service.cjs", import.meta.url), "utf8", ), ); assert.match(source, /\/var\/lib\/docker\/unraid\/images/); assert.match(source, /dynamix\.docker\.manager\/images/); assert.match(source, /-icon\.png/); assert.match(source, /cp \${shellQuote\(localIconPath\)}/); assert.match(source, /--force-recreate/); }); test("stuck deployment is cleared as superseded when a different healthy commit is already live", async () => { const requested = "a".repeat(40); const live = "b".repeat(40); const operation = { id: "op-superseded", type: "deployment", provider: "ssh-unraid", action: "deploy", repository: "Jens/Portfolio", profileId: "production", sha: requested, status: "running", logs: [], }; const saved = []; const service = new UnraidDeploymentService({ store: { getOperation: () => operation, addOperation: async (next) => { saved.push(next); return next; }, }, ssh: {}, git: {}, diagnostics: null, }); service.refreshProfileState = async () => ({ liveSha: live, containerRunning: true, healthy: true, }); const result = await service.refreshOperation(operation.id); assert.equal(result.status, "cancelled"); assert.match(result.error, /Superseded/); assert.equal(saved.at(-1).status, "cancelled"); }); test("existing Unraid deployment discovery derives profile values from Docker, Compose and DockerMan truth", () => { const { deriveDetectedProfile, } = require("../src/main/unraid-deployment-service.cjs"); const result = deriveDetectedProfile({ repository: { name: "blockpilot-autonomous", defaultBranch: "main", sshUrl: "ssh://git@gitea/Jens/blockpilot-autonomous.git", }, server: { id: "unraid", host: "192.168.10.150" }, remoteFolder: "blockpilot-autonomous", remotePath: "/mnt/user/appdata/blockpilot-autonomous", payload: { head: "a".repeat(40), branch: "main", remote: "ssh://git@gitea/Jens/blockpilot-autonomous.git", composeFiles: ["compose.yml"], compose: { services: { app: { image: "blockpilot:test" } } }, containers: [ { Name: "/blockpilot", State: { Running: true }, Config: { Image: "blockpilot:test", Env: ["TOKEN=secret", "MODE=prod"], Labels: { "com.docker.compose.service": "app", "com.docker.compose.project": "blockpilot", }, }, HostConfig: { RestartPolicy: { Name: "unless-stopped" } }, NetworkSettings: { Ports: { "8080/tcp": [{ HostIp: "0.0.0.0", HostPort: "1223" }] }, Networks: { bridge: {} }, }, Mounts: [ { Type: "bind", Source: "/mnt/user/appdata/blockpilot-autonomous/data", Destination: "/data", RW: true, }, ], }, ], dockerManXml: "blockpilothttp://[IP]:[PORT:1223]/https://example.test/icon.png/bin/bash", }, }); assert.equal(result.profile.hostPort, 1223); assert.equal(result.profile.containerPort, 8080); assert.equal(result.profile.containerName, "blockpilot"); assert.equal(result.profile.composeService, "app"); assert.equal(result.profile.webUiUrl, "http://[IP]:[PORT:1223]/"); assert.equal(result.profile.iconUrl, "https://example.test/icon.png"); assert.equal(result.profile.dockerShell, "/bin/bash"); assert.deepEqual(result.profile.detectedMetadata.envNames, ["TOKEN", "MODE"]); assert.ok(result.profile.preservePaths.includes("data")); assert.equal(result.provenance.hostPort.origin, "docker-inspect"); }); test("a previously failed deployment is corrected when its exact commit is healthy on Unraid", async () => { const sha = "e".repeat(40); const failed = { id: "failed-1", type: "deployment", provider: "ssh-unraid", action: "deploy", profileId: "production", sha, status: "failed", logs: [], }; const operations = [failed]; const service = new UnraidDeploymentService({ store: { data: { operations }, getOperation: (id) => operations.find((item) => item.id === id), addOperation: async (next) => { operations.splice( operations.findIndex((item) => item.id === next.id), 1, next, ); return next; }, }, ssh: {}, git: {}, diagnostics: null, }); await service.reconcileRecordedOperations("production", { liveSha: sha, matchesGitea: true, containerRunning: true, healthy: true, }); assert.equal(operations[0].status, "success"); assert.match(operations[0].logs.at(-1), /reconciled/i); }); test("a failed deployment is marked superseded when Gitea and Unraid agree on a newer commit", async () => { const liveSha = "d".repeat(40); const operations = [ { id: "failed-2", type: "deployment", provider: "ssh-unraid", profileId: "production", sha: "c".repeat(40), status: "failed", logs: [], }, ]; const service = new UnraidDeploymentService({ store: { data: { operations }, getOperation: (id) => operations.find((item) => item.id === id), addOperation: async (next) => { operations.splice( operations.findIndex((item) => item.id === next.id), 1, next, ); return next; }, }, ssh: {}, git: {}, diagnostics: null, }); await service.reconcileRecordedOperations("production", { liveSha, matchesGitea: true, containerRunning: true, healthy: true, }); assert.equal(operations[0].status, "cancelled"); assert.match(operations[0].error, /Superseded/); });