mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
1207 lines
35 KiB
TypeScript
1207 lines
35 KiB
TypeScript
/**
|
|
* Manifest v2 validation.
|
|
*
|
|
* The rules live in the SDK so the server, the authoring script and plugin
|
|
* authors share one implementation; these tests drive it through the server's
|
|
* re-export, which is the path the loader actually takes.
|
|
*/
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
parseManifest,
|
|
validateManifest,
|
|
SUPPORTED_PLUGIN_API_VERSION,
|
|
isTermixCompatible,
|
|
youtubeVideoId,
|
|
pluginFeatures,
|
|
pluginDocsUrl,
|
|
pluginEnvVars,
|
|
} from "../../plugins/manifest.js";
|
|
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
|
|
|
|
function base(overrides: Record<string, unknown> = {}) {
|
|
return {
|
|
id: "sample-plugin",
|
|
name: "Sample Plugin",
|
|
version: "1.0.0",
|
|
description: "A fixture.",
|
|
author: { name: "Termix Tests" },
|
|
license: "MIT",
|
|
category: "Productivity",
|
|
engine: { termix: ">=2.9.0", api: SUPPORTED_PLUGIN_API_VERSION },
|
|
capabilities: ["kv:own"],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("manifest v2 validation", () => {
|
|
it("accepts a minimal valid manifest", () => {
|
|
expect(validateManifest(base())).toEqual([]);
|
|
});
|
|
|
|
it("reports every missing required field at once", () => {
|
|
const errors = validateManifest({ id: "sample-plugin" });
|
|
|
|
expect(errors.some((e) => e.includes('"name"'))).toBe(true);
|
|
expect(errors.some((e) => e.includes('"version"'))).toBe(true);
|
|
expect(errors.some((e) => e.includes('"capabilities"'))).toBe(true);
|
|
});
|
|
|
|
it("rejects an id that is not a lowercase slug", () => {
|
|
expect(validateManifest(base({ id: "Sample_Plugin" })).join()).toMatch(
|
|
/"id" must match/,
|
|
);
|
|
});
|
|
|
|
it("rejects an id that starts with a digit", () => {
|
|
expect(validateManifest(base({ id: "1plugin" })).join()).toMatch(
|
|
/"id" must match/,
|
|
);
|
|
});
|
|
|
|
it("rejects a non-semver version", () => {
|
|
expect(validateManifest(base({ version: "1.0" })).join()).toMatch(
|
|
/valid semver/,
|
|
);
|
|
});
|
|
|
|
it("rejects an unknown category", () => {
|
|
expect(validateManifest(base({ category: "Nonsense" })).join()).toMatch(
|
|
/must be one of/,
|
|
);
|
|
});
|
|
|
|
// Capabilities were dotted in v1. They are colon-separated now so they can
|
|
// never be confused with the dotted RBAC permissions.
|
|
it("rejects a dotted capability name", () => {
|
|
expect(
|
|
validateManifest(base({ capabilities: ["hosts.read"] })).join(),
|
|
).toMatch(/not a known capability/);
|
|
});
|
|
|
|
it("rejects a capability outside the catalog", () => {
|
|
expect(
|
|
validateManifest(base({ capabilities: ["hosts:obliterate"] })).join(),
|
|
).toMatch(/not a known capability/);
|
|
});
|
|
|
|
it("rejects a duplicated capability", () => {
|
|
expect(
|
|
validateManifest(base({ capabilities: ["kv:own", "kv:own"] })).join(),
|
|
).toMatch(/duplicates/);
|
|
});
|
|
|
|
it("refuses a manifest targeting another SDK api version", () => {
|
|
const { manifest, errors } = parseManifest(
|
|
base({ engine: { termix: ">=2.9.0", api: "2" } }),
|
|
);
|
|
|
|
expect(manifest).toBeUndefined();
|
|
expect(errors.join()).toMatch(/needs plugin API 2/);
|
|
});
|
|
|
|
it("accepts an api range this build satisfies", () => {
|
|
for (const api of ["1", "^1.0.0", ">=1.0.0 <2", "1.x"]) {
|
|
expect(
|
|
parseManifest(base({ engine: { termix: ">=2.9.0", api } })).errors,
|
|
).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("refuses an api range needing a newer minor", () => {
|
|
const { errors } = parseManifest(
|
|
base({ engine: { termix: ">=2.9.0", api: "^1.99" } }),
|
|
);
|
|
expect(errors.join()).toMatch(/needs plugin API \^1\.99/);
|
|
});
|
|
|
|
it("refuses an engine that is not a semver range", () => {
|
|
expect(
|
|
validateManifest(base({ engine: { termix: "soon", api: "one" } })).join(),
|
|
).toMatch(/engine.termix.*semver[\s\S]*engine.api.*semver/);
|
|
});
|
|
|
|
it("checks engine.termix against the core version", () => {
|
|
expect(isTermixCompatible(">=2.9.0", "2.9.0")).toBe(true);
|
|
expect(isTermixCompatible(">=2.9.0", "2.9.0-beta.3")).toBe(true);
|
|
expect(isTermixCompatible(">=26.10.0", "2.9.1")).toBe(false);
|
|
expect(isTermixCompatible(">=26.10.0", null)).toBe(true);
|
|
});
|
|
|
|
it("fills in the entry point defaults", () => {
|
|
const { manifest } = parseManifest(base());
|
|
|
|
expect(manifest?.backend).toBe("dist/backend.js");
|
|
expect(manifest?.frontend).toBe("dist/frontend.js");
|
|
expect(manifest?.locales).toBe("locales");
|
|
});
|
|
});
|
|
|
|
// v1 accepted unknown fields silently, so a typo'd "contribute" validated
|
|
// clean and was then dropped. A manifest has to mean what it says.
|
|
describe("unknown field rejection", () => {
|
|
it("rejects an unknown top-level field", () => {
|
|
expect(validateManifest(base({ sudoEverything: true })).join()).toMatch(
|
|
/Unknown field "sudoEverything"/,
|
|
);
|
|
});
|
|
|
|
it("rejects a near-miss of a real field name", () => {
|
|
expect(validateManifest(base({ contribute: {} })).join()).toMatch(
|
|
/Unknown field "contribute"/,
|
|
);
|
|
});
|
|
|
|
it("rejects an unknown field inside contributes", () => {
|
|
const errors = validateManifest(base({ contributes: { widgets: [] } }));
|
|
|
|
expect(errors.join()).toMatch(/Unknown field "widgets"/);
|
|
});
|
|
|
|
it("rejects an unknown field inside a tab", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
tabs: [
|
|
{
|
|
id: "t",
|
|
titleKey: "k",
|
|
icon: "i",
|
|
openFrom: ["rail"],
|
|
surprise: 1,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/Unknown field "surprise"/);
|
|
});
|
|
|
|
it("rejects an unknown field inside author and engine", () => {
|
|
expect(
|
|
validateManifest(base({ author: { name: "x", role: "admin" } })).join(),
|
|
).toMatch(/Unknown field "role"/);
|
|
|
|
expect(
|
|
validateManifest(
|
|
base({ engine: { termix: ">=2.9.0", api: "1", unsafe: true } }),
|
|
).join(),
|
|
).toMatch(/Unknown field "unsafe"/);
|
|
});
|
|
|
|
it("rejects an unknown field inside a provides entry", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
provides: [
|
|
{
|
|
service: "sample.thing",
|
|
version: "1.0.0",
|
|
permission: "sample-plugin.use",
|
|
elevated: true,
|
|
},
|
|
],
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "permissions.use.title",
|
|
descriptionKey: "permissions.use.description",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/Unknown field "elevated"/);
|
|
});
|
|
});
|
|
|
|
describe("dependencies", () => {
|
|
it("accepts a well-formed dependency map", () => {
|
|
expect(
|
|
validateManifest(base({ dependencies: { automations: "^1.0.0" } })),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("rejects a range that is not valid semver", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({ dependencies: { automations: "latest!" } }),
|
|
).join(),
|
|
).toMatch(/valid semver range/);
|
|
});
|
|
|
|
it("refuses a plugin depending on itself", () => {
|
|
const { errors } = parseManifest(
|
|
base({ dependencies: { "sample-plugin": "^1.0.0" } }),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/points at this plugin/);
|
|
});
|
|
|
|
it("refuses the same plugin in both dependency maps", () => {
|
|
const { errors } = parseManifest(
|
|
base({
|
|
dependencies: { automations: "^1.0.0" },
|
|
optionalDependencies: { automations: "^1.0.0" },
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/both dependencies and optionalDependencies/);
|
|
});
|
|
});
|
|
|
|
describe("plugin permissions", () => {
|
|
it("accepts declared permissions", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "permissions.use.title",
|
|
descriptionKey: "permissions.use.description",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("accepts a dotted name and a system role default", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "services.use",
|
|
titleKey: "permissions.services.use.title",
|
|
descriptionKey: "permissions.services.use.description",
|
|
defaultRoles: ["admin"],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
// Without this a manifest could name a core group and then gate a route on
|
|
// authority it was never given.
|
|
it("refuses a name starting with a core group", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "admin.users.manage",
|
|
titleKey: "k",
|
|
descriptionKey: "d",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/reserved core group/);
|
|
});
|
|
|
|
it("refuses a name starting with hosts", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{ name: "hosts.view", titleKey: "k", descriptionKey: "d" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/reserved core group/);
|
|
});
|
|
|
|
// "sample-plugin" + "sample-plugin.use" would register as
|
|
// sample-plugin.sample-plugin.use, which is never the intent.
|
|
it("refuses a name that repeats the plugin id", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{ name: "sample-plugin.use", titleKey: "k", descriptionKey: "d" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/already starts with this plugin/);
|
|
});
|
|
|
|
it("refuses a default for a role core does not seed", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "k",
|
|
descriptionKey: "d",
|
|
defaultRoles: ["superuser"],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/defaultRoles\[0\]/);
|
|
});
|
|
|
|
it("refuses a duplicate name", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{ name: "use", titleKey: "k", descriptionKey: "d" },
|
|
{ name: "use", titleKey: "k2", descriptionKey: "d2" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/declared more than once/);
|
|
});
|
|
|
|
it("requires both i18n keys", () => {
|
|
const errors = validateManifest(
|
|
base({ contributes: { permissions: [{ name: "use" }] } }),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/titleKey/);
|
|
expect(errors.join()).toMatch(/descriptionKey/);
|
|
});
|
|
|
|
// The id an admin grants is <pluginId>.<name>, so cross-field checks compare
|
|
// against the qualified form rather than the short name.
|
|
it("matches a service permission against the qualified id", () => {
|
|
const { errors } = parseManifest(
|
|
base({
|
|
provides: [
|
|
{
|
|
service: "sample.thing",
|
|
version: "1.0.0",
|
|
permission: "sample-plugin.use",
|
|
},
|
|
],
|
|
contributes: {
|
|
permissions: [{ name: "use", titleKey: "k", descriptionKey: "d" }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("services, secrets and actions", () => {
|
|
it("refuses a service gated by a permission the group never declares", () => {
|
|
const { errors } = parseManifest(
|
|
base({
|
|
provides: [
|
|
{
|
|
service: "sample.thing",
|
|
version: "1.0.0",
|
|
permission: "sample-plugin.missing",
|
|
},
|
|
],
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "permissions.use.title",
|
|
descriptionKey: "permissions.use.description",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/not declared in contributes/);
|
|
});
|
|
|
|
it("refuses a shared secret gated by an undeclared permission", () => {
|
|
const { errors } = parseManifest(
|
|
base({
|
|
providesSecret: [
|
|
{ key: "api-key", permission: "sample-plugin.missing" },
|
|
],
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "permissions.use.title",
|
|
descriptionKey: "permissions.use.description",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/not declared in contributes/);
|
|
});
|
|
|
|
it("refuses a plugin borrowing a secret from itself", () => {
|
|
const { errors } = parseManifest(
|
|
base({ requiresSecret: [{ plugin: "sample-plugin", key: "api-key" }] }),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/points at this plugin/);
|
|
});
|
|
|
|
it("refuses an action gated by an undeclared permission", () => {
|
|
const { errors } = parseManifest(
|
|
base({
|
|
contributes: {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "permissions.use.title",
|
|
descriptionKey: "permissions.use.description",
|
|
},
|
|
],
|
|
actions: [
|
|
{
|
|
id: "sample.open",
|
|
titleKey: "k",
|
|
handler: "open",
|
|
permission: "sample-plugin.missing",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(errors.join()).toMatch(/not declared in contributes/);
|
|
});
|
|
|
|
it("allows a camelCase segment in an action id", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
actions: [
|
|
{ id: "ai.openWithContext", titleKey: "k", handler: "open" },
|
|
],
|
|
},
|
|
}),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("rejects an action id that is not dotted", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
actions: [{ id: "open", titleKey: "k", handler: "open" }],
|
|
},
|
|
}),
|
|
).join(),
|
|
).toMatch(/dotted action id/);
|
|
});
|
|
|
|
it("rejects a handler that is not an identifier", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
actions: [
|
|
{ id: "sample.open", titleKey: "k", handler: "not valid!" },
|
|
],
|
|
},
|
|
}),
|
|
).join(),
|
|
).toMatch(/JavaScript identifier/);
|
|
});
|
|
});
|
|
|
|
describe("tabs and host capabilities", () => {
|
|
it("rejects an unknown openFrom value", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
tabs: [
|
|
{ id: "t", titleKey: "k", icon: "i", openFrom: ["telepathy"] },
|
|
],
|
|
},
|
|
}),
|
|
).join(),
|
|
).toMatch(/openFrom\[0\] must be one of/);
|
|
});
|
|
|
|
it("rejects duplicate tab ids", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
tabs: [
|
|
{ id: "t", titleKey: "k", icon: "i", openFrom: ["rail"] },
|
|
{ id: "t", titleKey: "k2", icon: "i", openFrom: ["rail"] },
|
|
],
|
|
},
|
|
}),
|
|
).join(),
|
|
).toMatch(/duplicates/);
|
|
});
|
|
|
|
it("accepts hostCapability as a single object or an array", () => {
|
|
const one = {
|
|
key: "enableThing",
|
|
labelKey: "k",
|
|
editorTab: "general",
|
|
};
|
|
|
|
expect(
|
|
validateManifest(base({ contributes: { hostCapability: one } })),
|
|
).toEqual([]);
|
|
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
contributes: {
|
|
hostCapability: [one, { ...one, key: "enableOther" }],
|
|
},
|
|
}),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("rejects duplicate hostCapability keys", () => {
|
|
const one = { key: "enableThing", labelKey: "k", editorTab: "general" };
|
|
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { hostCapability: [one, one] } }),
|
|
).join(),
|
|
).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("accepts guest views on a guest plugin and refuses them otherwise", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { guest: true, guestViews: ["shared"] } }),
|
|
),
|
|
).toEqual([]);
|
|
const errors = validateManifest(
|
|
base({ contributes: { guestViews: ["Shared View"] } }),
|
|
).join();
|
|
expect(errors).toMatch(/contributes\.guestViews entries must be/);
|
|
expect(errors).toMatch(/needs "contributes\.guest"/);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|
|
|
|
describe("contributes.mobile", () => {
|
|
const tabs = [
|
|
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
|
|
];
|
|
|
|
it("accepts each mode with its own fields", () => {
|
|
for (const mobile of [
|
|
{
|
|
mode: "webview",
|
|
entry: "dist/mobile.js",
|
|
sizes: ["phone", "tablet"],
|
|
views: ["sample"],
|
|
note: "mobile.note",
|
|
},
|
|
{ mode: "webview" },
|
|
{ mode: "native", feature: "file-manager" },
|
|
{ mode: "none", note: "Not on phones" },
|
|
]) {
|
|
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
|
|
[],
|
|
);
|
|
}
|
|
});
|
|
|
|
it("needs a known mode and rejects unknown fields", () => {
|
|
const errors = validateManifest(
|
|
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
|
|
).join();
|
|
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
|
|
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
|
|
expect(
|
|
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
|
|
).toMatch(/"contributes\.mobile" must be an object/);
|
|
});
|
|
|
|
it("keeps webview and native fields to their own mode", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
tabs,
|
|
mobile: {
|
|
mode: "native",
|
|
entry: "dist/mobile.js",
|
|
views: ["sample"],
|
|
},
|
|
},
|
|
}),
|
|
).join();
|
|
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
|
|
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
|
|
).join(),
|
|
).toMatch(/mobile\.feature is only for mode "native"/);
|
|
});
|
|
|
|
it("puts the entry beside the frontend bundle", () => {
|
|
const check = (entry: string, frontend?: string) =>
|
|
validateManifest(
|
|
base({
|
|
...(frontend ? { frontend } : {}),
|
|
contributes: { mobile: { mode: "webview", entry } },
|
|
}),
|
|
).join();
|
|
expect(check("dist/mobile.js")).toBe("");
|
|
expect(check("./dist/mobile.js")).toBe("");
|
|
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
|
|
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
|
|
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
|
|
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
|
|
});
|
|
|
|
it("checks sizes, views and the feature id", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
contributes: {
|
|
tabs,
|
|
panels: [{ id: "side", titleKey: "nav.side" }],
|
|
mobile: {
|
|
mode: "webview",
|
|
sizes: ["phone", "phone", "watch"],
|
|
views: ["sample", "side", "missing"],
|
|
},
|
|
},
|
|
}),
|
|
).join();
|
|
expect(errors).toMatch(/sizes duplicates "phone"/);
|
|
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
|
|
expect(errors).toMatch(/views entries must be ids .*"missing"/);
|
|
expect(errors).not.toMatch(/"side"/);
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
|
|
).join(),
|
|
).toMatch(/sizes must be a non-empty array/);
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
|
|
).join(),
|
|
).toMatch(/mobile\.feature must match/);
|
|
});
|
|
});
|
|
|
|
describe("named service providers", () => {
|
|
const provides = (names: unknown) => [
|
|
{
|
|
service: "sample.live",
|
|
version: "1.0.0",
|
|
permission: "sample-plugin.use",
|
|
names,
|
|
},
|
|
];
|
|
const permissions = {
|
|
permissions: [
|
|
{
|
|
name: "use",
|
|
titleKey: "permissions.use.title",
|
|
descriptionKey: "permissions.use.description",
|
|
},
|
|
],
|
|
};
|
|
|
|
it("accepts a list of provider names", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({ provides: provides(["ssh", "rdp"]), contributes: permissions }),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses an empty, duplicated or malformed list", () => {
|
|
for (const names of [[], ["ssh", "ssh"], ["SSH"], "ssh"]) {
|
|
expect(
|
|
validateManifest(
|
|
base({ provides: provides(names), contributes: permissions }),
|
|
).join(),
|
|
).toMatch(/provides\[0\]\.names/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("contributes.http.legacyRedirects", () => {
|
|
const withRedirects = (legacyRedirects: unknown) =>
|
|
validateManifest(base({ contributes: { http: { legacyRedirects } } }));
|
|
|
|
it("accepts an old URL outside the plugin's namespace", () => {
|
|
expect(
|
|
withRedirects([
|
|
{ from: "/users/oidc/callback", to: "/callback", status: 308 },
|
|
{ from: "/host/old-callback", to: "/callback" },
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses the plugin framework's own prefixes", () => {
|
|
expect(
|
|
withRedirects([{ from: "/plugin-api/other/x", to: "/x" }]).join(),
|
|
).toMatch(/cannot be under plugin-api/);
|
|
});
|
|
|
|
it("refuses a relative path, a bad status and unknown keys", () => {
|
|
const errors = withRedirects([
|
|
{ from: "users/x", to: "/x" },
|
|
{ from: "/a", to: "x" },
|
|
{ from: "/a", to: "/x", status: 302 },
|
|
{ from: "/a", to: "/x", extra: true },
|
|
]).join();
|
|
expect(errors).toMatch(/from must be an absolute path/);
|
|
expect(errors).toMatch(/to must be a path/);
|
|
expect(errors).toMatch(/status must be 307 or 308/);
|
|
expect(errors).toMatch(/extra/);
|
|
});
|
|
});
|
|
|
|
describe("contributes.http.adminImpersonation", () => {
|
|
it("accepts a boolean and refuses anything else", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { http: { adminImpersonation: true } } }),
|
|
),
|
|
).toEqual([]);
|
|
expect(
|
|
validateManifest(
|
|
base({ contributes: { http: { adminImpersonation: "yes" } } }),
|
|
).join(),
|
|
).toMatch(/adminImpersonation" must be a boolean/);
|
|
});
|
|
});
|
|
|
|
describe("contributes.keybindingActions", () => {
|
|
const withActions = (keybindingActions: unknown) =>
|
|
validateManifest(base({ contributes: { keybindingActions } }));
|
|
|
|
it("accepts actions with typed parameters", () => {
|
|
expect(
|
|
withActions([
|
|
{ id: "sample.run" },
|
|
{
|
|
id: "sample.send",
|
|
params: {
|
|
itemId: {
|
|
type: "string",
|
|
required: true,
|
|
pattern: "^[0-9]+$",
|
|
maxLength: 20,
|
|
syncEntity: "items",
|
|
},
|
|
enter: { type: "boolean" },
|
|
},
|
|
},
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses the shell's own actions, duplicates and bad ids", () => {
|
|
const errors = withActions([
|
|
{ id: "nextTab" },
|
|
{ id: "sample.run" },
|
|
{ id: "sample.run" },
|
|
{ id: "bad id" },
|
|
]).join();
|
|
expect(errors).toMatch(/one of the shell's own actions/);
|
|
expect(errors).toMatch(/duplicates "sample.run"/);
|
|
expect(errors).toMatch(/letters, digits, dots and dashes/);
|
|
});
|
|
|
|
it("refuses bad parameters", () => {
|
|
const errors = withActions([
|
|
{
|
|
id: "sample.run",
|
|
params: {
|
|
type: { type: "string" },
|
|
a: { type: "number" },
|
|
b: { type: "string", pattern: "(" },
|
|
c: { type: "boolean", syncEntity: "items" },
|
|
d: { type: "string", extra: 1 },
|
|
},
|
|
},
|
|
]).join();
|
|
expect(errors).toMatch(/params.type is not a valid parameter name/);
|
|
expect(errors).toMatch(/a.type must be "string" or "boolean"/);
|
|
expect(errors).toMatch(/b.pattern must be a regular expression/);
|
|
expect(errors).toMatch(/c.syncEntity needs a string parameter/);
|
|
expect(errors).toMatch(/d/);
|
|
});
|
|
});
|
|
|
|
describe("contributes.protocols", () => {
|
|
const withProtocols = (protocols: unknown) =>
|
|
validateManifest(base({ contributes: { protocols } }));
|
|
|
|
it("accepts protocols with credential fields", () => {
|
|
expect(
|
|
withProtocols([
|
|
{
|
|
id: "spice",
|
|
credentialFields: [
|
|
{ key: "display" },
|
|
{ key: "ticket", secret: true },
|
|
],
|
|
defaultPort: 5930,
|
|
hostLoginFallback: ["username", "password"],
|
|
},
|
|
{ id: "x2go" },
|
|
]),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses core's protocol, duplicates and bad ids", () => {
|
|
const errors = withProtocols([
|
|
{ id: "ssh" },
|
|
{ id: "spice" },
|
|
{ id: "spice" },
|
|
{ id: "Bad_Id" },
|
|
]);
|
|
expect(errors.join("\n")).toMatch(/"ssh" belongs to core/);
|
|
expect(errors.join("\n")).toMatch(/duplicates "spice"/);
|
|
expect(errors.join("\n")).toMatch(/\[3\]\.id must be lowercase/);
|
|
});
|
|
|
|
it("refuses field keys every login already has, and duplicate keys", () => {
|
|
const errors = withProtocols([
|
|
{
|
|
id: "spice",
|
|
credentialFields: [
|
|
{ key: "password" },
|
|
{ key: "display" },
|
|
{ key: "display", secret: "yes" },
|
|
],
|
|
},
|
|
]);
|
|
expect(errors.join("\n")).toMatch(/"password" is part of every login/);
|
|
expect(errors.join("\n")).toMatch(/duplicates "display"/);
|
|
expect(errors.join("\n")).toMatch(/secret must be a boolean/);
|
|
});
|
|
|
|
it("refuses unknown keys, bad ports and bad fallbacks", () => {
|
|
const errors = withProtocols([
|
|
{
|
|
id: "spice",
|
|
defaultPort: 70000,
|
|
hostLoginFallback: ["key"],
|
|
extra: true,
|
|
},
|
|
]);
|
|
expect(errors.join("\n")).toMatch(/defaultPort must be a port number/);
|
|
expect(errors.join("\n")).toMatch(/hostLoginFallback/);
|
|
expect(errors.join("\n")).toMatch(/extra/);
|
|
});
|
|
});
|
|
|
|
describe("manifest video", () => {
|
|
it("accepts YouTube links", () => {
|
|
for (const video of [
|
|
"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"https://youtu.be/dQw4w9WgXcQ",
|
|
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
|
"https://youtube.com/shorts/dQw4w9WgXcQ",
|
|
]) {
|
|
expect(validateManifest(base({ video }))).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("rejects anything that is not a YouTube video", () => {
|
|
for (const video of [
|
|
"https://evil.example/watch?v=dQw4w9WgXcQ",
|
|
"http://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"https://www.youtube.com.evil.example/watch?v=dQw4w9WgXcQ",
|
|
"https://user@www.youtube.com/watch?v=dQw4w9WgXcQ",
|
|
"https://www.youtube.com/watch?v=short",
|
|
"https://www.youtube.com/channel/UC123",
|
|
"javascript:alert(1)",
|
|
"",
|
|
42,
|
|
]) {
|
|
expect(validateManifest(base({ video }))).toEqual([
|
|
expect.stringContaining('Field "video" must be a YouTube link'),
|
|
]);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("manifest features", () => {
|
|
it("accepts a short list of lines", () => {
|
|
expect(
|
|
validateManifest(base({ features: ["Start containers", "Read logs"] })),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("rejects bad lists", () => {
|
|
expect(validateManifest(base({ features: "Logs" }))).toEqual([
|
|
'Field "features" must be an array of strings',
|
|
]);
|
|
expect(validateManifest(base({ features: ["ok", "", 4] }))).toHaveLength(2);
|
|
expect(
|
|
validateManifest(base({ features: ["x".repeat(161)] })).join(),
|
|
).toMatch(/features\[0\]/);
|
|
expect(
|
|
validateManifest(base({ features: Array(21).fill("x") })).join(),
|
|
).toMatch(/at most 20/);
|
|
});
|
|
});
|
|
|
|
describe("pluginFeatures", () => {
|
|
it("keeps only usable lines", () => {
|
|
expect(pluginFeatures([" Logs ", "", 3, "x".repeat(161), "Stats"])).toEqual(
|
|
["Logs", "Stats"],
|
|
);
|
|
expect(pluginFeatures(Array(30).fill("x"))).toHaveLength(20);
|
|
expect(pluginFeatures("Logs")).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("youtubeVideoId", () => {
|
|
it("returns only the id", () => {
|
|
expect(
|
|
youtubeVideoId("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42"),
|
|
).toBe("dQw4w9WgXcQ");
|
|
expect(youtubeVideoId("https://youtu.be/dQw4w9WgXcQ?si=abc")).toBe(
|
|
"dQw4w9WgXcQ",
|
|
);
|
|
expect(youtubeVideoId("https://youtu.be/../../x")).toBeNull();
|
|
expect(youtubeVideoId(undefined)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("docs and env fields", () => {
|
|
it("accepts an https docs link and well formed env entries", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({
|
|
docs: "https://docs.termix.site/plugins/sample-plugin",
|
|
env: [
|
|
{ name: "SAMPLE_URL", description: "Where to connect." },
|
|
{
|
|
name: "SAMPLE_TOKEN",
|
|
description: "Token.",
|
|
secret: true,
|
|
required: true,
|
|
default: "",
|
|
},
|
|
],
|
|
}),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses a docs link that is not https", () => {
|
|
const errors = validateManifest(base({ docs: "http://example.com" }));
|
|
expect(errors.join(" ")).toMatch(/"docs" must be an https URL/);
|
|
});
|
|
|
|
it("refuses bad env entries", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
env: [
|
|
{ name: "lower_case", description: "x" },
|
|
{ name: "DUP", description: "x" },
|
|
{ name: "DUP", description: "x" },
|
|
{ name: "NO_DESC", description: "" },
|
|
{ name: "EXTRA", description: "x", other: 1 },
|
|
{ name: "FLAG", description: "x", secret: "yes" },
|
|
],
|
|
}),
|
|
).join(" ");
|
|
expect(errors).toMatch(/env\[0\]\.name/);
|
|
expect(errors).toMatch(/env\[2\]\.name" repeats DUP/);
|
|
expect(errors).toMatch(/env\[3\]\.description/);
|
|
expect(errors).toMatch(/Unknown field "other" in env\[4\]/);
|
|
expect(errors).toMatch(/env\[5\]\.secret/);
|
|
});
|
|
|
|
it("reads docs links and env entries leniently", () => {
|
|
expect(pluginDocsUrl("https://docs.termix.site/plugins/x/")).toBe(
|
|
"https://docs.termix.site/plugins/x",
|
|
);
|
|
expect(pluginDocsUrl("file:///etc/passwd")).toBeNull();
|
|
expect(pluginDocsUrl(42)).toBeNull();
|
|
expect(
|
|
pluginEnvVars([
|
|
{ name: "OK_VAR", description: "fine" },
|
|
{ name: "bad", description: "x" },
|
|
"nope",
|
|
]),
|
|
).toEqual([{ name: "OK_VAR", description: "fine" }]);
|
|
});
|
|
|
|
it("joins pages and anchors onto a docs link", () => {
|
|
const base = "https://docs.termix.site/plugins/docker";
|
|
expect(pluginDocsPage(base)).toBe(base);
|
|
expect(pluginDocsPage(base, "/setup/")).toBe(`${base}/setup`);
|
|
expect(pluginDocsPage(base, "setup", "#ports")).toBe(`${base}/setup#ports`);
|
|
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
|
});
|
|
});
|
|
|
|
describe("layouts and themes", () => {
|
|
const layouts = [
|
|
{
|
|
id: "fancy",
|
|
label: "layout.fancy",
|
|
defaultTheme: "paper",
|
|
default: true,
|
|
},
|
|
];
|
|
const themes = [
|
|
{
|
|
id: "paper",
|
|
label: "theme.paper",
|
|
mode: "light",
|
|
css: "themes/paper.css",
|
|
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
|
|
},
|
|
];
|
|
|
|
it("accepts layouts and themes with their capabilities", () => {
|
|
const { manifest, errors } = parseManifest(
|
|
base({
|
|
capabilities: ["ui:layout", "ui:theme"],
|
|
contributes: { layouts, themes },
|
|
}),
|
|
);
|
|
expect(errors).toEqual([]);
|
|
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
|
|
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
|
|
});
|
|
|
|
it("needs ui:layout for layouts and ui:theme for themes", () => {
|
|
const { errors } = parseManifest(
|
|
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
|
|
);
|
|
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
|
|
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
|
|
});
|
|
|
|
it("refuses bad ids, modes, paths and swatches", () => {
|
|
const errors = validateManifest(
|
|
base({
|
|
capabilities: ["ui:layout", "ui:theme"],
|
|
contributes: {
|
|
layouts: [
|
|
{ id: "Fancy", label: "x" },
|
|
{ id: "two", label: "x", default: true },
|
|
{ id: "three", label: "x", default: true, extra: 1 },
|
|
],
|
|
themes: [
|
|
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
|
|
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
|
|
{
|
|
id: "dusk",
|
|
label: "x",
|
|
mode: "dark",
|
|
css: "dusk.css",
|
|
swatch: { background: "red", panel: "#000", text: "#fff" },
|
|
},
|
|
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
const has = (text: string) => errors.some((e) => e.includes(text));
|
|
expect(has("contributes.layouts[0].id must match")).toBe(true);
|
|
expect(has("Only one entry")).toBe(true);
|
|
expect(has('Unknown field "extra"')).toBe(true);
|
|
expect(has('mode must be "light" or "dark"')).toBe(true);
|
|
expect(has("relative path inside the plugin")).toBe(true);
|
|
expect(has("must be a .css file")).toBe(true);
|
|
expect(has("swatch.background must be a hex color")).toBe(true);
|
|
expect(has('duplicates "dusk"')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("manifest runtime", () => {
|
|
it("accepts a desktop runtime plugin", () => {
|
|
expect(
|
|
validateManifest(
|
|
base({ runtime: "desktop", capabilities: ["desktop:local"] }),
|
|
),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("rejects an unknown runtime", () => {
|
|
expect(validateManifest(base({ runtime: "cloud" })).join()).toMatch(
|
|
/runtime/,
|
|
);
|
|
});
|
|
|
|
it("rejects a desktop runtime that is mirrored or server only", () => {
|
|
expect(
|
|
validateManifest(base({ runtime: "desktop", desktop: "mirror" })).join(),
|
|
).toMatch(/local/);
|
|
});
|
|
});
|