Files
ForgeFlow/tests/inventory-classifier.test.mjs
T

122 lines
9.0 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { classifyInventory } = require("../src/main/inventory-classifier.cjs");
const { deploymentIdentity, deploymentEvidenceHash, deploymentAuthorityKey } = require("../src/main/deployment-identity.cjs");
const { InventoryReviewService } = require("../src/main/inventory-review-service.cjs");
function workload(id, options = {}) {
return {
workloadId: id, serverId: options.serverId || "unraid", displayName: options.name || id,
status: options.status || (options.link ? "linked" : "suggested"), link: options.link || null,
compose: { project: options.project || id, workingDir: options.root || `/mnt/user/appdata/${id}`, configFiles: options.files || [`/mnt/user/appdata/${id}/compose.yml`], services: ["app"] },
containers: options.noContainers ? [] : [{ id: `container-${id}`, name: id, running: options.running !== false }],
runtime: { running: options.running !== false, health: options.health || "healthy" },
metadata: { sourceRepository: options.remote ?? "git@gitea.test:Jens/Portfolio.git", liveRevision: options.sha || "a".repeat(40), branch: options.branch || "main" },
candidates: options.candidates || [{ repositoryFullName: "Jens/Portfolio", score: 100, exact: true }],
remoteFolderCandidate: id,
};
}
test("deployment identity is canonical across SSH and HTTPS remotes", () => {
const ssh = deploymentIdentity({ workload: workload("one") });
const https = deploymentIdentity({ workload: workload("two", { remote: "https://gitea.test/Jens/Portfolio.git" }) });
assert.equal(ssh.repository, https.repository);
assert.equal(deploymentAuthorityKey(ssh), deploymentAuthorityKey({ ...https, serverId: ssh.serverId, environment: ssh.environment }));
});
test("running duplicate is authoritative and historical folder is never linked", () => {
const active = workload("portfolio-current", { link: { profileId: "profile", repositoryFullName: "Jens/Portfolio" } });
const historical = workload("portfolio-old", { running: false, root: "/mnt/user/appdata/portfolio/releases/old", link: { profileId: "profile-old", repositoryFullName: "Jens/Portfolio" } });
const result = classifyInventory([historical, active], [{ id: "profile", environment: "production" }, { id: "profile-old", environment: "production" }]);
assert.equal(result.find((item) => item.workloadId === "portfolio-current").authoritative, true);
assert.equal(result.find((item) => item.workloadId === "portfolio-old").classification.type, "duplicate");
assert.equal(result.find((item) => item.workloadId === "portfolio-old").link, null);
});
for (const [label, item, expected] of [
["backup Compose folder", workload("backup", { root: "/mnt/user/appdata/portfolio-backup", running: false }), "backup"],
["release directory", workload("release", { root: "/mnt/user/appdata/portfolio/releases/a1", running: false }), "release-folder"],
["staging workload", workload("stage", { root: "/mnt/user/appdata/portfolio-staging" }), "staging"],
["stopped legitimate app", workload("stopped", { running: false }), "stopped-application"],
["Compose without container", workload("historical", { noContainers: true, running: false }), "historical-compose"],
["container without repository", workload("orphan", { remote: "", candidates: [], status: "unmatched" }), "orphan-container"],
["system container", workload("infra", { name: "watchtower", remote: "", candidates: [] }), "system-container"],
["ambiguous exact matches", workload("ambiguous", { status: "ambiguous", candidates: [{ repositoryFullName: "Jens/A", score: 100, exact: true }, { repositoryFullName: "Jens/B", score: 100, exact: true }] }), "ambiguous"],
["profile whose server workload disappeared", { ...workload("stale", { running: false, noContainers: true, link: { profileId: "profile-stale", repositoryFullName: "Jens/Portfolio" } }), metadata: { sourceRepository: "git@gitea.test:Jens/Portfolio.git", branch: "main", staleLink: true } }, "stale-link"],
]) test(`inventory classifies ${label}`, () => {
assert.equal(classifyInventory([item])[0].classification.type, expected);
});
test("multi-instance environments remain separate authority groups", () => {
const a = workload("instance-a", { link: { profileId: "a", repositoryFullName: "Jens/Portfolio" } });
const b = workload("instance-b", { link: { profileId: "b", repositoryFullName: "Jens/Portfolio" } });
const result = classifyInventory([a, b], [{ id: "a", environment: "production" }, { id: "b", environment: "staging" }]);
assert.equal(result.filter((item) => item.classification.type === "duplicate").length, 0);
});
test("stored review decision becomes stale when remote evidence changes", () => {
const original = classifyInventory([workload("review")])[0];
const decision = { workloadId: original.workloadId, evidenceHash: original.evidenceHash, action: "ignore", reason: "Known external workload" };
const unchanged = classifyInventory([workload("review")], [], [decision])[0];
const changed = classifyInventory([workload("review", { remote: "git@gitea.test:Jens/Renamed.git" })], [], [decision])[0];
assert.equal(unchanged.reviewDecision.action, "ignore");
assert.equal(changed.reviewDecision, null);
assert.equal(changed.reviewDecisionStale, true);
});
test("review decisions drive classification and explicit authority", () => {
const primary = classifyInventory([workload("primary")])[0];
const secondary = classifyInventory([workload("secondary")])[0];
const decisions = [
{ workloadId: primary.workloadId, evidenceHash: primary.evidenceHash, action: "mark-historical", reason: "Retained rollback definition" },
{ workloadId: secondary.workloadId, evidenceHash: secondary.evidenceHash, action: "select-authoritative", reason: "Verified production instance" },
];
const result = classifyInventory([workload("primary"), workload("secondary")], [], decisions);
assert.equal(result.find((item) => item.workloadId === "primary").classification.type, "duplicate");
assert.equal(result.find((item) => item.workloadId === "secondary").authoritative, true);
});
test("ignore and monitor-only decisions stay evidence-bound", () => {
const ignored = classifyInventory([workload("ignored")])[0];
const monitoredSource = workload("monitored", { remote: "git@gitea.test:Jens/Monitored.git", candidates: [{ repositoryFullName: "Jens/Monitored", score: 100, exact: true }] });
const monitored = classifyInventory([monitoredSource])[0];
const result = classifyInventory([workload("ignored"), monitoredSource], [], [
{ workloadId: ignored.workloadId, evidenceHash: ignored.evidenceHash, action: "ignore", reason: "Managed by another platform" },
{ workloadId: monitored.workloadId, evidenceHash: monitored.evidenceHash, action: "monitor-only", reason: "Visibility without deployment ownership" },
]);
assert.equal(result.find((item) => item.workloadId === "ignored").classification.type, "manually-excluded");
assert.equal(result.find((item) => item.workloadId === "monitored").classification.type, "monitor-only");
});
test("review service requires reason, exact plan and recovery snapshot", async () => {
const decisions = [];
const store = { getInventoryReviewDecisions: () => decisions, createRecoverySnapshot: async () => ({ filePath: "snapshot.json" }), saveInventoryReviewDecision: async (_server, decision) => { decisions.push(decision); return decision; } };
const service = new InventoryReviewService({ store });
const item = classifyInventory([workload("review")])[0];
assert.throws(() => service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "no" }), (error) => error.code === "INVENTORY_REVIEW_REASON_REQUIRED");
const plan = service.preview({ serverId: "unraid", workload: item, action: "ignore", reason: "Managed outside ForgeFlow" });
await assert.rejects(service.apply({ plan }), (error) => error.code === "INVENTORY_REVIEW_PLAN_REQUIRED");
const result = await service.apply({ plan, expectedPlanId: plan.id });
assert.equal(result.snapshot.filePath, "snapshot.json");
assert.equal(decisions[0].evidenceHash, item.evidenceHash);
});
test("large inventory classification is deterministic and bounded", () => {
const input = Array.from({ length: 1200 }, (_, index) => workload(`app-${index}`, { remote: `git@gitea.test:Jens/App-${index}.git`, candidates: [{ repositoryFullName: `Jens/App-${index}`, score: 100, exact: true }] }));
const started = Date.now();
const result = classifyInventory(input);
assert.equal(result.length, 1200);
assert.ok(Date.now() - started < 2000);
assert.equal(new Set(result.map((item) => item.evidenceHash)).size, 1200);
});
test("evidence hash changes for runtime, Compose and candidate changes", () => {
const identity = deploymentIdentity({ workload: workload("hash") });
const one = deploymentEvidenceHash(identity, { running: true, files: ["compose.yml"] });
const two = deploymentEvidenceHash(identity, { running: false, files: ["compose.yml"] });
assert.notEqual(one, two);
});