import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { discoverModules, ModuleDiscoveryError, type ModuleDiscoveryIssueCode, resolveModulesDirectory, } from "../src/modules/index.js"; const versions = { moduleSdkVersion: "0.1.0", platformVersion: "0.1.0" } as const; const repositoryRoot = path.join(path.sep, "opt", "ekklesia"); const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all( temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), ); }); async function temporaryDirectory(): Promise { const directory = await mkdtemp(path.join(tmpdir(), "ekklesia-modules-directory-")); temporaryDirectories.push(directory); return directory; } function captureDiscoveryError(operation: () => unknown): ModuleDiscoveryError { try { operation(); } catch (error) { expect(error).toBeInstanceOf(ModuleDiscoveryError); return error as ModuleDiscoveryError; } throw new Error("Expected a ModuleDiscoveryError."); } async function captureAsyncDiscoveryError( operation: Promise, ): Promise { try { await operation; } catch (error) { expect(error).toBeInstanceOf(ModuleDiscoveryError); return error as ModuleDiscoveryError; } throw new Error("Expected a ModuleDiscoveryError."); } function codes(error: ModuleDiscoveryError): readonly ModuleDiscoveryIssueCode[] { return error.issues.map((issue) => issue.code); } /** Everything an operator, a log, or a test report could ever see from this error. */ function rendered(error: ModuleDiscoveryError): string { return JSON.stringify({ issues: error.issues, message: error.message, stack: error.stack }); } describe("resolveModulesDirectory", () => { it("defaults to the repository's own modules directory when nothing is configured", () => { expect(resolveModulesDirectory({ configured: undefined, repositoryRoot })).toEqual({ modulesDirectory: path.join(repositoryRoot, "modules"), modulesDirectoryRequired: false, }); }); it("accepts an explicit absolute path, normalized, and requires it to exist", () => { expect( resolveModulesDirectory({ configured: "/var/lib/ekklesia/modules/", repositoryRoot }), ).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true }); expect( resolveModulesDirectory({ configured: "/var/lib/./ekklesia//modules", repositoryRoot }), ).toEqual({ modulesDirectory: "/var/lib/ekklesia/modules", modulesDirectoryRequired: true }); }); it("never resolves a configured path against the repository or the working directory", () => { const resolved = resolveModulesDirectory({ configured: "/srv/modules", repositoryRoot }); expect(resolved.modulesDirectory).toBe("/srv/modules"); }); it.each([ ["a relative name", "modules"], ["a dot-relative path", "./modules"], ["a parent-relative path", "../private/modules"], ["a home-relative path", "~/modules"], ["the empty string", ""], ["whitespace only", " "], ["an absolute path with surrounding whitespace", " /var/lib/ekklesia/modules"], ["a NUL byte", "/var/lib/ekklesia/\u0000modules"], ])("rejects %s with a sanitized MODULES_DIRECTORY_INVALID", (_label, configured) => { const error = captureDiscoveryError(() => resolveModulesDirectory({ configured, repositoryRoot }), ); expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]); expect(error.issues[0]?.message).toBe("EKKLESIA_MODULES_DIR must be an absolute path."); }); it("never echoes the supplied value in the error", () => { const configured = "relative/secret-token-a1b2c3/postgresql://admin:hunter2@db/modules"; const error = captureDiscoveryError(() => resolveModulesDirectory({ configured, repositoryRoot }), ); const text = rendered(error); expect(text).not.toContain("secret-token-a1b2c3"); expect(text).not.toContain("hunter2"); expect(text).not.toContain("relative/"); }); }); describe("module discovery against a configured modules directory", () => { it("fails closed, without the path, when a configured directory does not exist", async () => { const parent = await temporaryDirectory(); const missing = path.join(parent, "private-mount-name-7f3e"); const error = await captureAsyncDiscoveryError( discoverModules({ ...versions, modulesDirectory: missing, modulesDirectoryRequired: true }), ); expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]); expect(rendered(error)).not.toContain("private-mount-name-7f3e"); expect(rendered(error)).not.toContain(parent); }); it("fails closed when a configured path is a file rather than a directory", async () => { const parent = await temporaryDirectory(); const file = path.join(parent, "modules"); await writeFile(file, "not a directory"); const error = await captureAsyncDiscoveryError( discoverModules({ ...versions, modulesDirectory: file, modulesDirectoryRequired: true }), ); expect(codes(error)).toEqual(["MODULES_DIRECTORY_UNREADABLE"]); expect(rendered(error)).not.toContain(parent); }); it("rejects a relative configured path even when it bypasses the resolver", async () => { const error = await captureAsyncDiscoveryError( discoverModules({ ...versions, modulesDirectory: "modules", modulesDirectoryRequired: true }), ); expect(codes(error)).toEqual(["MODULES_DIRECTORY_INVALID"]); }); it("treats an existing, empty configured directory as zero installed modules", async () => { const directory = await temporaryDirectory(); await expect( discoverModules({ ...versions, modulesDirectory: directory, modulesDirectoryRequired: true }), ).resolves.toEqual([]); }); it("keeps the development default lenient: a missing default directory is zero modules", async () => { const parent = await temporaryDirectory(); await expect( discoverModules({ ...versions, modulesDirectory: path.join(parent, "modules") }), ).resolves.toEqual([]); }); it("discovers through the resolver's own selection unchanged", async () => { const directory = await temporaryDirectory(); const selection = resolveModulesDirectory({ configured: directory, repositoryRoot }); await expect(discoverModules({ ...versions, ...selection })).resolves.toEqual([]); }); });