mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
224 lines
7.3 KiB
TypeScript
224 lines
7.3 KiB
TypeScript
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 installed: { 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");
|
|
const unpacked = path.join(root, ".unpacked", "gadget");
|
|
fs.mkdirSync(path.join(unpacked, "dist"), { recursive: true });
|
|
fs.writeFileSync(path.join(unpacked, "dist", "frontend.js"), "export {}");
|
|
installed = {
|
|
id: "gadget",
|
|
dir: unpacked,
|
|
manifest: { id: "gadget", version: "1.0.0" } as never,
|
|
};
|
|
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 : id === "gadget" ? installed : 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("changes the version when only the stylesheet changes", () => {
|
|
const before = describePluginFrontend(plugin).assetVersion;
|
|
const css = path.join(root, "gizmo", "dist", "frontend.css");
|
|
fs.writeFileSync(css, ".gizmo{color:red}");
|
|
try {
|
|
expect(describePluginFrontend(plugin).assetVersion).not.toBe(before);
|
|
} finally {
|
|
fs.writeFileSync(css, ".gizmo{}");
|
|
}
|
|
});
|
|
|
|
it("changes the version when only a translation changes", () => {
|
|
const before = describePluginFrontend(plugin).assetVersion;
|
|
const locale = path.join(
|
|
root,
|
|
"gizmo",
|
|
"locales",
|
|
"translated",
|
|
"de_DE.json",
|
|
);
|
|
fs.writeFileSync(locale, '{"hello":"Hallo"}');
|
|
try {
|
|
expect(describePluginFrontend(plugin).assetVersion).not.toBe(before);
|
|
} finally {
|
|
fs.writeFileSync(locale, "{}");
|
|
}
|
|
});
|
|
|
|
it("reports nothing for an unknown plugin", () => {
|
|
expect(describePluginFrontend(undefined)).toEqual({
|
|
frontend: false,
|
|
css: false,
|
|
assetVersion: null,
|
|
locales: [],
|
|
themeVersion: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
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 an installed plugin unpacked under a dot folder", async () => {
|
|
const res = await fetch(`${baseUrl}/plugin-assets/gadget/frontend.js`);
|
|
expect(res.status).toBe(200);
|
|
expect(await res.text()).toBe("export {}");
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("theme stylesheets", () => {
|
|
let themed: { id: string; dir: string; manifest: never };
|
|
|
|
beforeAll(() => {
|
|
const dir = path.join(root, "inks");
|
|
fs.mkdirSync(path.join(dir, "themes"), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}");
|
|
fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}");
|
|
themed = {
|
|
id: "inks",
|
|
dir,
|
|
manifest: {
|
|
id: "inks",
|
|
version: "1.0.0",
|
|
contributes: {
|
|
themes: [
|
|
{
|
|
id: "paper",
|
|
label: "Paper",
|
|
mode: "light",
|
|
css: "themes/paper.css",
|
|
},
|
|
],
|
|
},
|
|
} as never,
|
|
};
|
|
});
|
|
|
|
it("serves a declared theme's CSS under _themes/<id>.css only", () => {
|
|
expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe(
|
|
path.join(themed.dir, "themes", "paper.css"),
|
|
);
|
|
expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull();
|
|
expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull();
|
|
expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull();
|
|
});
|
|
|
|
it("versions theme files even without a frontend bundle", () => {
|
|
const info = describePluginFrontend(themed);
|
|
expect(info.frontend).toBe(false);
|
|
expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/);
|
|
expect(describePluginFrontend(plugin).themeVersion).toBeNull();
|
|
});
|
|
});
|