import { mkdir, rm } from "node:fs/promises";
// src/services/compose-cache.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { HostSecurityError } from "../utils/path-security.js";
import { ComposeProjectCache } from "./compose-cache.js";
describe("ComposeProjectCache", () => {
const testCacheDir = ".cache/test-compose-projects";
let cache: ComposeProjectCache;
beforeEach(async () => {
await mkdir(testCacheDir, { recursive: true });
cache = new ComposeProjectCache(testCacheDir);
});
afterEach(async () => {
await rm(testCacheDir, { recursive: true, force: true });
});
it("should load empty cache for new host", async () => {
const data = await cache.load("test-host");
expect(data).toEqual({
lastScan: expect.any(String),
searchPaths: [],
projects: {},
});
});
it("should save and load cache data", async () => {
// Use fixed timestamps to avoid timing drift between test data creation and comparison
const fixedTimestamp = "2026-01-15T12:00:00.000Z";
const data = {
lastScan: fixedTimestamp,
searchPaths: ["/compose", "/mnt/cache/compose"],
projects: {
plex: {
path: "/mnt/cache/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: fixedTimestamp,
},
},
};
await cache.save("test-host", data);
const loaded = await cache.load("test-host");
expect(loaded).toEqual(data);
});
it("should get project from cache", async () => {
const data = {
lastScan: new Date().toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/mnt/cache/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
},
},
};
await cache.save("test-host", data);
const project = await cache.getProject("test-host", "plex");
expect(project?.path).toBe("/mnt/cache/compose/plex/docker-compose.yaml");
});
it("should return undefined for missing project", async () => {
const project = await cache.getProject("test-host", "missing");
expect(project).toBeUndefined();
});
it("should invalidate stale cache entries based on TTL", async () => {
const staleDate = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25 hours ago
const data = {
lastScan: staleDate.toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/mnt/cache/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: staleDate.toISOString(),
},
},
};
await cache.save("test-host", data);
// Should return undefined for stale entry (default TTL: 24 hours)
const project = await cache.getProject("test-host", "plex");
expect(project).toBeUndefined();
});
it("should return valid cache entries within TTL", async () => {
const recentDate = new Date(Date.now() - 1 * 60 * 60 * 1000); // 1 hour ago
const data = {
lastScan: recentDate.toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/mnt/cache/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: recentDate.toISOString(),
},
},
};
await cache.save("test-host", data);
// Should return entry within TTL
const project = await cache.getProject("test-host", "plex");
expect(project?.path).toBe("/mnt/cache/compose/plex/docker-compose.yaml");
});
it("should update existing project", async () => {
// Save initial project
const initial = {
lastScan: new Date().toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/old/path/docker-compose.yaml",
name: "plex",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
},
},
};
await cache.save("test-host", initial);
// Update project
await cache.updateProject("test-host", "plex", {
path: "/new/path/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
});
// Verify update
const project = await cache.getProject("test-host", "plex");
expect(project?.path).toBe("/new/path/docker-compose.yaml");
expect(project?.discoveredFrom).toBe("docker-ls");
});
it("should add new project via updateProject", async () => {
// Update non-existent project (should add it)
await cache.updateProject("test-host", "sonarr", {
path: "/compose/sonarr/docker-compose.yaml",
name: "sonarr",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
const project = await cache.getProject("test-host", "sonarr");
expect(project?.name).toBe("sonarr");
});
it("should update lastScan timestamp on updateProject", async () => {
const before = Date.now();
await cache.updateProject("test-host", "test", {
path: "/test/docker-compose.yaml",
name: "test",
discoveredFrom: "scan" as const,
lastSeen: new Date().toISOString(),
});
const data = await cache.load("test-host");
const after = new Date(data.lastScan).getTime();
expect(after).toBeGreaterThanOrEqual(before);
});
it("should remove project from cache", async () => {
const data = {
lastScan: new Date().toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "docker-ls" as const,
lastSeen: new Date().toISOString(),
},
},
};
await cache.save("test-host", data);
// Remove project
await cache.removeProject("test-host", "plex");
// Verify removed
const project = await cache.getProject("test-host", "plex");
expect(project).toBeUndefined();
});
it("should handle removing non-existent project gracefully", async () => {
// Should not throw
await cache.removeProject("test-host", "non-existent");
// Cache should still be valid
const data = await cache.load("test-host");
expect(data.projects).toEqual({});
});
describe("Security: Host Validation", () => {
it("should reject path traversal in load()", async () => {
await expect(cache.load("../../../etc")).rejects.toThrow(HostSecurityError);
});
it("should reject path traversal in save()", async () => {
const data = {
lastScan: new Date().toISOString(),
searchPaths: [],
projects: {},
};
await expect(cache.save("../../../etc", data)).rejects.toThrow(HostSecurityError);
});
it("should reject shell metacharacters in host", async () => {
await expect(cache.load("host; rm -rf /")).rejects.toThrow(HostSecurityError);
});
it("should accept valid hostnames", async () => {
const validHosts = ["test-host", "host_123", "host.domain.com", "server-01"];
for (const host of validHosts) {
await expect(cache.load(host)).resolves.toBeDefined();
}
});
});
describe("Runtime Validation", () => {
it("should reject corrupted cache file with invalid schema", async () => {
// Write corrupted cache file directly
const corruptedData = {
lastScan: new Date().toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/compose/plex/docker-compose.yaml",
name: "plex",
// Missing required field: discoveredFrom
lastSeen: new Date().toISOString(),
},
},
};
await mkdir(testCacheDir, { recursive: true });
const { writeFile } = await import("node:fs/promises");
await writeFile(`${testCacheDir}/corrupted-host.json`, JSON.stringify(corruptedData));
await expect(cache.load("corrupted-host")).rejects.toThrow("Cache file validation failed");
});
it("should reject cache file with invalid project type", async () => {
// Write cache file with wrong discoveredFrom value
const invalidData = {
lastScan: new Date().toISOString(),
searchPaths: ["/compose"],
projects: {
plex: {
path: "/compose/plex/docker-compose.yaml",
name: "plex",
discoveredFrom: "invalid-source", // Not in enum
lastSeen: new Date().toISOString(),
},
},
};
await mkdir(testCacheDir, { recursive: true });
const { writeFile } = await import("node:fs/promises");
await writeFile(`${testCacheDir}/invalid-type-host.json`, JSON.stringify(invalidData));
await expect(cache.load("invalid-type-host")).rejects.toThrow("Cache file validation failed");
});
});
});