Files
plainpages/src/plugin-host/plugin.test.ts
T

118 lines
6.7 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import {
checkApiVersion,
declaredPermissions,
definePlugin,
HOST_API_VERSION,
isValidPermissionName,
isValidPluginId,
parseSemver,
RESERVED_PLUGIN_IDS,
type Plugin,
type PluginManifest,
} from "./plugin.ts";
import { AUTH_FLOWS } from "../auth/flow-view.ts";
// A representative manifest exercising every field — its existence type-checks the contract.
// `apiVersion` is a literal: a plugin pins the version it was built against, so importing
// HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the
// host derives both from the plugin's folder name.
const scheduling: PluginManifest = definePlugin({
apiVersion: "1.0.0",
hooks: { onBoot: () => {} },
nav: [{
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
}],
permissions: [{ description: "View shifts", name: "scheduling:read" }],
routes: [
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
],
});
test("definePlugin returns the manifest unchanged — id/mount come from the folder, not the manifest", () => {
const m: PluginManifest = { apiVersion: "1.0.0" };
assert.equal(definePlugin(m), m); // identity, not a copy
assert.equal(scheduling.routes?.length, 3);
});
test("isValidPluginId accepts lowercase/digits/dashes anywhere and rejects everything else", () => {
for (const ok of ["scheduling", "people-directory", "people2", "v2", "people--dir", "-people", "people-", "a-1-b", "1"]) {
assert.ok(isValidPluginId(ok), ok);
}
for (const bad of ["People", "people_dir", "a/b", "a.b", "a b", ""]) {
assert.ok(!isValidPluginId(bad), bad);
}
});
test("isValidPermissionName requires <resource>:<action> — a bare word names a role, and roles are groups", () => {
for (const ok of ["users:read", "scheduling:write", "oauth2-clients:read", "team-a:a1_b9", "invoices:approve"]) {
assert.ok(isValidPermissionName(ok), ok);
}
// "admin" is the shape this rule exists to stop: it says who someone is, not what they may do.
for (const bad of ["admin", "", "Users:read", "users:", ":read", "users:read:extra", "a b:read", "-bad:read", "a/b:read", `${"a".repeat(60)}:read`]) {
assert.ok(!isValidPermissionName(bad), bad);
}
});
test("declaredPermissions is the catalog: every plugin's declarations, deduped by name and sorted", () => {
const a: Plugin = { apiVersion: "1.0.0", id: "a", permissions: [{ description: "Write things", name: "things:write" }, { description: "Read things", name: "things:read" }] };
const b: Plugin = { apiVersion: "1.0.0", id: "b", permissions: [{ description: "b's wording", name: "things:read" }, { name: "orders:read" }] };
const c: Plugin = { apiVersion: "1.0.0", id: "c" }; // declaring none is fine
const catalog = declaredPermissions([a, b, c]);
assert.deepEqual(catalog.map((p) => p.name), ["orders:read", "things:read", "things:write"]);
// A shared name is legitimate (findConflicts only warns); the first declaration wins its wording.
assert.equal(catalog.find((p) => p.name === "things:read")?.description, "Read things");
assert.deepEqual(declaredPermissions([]), []);
});
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
for (const bad of ["1", "1.2", "1.2.3.4", "v1.2.3", "^1.2.3", "01.2.3", "1.2.x", "1.2.3 ", "", 3, null, undefined]) {
assert.equal(parseSemver(bad), null, `${String(bad)} is not a semver`);
}
});
test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => {
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // the host always accepts its own version
assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility
assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer)
assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older)
assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored
assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot
assert.match(checkApiVersion("0.2.0", "0.1.0").message, /upgrade the host/); // ahead of the host, even pre-1.0
for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) {
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
}
});
// Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route
// needs a line here too.
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field), `admin` and `auth-admin` (plugins) are NOT reserved", () => {
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
const builtins = new Set<string>([
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
"auth", // /auth/complete (login completion)
"logout", // POST /logout
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
"dashboard", // the gated app home
"public", // static assets
"error", // /error (Kratos' error sink)
]);
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
// deliberately not reserved — a plugin folder named "home" is legal. `auth-admin` is likewise free:
// the admin screens ship as a drop-in plugin mounted at /auth-admin, not a built-in route; and so
// is `admin`, the id an operator's pre-0.5 copy of that plugin still carries.
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
assert.equal(RESERVED_PLUGIN_IDS.has("auth-admin"), false);
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
});