256 lines
17 KiB
JavaScript
256 lines
17 KiB
JavaScript
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");
|
|
});
|
|
|
|
test("session credentials preserve, replace and clear safely when OS encryption is unavailable", async (t) => {
|
|
const { store } = await storeFixture(t);
|
|
assert.deepEqual(store.setToken(" session-token "), { persistent: false, preserved: false });
|
|
assert.equal(store.getToken(), "session-token");
|
|
assert.deepEqual(store.setToken("", { preserveExisting: true }), { persistent: false, preserved: true });
|
|
assert.equal(store.getToken(), "session-token");
|
|
assert.deepEqual(store.setToken("replacement"), { persistent: false, preserved: false });
|
|
assert.equal(store.getToken(), "replacement");
|
|
assert.deepEqual(store.setToken(""), { persistent: true, preserved: false });
|
|
assert.equal(store.getToken(), "");
|
|
assert.throws(() => store.encryptSecret("password"), (error) => error.code === "SECURE_STORAGE_UNAVAILABLE");
|
|
assert.equal(store.encryptSecret(""), null);
|
|
assert.equal(store.decryptSecret(null), "");
|
|
assert.equal(store.decryptSecret("not-base64-encrypted-data"), "");
|
|
});
|
|
|
|
test("setup, Gitea updates and generic patches retain normalized public state", async (t) => {
|
|
const { store } = await storeFixture(t);
|
|
const completed = await store.completeSetup({
|
|
baseUrl: "https://gitea.test", token: "token", user: { login: "jens" },
|
|
workspaceRoots: [" C:/Projects ", "C:/Projects", ""]
|
|
});
|
|
assert.equal(completed.state.setupComplete, true);
|
|
assert.equal(completed.state.gitea.hasToken, true);
|
|
assert.deepEqual(completed.state.workspaceRoots, ["C:/Projects"]);
|
|
const update = await store.updateGitea({ baseUrl: "https://new.test", token: "", user: null });
|
|
assert.equal(update.preserved, true);
|
|
assert.equal(store.data.gitea.user.login, "jens");
|
|
const patched = await store.patch({ appearance: "light", workspaceRoots: ["D:/Code", "D:/Code"] });
|
|
assert.equal(patched.appearance, "light");
|
|
assert.deepEqual(patched.workspaceRoots, ["D:/Code"]);
|
|
assert.equal("encryptedToken" in patched.gitea, false);
|
|
});
|
|
|
|
test("server saves reject absent credentials before mutating configuration", async (t) => {
|
|
const { store } = await storeFixture(t);
|
|
await assert.rejects(
|
|
store.saveServer({ host: "unraid", username: "root", authType: "password", basePath: "/mnt/apps" }),
|
|
/password is required/i
|
|
);
|
|
await assert.rejects(
|
|
store.saveServer({ host: "unraid", username: "root", authType: "privateKey", basePath: "/mnt/apps", privateKeyPath: "" }),
|
|
/select a private key/i
|
|
);
|
|
assert.deepEqual(store.data.servers, []);
|
|
});
|
|
|
|
test("deployment profile normalization covers safe defaults and every optional Unraid control", async (t) => {
|
|
const { store } = await storeFixture(t);
|
|
const actions = store.normalizeDeploymentProfile({ environment: "qa", statusUrl: "https://app.test/status" });
|
|
assert.equal(actions.provider, "gitea-actions");
|
|
assert.equal(actions.name, "qa");
|
|
assert.equal(actions.branch, "main");
|
|
assert.equal(actions.workflowFile, "deploy.yml");
|
|
assert.equal(actions.rollbackWorkflowFile, "");
|
|
assert.equal(actions.confirmationRequired, true);
|
|
|
|
const unraid = store.normalizeDeploymentProfile({
|
|
id: "all-options", name: " Server ", environment: "production", provider: "ssh-unraid", branch: "release",
|
|
serverId: " server ", remoteFolder: "apps/App", deploymentMode: "monitor-only", generatedCompose: true,
|
|
composeFiles: [], composeServices: ["WEB", "Worker"], composeProject: "App.prod", composeWorkingDir: "/mnt/apps/App",
|
|
containerName: "Visible.App", cloneUrl: "https://gitea.test/Owner/App.git", alignRemote: true,
|
|
hostPort: -2, containerPort: 70000, webUiUrl: "http://[IP]:[PORT:3000]/", iconMode: "upload",
|
|
iconFilePath: "C:/icon.png", dockerShell: "/bin/bash", preservePaths: [], adoptedFromServer: true,
|
|
serverSourceOfTruth: true, manageDockerMan: true, forceRecreate: true, removeOrphans: true,
|
|
workloadIdentity: { workloadId: "one" }, serverGitAccess: { configured: true, deployKeyId: "invalid", keyFingerprint: "", hostFingerprint: "", configuredAt: "now" },
|
|
provenance: { remoteFolder: "server" }, detectedMetadata: { source: "docker" }, serverIconReference: " icon ",
|
|
deploymentPolicy: { frozen: true, freezeReason: " maintenance ", requireNote: true, maintenanceWindows: [{ days: [0, 0, 6, 7, "bad"], start: "01:00", end: "02:00" }] }
|
|
});
|
|
assert.equal(unraid.name, "Server");
|
|
assert.equal(unraid.serverId, "server");
|
|
assert.equal(unraid.composeFile, "docker-compose.yml");
|
|
assert.deepEqual(unraid.composeServices, ["web", "worker"]);
|
|
assert.equal(unraid.hostPort, 1);
|
|
assert.equal(unraid.containerPort, 65535);
|
|
assert.equal(unraid.iconMode, "upload");
|
|
assert.equal(unraid.dockerShell, "/bin/bash");
|
|
assert.equal(unraid.serverGitAccess.deployKeyId, null);
|
|
assert.equal(unraid.serverGitAccess.keyFingerprint, null);
|
|
assert.deepEqual(unraid.deploymentPolicy.maintenanceWindows[0].days, [0, 6]);
|
|
assert.equal(unraid.serverIconReference, "icon");
|
|
|
|
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeService: "app", composeServices: ["bad service"] }), /Compose services/);
|
|
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeProject: "bad project!" }), /Compose project/);
|
|
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", composeWorkingDir: "relative" }), /working directory/);
|
|
assert.throws(() => store.normalizeDeploymentProfile({ provider: "ssh-unraid", remoteFolder: "app", environment: "prod", containerName: "bad name" }), /Container name/);
|
|
});
|