mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: load plugin frontends at runtime and remove plugin ids from the shell
This commit is contained in:
1 parent
49f0286a14
commit
e88d5a52e3
721 files changed
+54265
-44416
No files matched your search
@@ -92,7 +92,9 @@ vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({ loader: { list: () => [] } }),
|
||||
getPluginRuntime: () => ({
|
||||
loader: { list: () => [], get: () => undefined },
|
||||
}),
|
||||
activatePlugin: vi.fn(),
|
||||
deactivatePlugin: vi.fn(),
|
||||
}));
|
||||
@@ -180,6 +182,50 @@ describe("plugins route", () => {
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("GET /plugins/public", () => {
|
||||
it("lists only enabled plugins that opt into guest pages", async () => {
|
||||
state.plugins.set(
|
||||
"guest-plugin",
|
||||
makePlugin({
|
||||
id: "guest-plugin",
|
||||
manifestJson: JSON.stringify({
|
||||
capabilities: ["hosts:read"],
|
||||
contributes: { guest: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
state.plugins.set(
|
||||
"off-guest",
|
||||
makePlugin({
|
||||
id: "off-guest",
|
||||
state: "disabled",
|
||||
manifestJson: JSON.stringify({ contributes: { guest: true } }),
|
||||
}),
|
||||
);
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
|
||||
const res = await fetch(`${baseUrl}/plugins/public`);
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(body.map((plugin: { id: string }) => plugin.id)).toEqual([
|
||||
"guest-plugin",
|
||||
]);
|
||||
expect(body[0].contributes).toEqual({ guest: true });
|
||||
expect(body[0]).not.toHaveProperty("capabilities");
|
||||
expect(body[0]).not.toHaveProperty("lastError");
|
||||
});
|
||||
|
||||
it("skips a plugin whose manifest does not parse", async () => {
|
||||
state.plugins.set(
|
||||
"broken",
|
||||
makePlugin({ id: "broken", manifestJson: "{" }),
|
||||
);
|
||||
const res = await fetch(`${baseUrl}/plugins/public`);
|
||||
expect(await res.json()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("lists a plugin's declared and granted capabilities", async () => {
|
||||
state.plugins.set("sample-plugin", makePlugin());
|
||||
state.grants.push({
|
||||
@@ -335,6 +381,12 @@ describe("plugins route", () => {
|
||||
enabled: true,
|
||||
state: "enabled",
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Server } from "node:http";
|
||||
import express from "express";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPluginAssetsRouter,
|
||||
describePluginFrontend,
|
||||
resolvePluginAsset,
|
||||
} from "../../plugins/assets.js";
|
||||
|
||||
let root = "";
|
||||
let plugin: { id: string; dir: string; manifest: never };
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
function write(relative: string, content: string) {
|
||||
const file = path.join(root, "gizmo", relative);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-assets-"));
|
||||
write("dist/frontend.js", "export function activate() {}");
|
||||
write("dist/frontend.css", ".gizmo{}");
|
||||
write("dist/backend.js", "secret()");
|
||||
write("dist/notes.txt", "text");
|
||||
write("locales/en.json", "{}");
|
||||
write("locales/translated/de_DE.json", "{}");
|
||||
write("manifest.json", "{}");
|
||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
||||
plugin = {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
manifest: { id: "gizmo", version: "1.0.0" } as never,
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
});
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("describePluginFrontend", () => {
|
||||
it("reports the bundle, stylesheet, version and locales", () => {
|
||||
const info = describePluginFrontend(plugin);
|
||||
expect(info.frontend).toBe(true);
|
||||
expect(info.css).toBe(true);
|
||||
expect(info.assetVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(info.locales).toEqual(["de_DE", "en"]);
|
||||
});
|
||||
|
||||
it("reports nothing for an unknown plugin", () => {
|
||||
expect(describePluginFrontend(undefined)).toEqual({
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePluginAsset", () => {
|
||||
it("serves the bundle and locale files", () => {
|
||||
expect(resolvePluginAsset(plugin, "frontend.js")).toBe(
|
||||
path.join(root, "gizmo", "dist", "frontend.js"),
|
||||
);
|
||||
expect(resolvePluginAsset(plugin, "locales/translated/de_DE.json")).toBe(
|
||||
path.join(root, "gizmo", "locales", "translated", "de_DE.json"),
|
||||
);
|
||||
});
|
||||
|
||||
it("never serves the backend bundle", () => {
|
||||
expect(resolvePluginAsset(plugin, "backend.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses paths that escape the plugin's directories", () => {
|
||||
expect(resolvePluginAsset(plugin, "../manifest.json")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "../../outside.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "locales/../dist/backend.js")).toBeNull();
|
||||
expect(resolvePluginAsset(plugin, "..\\manifest.json")).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses file types it does not serve", () => {
|
||||
expect(resolvePluginAsset(plugin, "notes.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /plugin-assets", () => {
|
||||
it("serves a bundle as a module any origin can load", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("text/javascript");
|
||||
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(res.headers.get("cache-control")).toBe("no-cache");
|
||||
expect(await res.text()).toContain("activate");
|
||||
});
|
||||
|
||||
it("caches a versioned request for good", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js?v=abc`);
|
||||
expect(res.headers.get("cache-control")).toContain("immutable");
|
||||
});
|
||||
|
||||
it("404s an unknown plugin", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/nobody/frontend.js`);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s the backend bundle and traversal attempts", async () => {
|
||||
for (const target of [
|
||||
"gizmo/backend.js",
|
||||
"gizmo/%2e%2e/manifest.json",
|
||||
"gizmo/..%2f..%2foutside.js",
|
||||
]) {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/${target}`);
|
||||
expect(res.status, target).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -119,11 +119,9 @@ describe("unknown field rejection", () => {
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside contributes", () => {
|
||||
const errors = validateManifest(
|
||||
base({ contributes: { dashboardCards: [] } }),
|
||||
);
|
||||
const errors = validateManifest(base({ contributes: { widgets: [] } }));
|
||||
|
||||
expect(errors.join()).toMatch(/Unknown field "dashboardCards"/);
|
||||
expect(errors.join()).toMatch(/Unknown field "widgets"/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a tab", () => {
|
||||
@@ -557,3 +555,50 @@ describe("tabs and host capabilities", () => {
|
||||
).toMatch(/duplicates/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("panels, dashboard cards and guest pages", () => {
|
||||
it("accepts declared panels, cards and the guest flag", () => {
|
||||
expect(
|
||||
validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "sample-panel", titleKey: "nav.panel" }],
|
||||
dashboardCards: [
|
||||
{ id: "sample-card", titleKey: "cards.sample", icon: "Box" },
|
||||
],
|
||||
guest: true,
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate view ids and missing title keys", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [
|
||||
{ id: "p", titleKey: "a" },
|
||||
{ id: "p", titleKey: "b" },
|
||||
],
|
||||
dashboardCards: [{ id: "c" }],
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/panels\[1\]\.id duplicates "p"/);
|
||||
expect(errors).toMatch(/dashboardCards\[0\]\.titleKey/);
|
||||
});
|
||||
|
||||
it("rejects an unknown field inside a view and a non-boolean guest", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
contributes: {
|
||||
panels: [{ id: "p", titleKey: "a", width: 3 }],
|
||||
guest: "yes",
|
||||
},
|
||||
}),
|
||||
).join();
|
||||
expect(errors).toMatch(/Unknown field "width"/);
|
||||
expect(errors).toMatch(/contributes\.guest" must be a boolean/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user