mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
161 lines
5.5 KiB
TypeScript
161 lines
5.5 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("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 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);
|
|
}
|
|
});
|
|
});
|