mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
178 lines
6.0 KiB
TypeScript
178 lines
6.0 KiB
TypeScript
/**
|
|
* Discovery: a bundled id can never be taken by a user plugin, the folder
|
|
* name is the id, nothing a manifest names (or a symlink inside the plugin)
|
|
* reaches outside the plugin folder, table prefixes cannot overlap, and the
|
|
* manifest validator refuses unknown fields, bad ids and reserved ids.
|
|
*/
|
|
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
|
|
import { parseManifest } from "../../../plugins/manifest.js";
|
|
import { RESERVED_PLUGIN_IDS } from "@termix-ssh/plugin-sdk/manifest";
|
|
import { createFixturePlugin } from "../fixture-plugin.js";
|
|
|
|
vi.mock("../../../database/repositories/factory.js", () => ({
|
|
createCurrentPluginStorageRepository: () => ({}),
|
|
}));
|
|
|
|
const cleanups: Array<() => void> = [];
|
|
|
|
function tempRoot(prefix: string): string {
|
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
cleanups.push(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
return root;
|
|
}
|
|
|
|
afterEach(() => {
|
|
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
|
|
delete process.env.DATA_DIR;
|
|
while (cleanups.length) cleanups.pop()?.();
|
|
});
|
|
|
|
function validManifest(overrides: Record<string, unknown> = {}) {
|
|
const fixture = createFixturePlugin();
|
|
cleanups.push(fixture.cleanup);
|
|
const manifest = JSON.parse(
|
|
fs.readFileSync(path.join(fixture.dir, "manifest.json"), "utf8"),
|
|
);
|
|
return { ...manifest, ...overrides };
|
|
}
|
|
|
|
describe("shadowing a bundled plugin", () => {
|
|
it("is refused even when the bundled one failed to load", async () => {
|
|
const bundled = tempRoot("termix-bundled-");
|
|
const user = tempRoot("termix-data-");
|
|
const userPlugins = path.join(user, "plugins");
|
|
fs.mkdirSync(userPlugins, { recursive: true });
|
|
|
|
// The bundled copy is broken, so it never makes it into the loader.
|
|
createFixturePlugin({ id: "ssh-terminal", root: bundled });
|
|
fs.writeFileSync(
|
|
path.join(bundled, "ssh-terminal", "manifest.json"),
|
|
"{ broken",
|
|
);
|
|
createFixturePlugin({ id: "ssh-terminal", root: userPlugins });
|
|
|
|
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
|
|
process.env.DATA_DIR = user;
|
|
const loaded = await new PluginLoader().loadAll();
|
|
|
|
expect(loaded).toEqual([]);
|
|
});
|
|
|
|
it("needs the folder name to be the manifest id", async () => {
|
|
const fixture = createFixturePlugin({
|
|
manifestOverrides: { id: "other-id" },
|
|
});
|
|
cleanups.push(fixture.cleanup);
|
|
await expect(new PluginLoader().load(fixture.dir, "user")).rejects.toThrow(
|
|
/does not match manifest id/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("table prefixes", () => {
|
|
it("overlap when one id's prefix covers the other's tables", () => {
|
|
expect(prefixesOverlap("foo", "foo-bar")).toBe(true);
|
|
expect(prefixesOverlap("foo-bar", "foo")).toBe(true);
|
|
expect(prefixesOverlap("session", "session-sharing")).toBe(true);
|
|
expect(prefixesOverlap("ai", "automations")).toBe(false);
|
|
expect(prefixesOverlap("web-endpoint", "webauthn")).toBe(false);
|
|
expect(prefixesOverlap("foo", "foo")).toBe(false);
|
|
});
|
|
|
|
it("the loader refuses the second plugin of an overlapping pair", async () => {
|
|
const root = tempRoot("termix-overlap-");
|
|
createFixturePlugin({ id: "foo", root });
|
|
createFixturePlugin({ id: "foo-bar", root });
|
|
const loader = new PluginLoader();
|
|
await loader.load(path.join(root, "foo"), "bundled");
|
|
await expect(
|
|
loader.load(path.join(root, "foo-bar"), "user"),
|
|
).rejects.toThrow(/overlaps "foo"/);
|
|
});
|
|
});
|
|
|
|
describe("paths stay inside the plugin", () => {
|
|
it("refuses a backend entry that is a symlink to outside", async () => {
|
|
const root = tempRoot("termix-link-");
|
|
const outside = path.join(root, "outside.mjs");
|
|
fs.writeFileSync(outside, "export function activate() {}");
|
|
const fixture = createFixturePlugin({ id: "linker", root });
|
|
const entry = path.join(fixture.dir, "backend", "index.mjs");
|
|
fs.rmSync(entry);
|
|
try {
|
|
fs.symlinkSync(outside, entry, "file");
|
|
} catch {
|
|
return;
|
|
}
|
|
await expect(new PluginLoader().load(fixture.dir, "user")).rejects.toThrow(
|
|
/outside the plugin directory/,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
["backend", "../../evil.js"],
|
|
["backend", "/etc/evil.js"],
|
|
["backend", "C:/evil.js"],
|
|
["frontend", "dist\\..\\..\\evil.js"],
|
|
["frontend", "."],
|
|
["locales", "../../secrets"],
|
|
])("the validator refuses %s: %s", (field, value) => {
|
|
const { manifest, errors } = parseManifest(
|
|
validManifest({ [field]: value }),
|
|
);
|
|
expect(manifest).toBeUndefined();
|
|
expect(errors.join("\n")).toMatch(/relative path inside the plugin/);
|
|
});
|
|
});
|
|
|
|
describe("manifest validation", () => {
|
|
it.each(RESERVED_PLUGIN_IDS.map((id) => [id]))(
|
|
"refuses the reserved id %s",
|
|
(id) => {
|
|
const { manifest, errors } = parseManifest(validManifest({ id }));
|
|
expect(manifest).toBeUndefined();
|
|
expect(errors.join("\n")).toMatch(/reserved/);
|
|
},
|
|
);
|
|
|
|
it.each([["Bad-Id"], ["1starts-with-digit"], ["has_underscore"], ["a"]])(
|
|
"refuses the bad id %s",
|
|
(id) => {
|
|
expect(parseManifest(validManifest({ id })).manifest).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it("refuses an unknown top-level field", () => {
|
|
const { manifest, errors } = parseManifest(
|
|
validManifest({ runAsRoot: true }),
|
|
);
|
|
expect(manifest).toBeUndefined();
|
|
expect(errors.join("\n")).toMatch(/runAsRoot/);
|
|
});
|
|
|
|
it("refuses an unknown nested field", () => {
|
|
const { manifest } = parseManifest(
|
|
validManifest({ author: { name: "x", backdoor: true } }),
|
|
);
|
|
expect(manifest).toBeUndefined();
|
|
});
|
|
|
|
it("refuses a preset key that is not a plain name", () => {
|
|
const presets = {
|
|
simple: { __proto__x: true },
|
|
balanced: { __proto__x: true },
|
|
advanced: { __proto__x: true },
|
|
};
|
|
const { manifest, errors } = parseManifest(
|
|
validManifest({ contributes: { uiPresets: presets } }),
|
|
);
|
|
expect(manifest).toBeUndefined();
|
|
expect(errors.join("\n")).toMatch(/invalid key/);
|
|
});
|
|
});
|