fix: finish 2.9.0 plugin moves and get every check green

This commit is contained in:
LukeGus committed 2026-09-24 23:35:18 -05:00
1 parent 8c1e02af89
commit 6489b62ece
335 files changed
+29878 -17749

No files matched your search

+141
View File
@@ -0,0 +1,141 @@
/**
* Boots core the way starter.ts does, against a real SQLite database that
* starts in 2.8 shape, with the plugin loader pointed at the built bundles in
* dist/plugins rather than source. Shared by the boot and upgrade tests.
*
* Needs `npm run build` (or build:sdk and build:plugins) first.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import Database from "better-sqlite3";
export const REPO_ROOT = path.resolve(__dirname, "../../../..");
export const BUILT_PLUGINS_DIR = path.join(REPO_ROOT, "dist", "plugins");
const SCHEMA_28 = path.join(__dirname, "../fixtures/sqlite-2.8-schema.sql");
/** Every plugin in the repo, by folder. */
export function sourcePluginIds(): string[] {
const root = path.join(REPO_ROOT, "plugins");
return fs
.readdirSync(root, { withFileTypes: true })
.filter(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(root, entry.name, "manifest.json")),
)
.map((entry) => entry.name)
.sort();
}
/** Throws with the fix when dist/plugins is missing a plugin. */
export function assertPluginsBuilt(): void {
const missing = sourcePluginIds().filter(
(id) =>
!fs.existsSync(path.join(BUILT_PLUGINS_DIR, id, "dist", "backend.js")),
);
if (missing.length > 0) {
throw new Error(
`dist/plugins is missing ${missing.join(", ")}. Run npm run build first.`,
);
}
}
export function builtManifest(id: string): Record<string, unknown> {
return JSON.parse(
fs.readFileSync(path.join(BUILT_PLUGINS_DIR, id, "manifest.json"), "utf8"),
);
}
type Row = Record<string, unknown>;
/** A 2.8 database in memory, with a helper that ignores unknown columns. */
export function create28Database() {
const sqlite = new Database(":memory:");
sqlite.exec(fs.readFileSync(SCHEMA_28, "utf8"));
const columns = new Map<string, Set<string>>();
const insert = (table: string, row: Row): number => {
let known = columns.get(table);
if (!known) {
known = new Set(
(
sqlite.prepare(`PRAGMA table_info("${table}")`).all() as Array<{
name: string;
}>
).map((column) => column.name),
);
columns.set(table, known);
}
const entries = Object.entries(row).filter(([key]) => known!.has(key));
const sql = `INSERT INTO "${table}" (${entries
.map(([key]) => `"${key}"`)
.join(", ")}) VALUES (${entries.map(() => "?").join(", ")})`;
const values = entries.map(([, value]) =>
typeof value === "boolean"
? value
? 1
: 0
: value !== null && typeof value === "object"
? JSON.stringify(value)
: value,
);
return Number(sqlite.prepare(sql).run(...values).lastInsertRowid);
};
return { sqlite, insert };
}
export interface BootedCore {
dataDir: string;
/** The live database core and the plugins write to. */
sqlite: Database.Database;
shutdown: () => Promise<void>;
}
/**
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
* sequence up to, not including, opening the HTTP server. Call
* vi.resetModules() before, so module state from other tests is gone.
*/
export async function bootCore(seed: Database.Database): Promise<BootedCore> {
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-boot-"));
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
process.env.TERMIX_BUNDLED_PLUGINS_DIR = BUILT_PLUGINS_DIR;
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
const { SystemCrypto } = await import("../../utils/system-crypto.js");
const systemCrypto = SystemCrypto.getInstance();
await systemCrypto.initializeJWTSecret();
await systemCrypto.initializeDatabaseKey();
await systemCrypto.initializeEncryptionKey();
await systemCrypto.initializeInternalAuthToken();
const db = await import("../../database/db/index.js");
await db.initializeDatabase();
const { runCoreBootMigrations } = await import("../../boot.js");
await runCoreBootMigrations();
const { primeKnownPermissions } =
await import("../../utils/known-permissions.js");
await primeKnownPermissions();
const { initializePlugins, shutdownPlugins } =
await import("../../plugins/index.js");
await initializePlugins();
const { runPluginDataMigrations } =
await import("../../plugins/boot-migrations.js");
await runPluginDataMigrations();
return {
dataDir,
sqlite: db.getSqlite(),
shutdown: async () => {
await shutdownPlugins();
fs.rmSync(dataDir, { recursive: true, force: true });
},
};
}
@@ -0,0 +1,149 @@
/**
* The built plugins really load: every bundle in dist/plugins activates on a
* database that started in 2.8 shape, in dependency order, with every
* service it needs provided and nothing registered twice.
*/
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
assertPluginsBuilt,
bootCore,
builtManifest,
create28Database,
sourcePluginIds,
type BootedCore,
} from "./built-harness.js";
const BOOT_TIMEOUT = 180_000;
let core: BootedCore;
const logged = { errors: [] as string[], warnings: [] as string[] };
beforeAll(async () => {
assertPluginsBuilt();
vi.resetModules();
const { sqlite } = create28Database();
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
logged.errors.push(args.map(String).join(" "));
});
const warn = vi.spyOn(console, "warn").mockImplementation((...args) => {
logged.warnings.push(args.map(String).join(" "));
});
try {
core = await bootCore(sqlite);
} finally {
error.mockRestore();
warn.mockRestore();
}
}, BOOT_TIMEOUT);
afterAll(async () => {
await core?.shutdown();
}, BOOT_TIMEOUT);
describe("the built plugins on a 2.8 database", () => {
it("boots without logging an error", () => {
expect(logged.errors).toEqual([]);
if (process.env.SHOW_BOOT_WARNINGS) {
console.log(logged.warnings.join("\n"));
}
});
it("activates every plugin, none failed or blocked", async () => {
const { getPluginRuntime } = await import("../../plugins/index.js");
const states = Object.fromEntries(
getPluginRuntime()
.loader.list()
.map((plugin) => [plugin.id, plugin.state]),
);
expect(Object.keys(states).sort()).toEqual(sourcePluginIds());
const notActive = Object.entries(states).filter(
([, state]) => state !== "active",
);
expect(notActive).toEqual([]);
});
it("gives every declared service a provider and meets every hard dependency", async () => {
const { listServices } = await import("../../plugins/service-registry.js");
const provided = new Set(
listServices().map((service) => `${service.pluginId}:${service.service}`),
);
const ids = new Set(sourcePluginIds());
for (const id of ids) {
const manifest = builtManifest(id) as {
provides?: Array<{ service: string }>;
requires?: Array<{ service: string; optional?: boolean }>;
dependencies?: Record<string, string>;
};
for (const { service } of manifest.provides ?? []) {
expect(provided, `${id} provides ${service}`).toContain(
`${id}:${service}`,
);
}
const available = new Set(
listServices().map((service) => service.service),
);
for (const requirement of manifest.requires ?? []) {
if (requirement.optional) continue;
expect(available, `${id} requires ${requirement.service}`).toContain(
requirement.service,
);
}
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
expect(ids, `${id} depends on ${dependency}`).toContain(dependency);
}
}
});
it("registers no route, socket or permission that clashes", async () => {
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toEqual([]);
const { getRegisteredWsRoutes } = await import("../../plugins/ws.js");
const sockets = getRegisteredWsRoutes();
expect(new Set(sockets).size).toBe(sockets.length);
// Plugin routes live under /plugin-api/<id>/, so a clash is two plugins
// claiming one id, which the loader refuses; a legacy path must start
// with its own plugin id.
for (const id of sourcePluginIds()) {
const legacy =
(
builtManifest(id) as {
contributes?: { http?: { legacyPaths?: string[] } };
}
).contributes?.http?.legacyPaths ?? [];
for (const legacyPath of legacy) {
expect(legacyPath.startsWith(`/${id}/`)).toBe(true);
}
}
const { getPermissionCatalog } =
await import("../../utils/permission-catalog.js");
const permissions = getPermissionCatalog().flatMap(
(entry) => entry.permissions,
);
expect(new Set(permissions).size).toBe(permissions.length);
});
it("records every plugin migration it ran", async () => {
const recorded = core.sqlite
.prepare(
"SELECT plugin_id, COUNT(*) AS n FROM plugin_migrations GROUP BY plugin_id",
)
.all() as Array<{ plugin_id: string; n: number }>;
const byPlugin = new Map(recorded.map((row) => [row.plugin_id, row.n]));
const fs = await import("node:fs");
const path = await import("node:path");
const { BUILT_PLUGINS_DIR } = await import("./built-harness.js");
for (const id of sourcePluginIds()) {
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
const files = fs.existsSync(dir)
? fs.readdirSync(dir).filter((name) => name.endsWith(".sql"))
: [];
expect(byPlugin.get(id) ?? 0, `${id} migrations`).toBe(files.length);
}
});
});
@@ -212,6 +212,20 @@ describe("ctx.events namespacing", () => {
expect(() => ctx.events.emit("host.status", {})).not.toThrow();
});
it("gates listening to core topics the same way", () => {
const ctx = context(["kv:own"]);
expect(() => ctx.events.on("user.deleted", () => {})).toThrow(
/may only listen to/,
);
expect(() =>
ctx.events.on("plugin.other-plugin.thing", () => {}),
).not.toThrow();
expect(() =>
context(["events:core"]).events.on("user.deleted", () => {}),
).not.toThrow();
});
});
describe("ctx.kv limits", () => {
@@ -81,3 +81,31 @@ describe("ctx.audit.record", () => {
});
});
});
describe("ctx.audit.record with a request", () => {
it("records the request's IP address and user agent", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_room_end",
success: true,
request: {
ip: "203.0.113.4",
headers: { "user-agent": "TestAgent/1.0" },
},
});
expect(auditEntries[0]).toMatchObject({
ipAddress: "203.0.113.4",
userAgent: "TestAgent/1.0",
});
});
it("falls back to the socket address on an upgrade request", async () => {
const ctx = contextFor("audit-fixture");
await ctx.audit.record({
action: "collab_join",
success: true,
request: { headers: {}, socket: { remoteAddress: "198.51.100.9" } },
});
expect(auditEntries[0]).toMatchObject({ ipAddress: "198.51.100.9" });
});
});
@@ -22,6 +22,10 @@ const state = vi.hoisted(() => ({
warnings: [] as string[],
}));
vi.mock("../../plugins/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
@@ -29,7 +33,9 @@ vi.mock("../../utils/logger.js", () => ({
warn: vi.fn((message: string) => {
state.warnings.push(message);
}),
error: vi.fn(),
error: vi.fn((message: string) => {
state.warnings.push(message);
}),
success: vi.fn(),
},
databaseLogger: {
@@ -265,6 +271,11 @@ describe("namespace escalation is refused", () => {
expect(isValidPermission("docker.ps")).toBe(true);
expect(isValidPermission("docker.automations.run")).toBe(false);
expect(state.warnings.join()).toMatch(/belongs to someone else/);
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toContainEqual(
expect.objectContaining({ kind: "permission", heldBy: "automations" }),
);
} finally {
await loader.shutdown();
fixture.cleanup();
@@ -0,0 +1,55 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
FETCH_BLOCKED_PORTS,
installSafePorts,
} from "../../../../packages/plugin-sdk/src/testing/safe-ports.js";
let server: http.Server | null = null;
afterEach(async () => {
await new Promise<void>((resolve) =>
server?.listening ? server.close(() => resolve()) : resolve(),
);
server = null;
});
describe("installSafePorts", () => {
it("listens again when the random port is one fetch refuses", async () => {
installSafePorts();
server = http.createServer((_req, res) => res.end("ok"));
const realAddress = server.address.bind(server);
vi.spyOn(server, "address").mockImplementationOnce(() => ({
address: "127.0.0.1",
family: "IPv4",
port: 6000,
}));
const onListening = vi.fn();
await new Promise<void>((resolve) =>
server!.listen(0, "127.0.0.1", () => {
onListening();
resolve();
}),
);
expect(onListening).toHaveBeenCalledTimes(1);
const { port } = realAddress() as AddressInfo;
expect(FETCH_BLOCKED_PORTS.has(port)).toBe(false);
const response = await fetch(`http://127.0.0.1:${port}/`);
expect(await response.text()).toBe("ok");
});
it("leaves a fixed port alone", async () => {
installSafePorts();
server = http.createServer();
await new Promise<void>((resolve) => server!.listen(0, resolve));
const { port } = server.address() as AddressInfo;
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = http.createServer();
await new Promise<void>((resolve) => server!.listen(port, resolve));
expect((server.address() as AddressInfo).port).toBe(port);
});
});
@@ -395,3 +395,22 @@ describe("service registry", () => {
expect(error.message).toContain("provider");
});
});
describe("provider clashes", () => {
it("refuses a second plugin answering to the same service and name", async () => {
register();
expect(() => register({ pluginId: "other-plugin" })).toThrow(
/already provided by provider-plugin/,
);
const { listRegistrationConflicts } =
await import("../../plugins/conflicts.js");
expect(listRegistrationConflicts()).toContainEqual(
expect.objectContaining({ kind: "service", pluginId: "other-plugin" }),
);
});
it("lets a plugin replace its own registration", () => {
register();
expect(() => register()).not.toThrow();
});
});
@@ -372,3 +372,83 @@ describe("settings field permissions", () => {
expect(errors).toEqual([]);
});
});
describe("host field options", () => {
const host = (fields: unknown[], extra: Record<string, unknown> = {}) =>
withSettings({
admin: [
{ key: "defaultOn", type: "boolean", labelKey: "k" },
{ key: "token", type: "secret", labelKey: "k" },
],
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
});
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
expect(
validateManifest(
host(
[
{
key: "mode",
type: "boolean",
labelKey: "k",
defaultFrom: "defaultOn",
shareRead: "edit",
ownerOnly: true,
},
],
{ enableDefault: true },
),
),
).toEqual([]);
});
it("rejects a defaultFrom that names a secret or unknown admin field", () => {
for (const defaultFrom of ["token", "missing"]) {
expect(
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom }]),
).join(),
).toMatch(/defaultFrom/);
}
});
it("rejects an unknown shareRead level and host options on admin fields", () => {
expect(
validateManifest(
host([
{ key: "mode", type: "boolean", labelKey: "k", shareRead: "all" },
]),
).join(),
).toMatch(/shareRead/);
expect(
validateManifest(
withSettings({
admin: [
{ key: "a", type: "boolean", labelKey: "k", ownerOnly: true },
],
}),
).join(),
).toMatch(/only valid on host fields/);
});
});
describe("contributes.http.legacyPaths", () => {
it("accepts a path under the plugin's own id", () => {
expect(
validateManifest(
base({
contributes: { http: { legacyPaths: ["/sample-plugin/hook"] } },
}),
),
).toEqual([]);
});
it("rejects a path outside the plugin's id", () => {
expect(
validateManifest(
base({ contributes: { http: { legacyPaths: ["/users/login"] } } }),
).join(),
).toMatch(/must start with "\/sample-plugin\/"/);
});
});
@@ -17,6 +17,11 @@ const state = vi.hoisted(() => ({
userId: "user-1",
permissions: new Set<string>(),
hostEditAccess: true,
hostOwner: true,
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
runStatement: vi.fn(async () => {}),
}));
vi.mock("../../utils/logger.js", () => {
@@ -67,7 +72,7 @@ vi.mock("../../utils/permission-manager.js", () => ({
},
canAccessHost: async () => ({
hasAccess: state.hostEditAccess,
isOwner: state.hostEditAccess,
isOwner: state.hostEditAccess && state.hostOwner,
isShared: false,
}),
}),
@@ -115,7 +120,10 @@ const MANIFEST: PluginManifest = {
host: {
enableKey: "enableThing",
enableLabelKey: "k",
fields: [{ key: "port", type: "number", labelKey: "k" }],
fields: [
{ key: "port", type: "number", labelKey: "k" },
{ key: "profile", type: "string", labelKey: "k", ownerOnly: true },
],
},
},
},
@@ -214,6 +222,7 @@ beforeEach(async () => {
state.userId = "user-1";
state.permissions = new Set<string>();
state.hostEditAccess = true;
state.hostOwner = true;
const app = express();
app.use(express.json());
@@ -440,6 +449,40 @@ describe("user scope", () => {
});
describe("host scope", () => {
it("keeps an owner-only field from a shared editor", async () => {
state.hostOwner = false;
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ profile: "other", port: 22 }),
});
expect(write.status).toBe(400);
expect((await write.json()).errors.profile).toMatch(/owner/);
expect(rows).toHaveLength(0);
state.hostOwner = true;
const owner = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ profile: "mine" }),
});
expect(owner.status).toBe(200);
});
it("runs the plugin's validators before writing anything", async () => {
const { onSettingsValidate, clearSettingsListeners } =
await import("../../plugins/settings.js");
onSettingsValidate("sample", "host", (values) =>
Number(values.port) > 1000 ? { port: "Too high" } : undefined,
);
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
body: JSON.stringify({ enableThing: true, port: 5000 }),
});
expect(write.status).toBe(400);
expect((await write.json()).errors).toEqual({ port: "Too high" });
expect(rows).toHaveLength(0);
clearSettingsListeners("sample");
});
it("lets a user with edit access read and write", async () => {
const write = await call("/plugins/sample/settings/host/7", {
method: "PUT",
@@ -72,12 +72,24 @@ const settingsRepository = {
rows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
),
listByKey: vi.fn(async (pluginId: string, scope: string, key: string) =>
rows.filter(
(row) =>
row.pluginId === pluginId && row.scope === scope && row.key === key,
),
),
};
const hostOwners = new Map<number, string>();
const coreSettings = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => settingsRepository,
createCurrentHostRepository: () => ({
findById: async (id: number) =>
hostOwners.has(id) ? { id, userId: hostOwners.get(id) } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
@@ -106,9 +118,12 @@ const {
findField,
getAllSettings,
getSetting,
listHostValues,
onSettingsChange,
onSettingsValidate,
readCoreSetting,
setSetting,
validateSettingsSave,
} = await import("../../plugins/settings.js");
function manifest(settings: unknown): PluginManifest {
@@ -440,3 +455,86 @@ describe("readCoreSetting", () => {
}
});
});
describe("settings validators", () => {
it("collects every validator's errors for its scope only", async () => {
const stop = onSettingsValidate("validated", "admin", (values) =>
values.url === "bad" ? { url: "Nope" } : undefined,
);
onSettingsValidate("validated", "user", () => ({ other: "Wrong scope" }));
expect(
await validateSettingsSave("validated", "admin", null, { url: "bad" }),
).toEqual({ url: "Nope" });
expect(
await validateSettingsSave("validated", "admin", null, { url: "ok" }),
).toEqual({});
stop();
clearSettingsListeners("validated");
expect(await validateSettingsSave("validated", "user", null, {})).toEqual(
{},
);
});
it("passes the host id to a host validator", async () => {
let seen: number | undefined;
onSettingsValidate("validated-host", "host", (_values, context) => {
seen = context.hostId;
});
await validateSettingsSave("validated-host", "host", "12", {});
expect(seen).toBe(12);
clearSettingsListeners("validated-host");
});
});
describe("listHostValues", () => {
const withHostField = manifest({
host: {
enableKey: "on",
enableLabelKey: "x",
fields: [{ key: "token", type: "secret", labelKey: "x" }],
},
});
it("lists stored values with each host's owner", async () => {
hostOwners.set(7, "alice");
hostOwners.set(8, "bob");
await setSetting(withHostField, "host", 7, "on", true);
await setSetting(withHostField, "host", 8, "on", false);
await setSetting(withHostField, "host", 99, "on", true);
const listed = await listHostValues(withHostField, "on");
expect(listed).toEqual(
expect.arrayContaining([
{ hostId: 7, userId: "alice", value: true },
{ hostId: 8, userId: "bob", value: false },
]),
);
// A row for a host that no longer exists is skipped.
expect(listed.find((entry) => entry.hostId === 99)).toBeUndefined();
});
it("refuses a secret field", async () => {
await expect(listHostValues(withHostField, "token")).rejects.toThrow(
/secret/,
);
});
});
describe("enableDefault", () => {
it("is what the host switch reads before a host saves it", () => {
const fields = declaredFields(
manifest({
host: {
enableKey: "on",
enableLabelKey: "x",
enableDefault: true,
fields: [],
},
}),
"host",
);
expect(fields[0]).toMatchObject({ key: "on", default: true });
});
});
@@ -0,0 +1,67 @@
/**
* The CLI's ?url import: the file lands under dist/assets/ and the import
* becomes a URL relative to the bundle, which /plugin-assets/<id>/ serves.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import * as esbuild from "esbuild";
import { afterEach, describe, expect, it } from "vitest";
// @ts-expect-error the CLI is plain ESM without types
import { staticUrlImports } from "../../../../packages/plugin-sdk/cli/lib/static-url-imports.mjs";
const dirs: string[] = [];
function tempDir() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-url-"));
dirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of dirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
async function bundle(entrySource: string, files: Record<string, string>) {
const dir = tempDir();
for (const [name, contents] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), contents);
}
fs.writeFileSync(path.join(dir, "entry.js"), entrySource);
const outDir = path.join(dir, "dist");
const result = await esbuild.build({
entryPoints: [path.join(dir, "entry.js")],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
write: true,
logLevel: "silent",
plugins: [staticUrlImports({ outDir })],
});
return { outDir, result };
}
describe("staticUrlImports", () => {
it("copies a worker next to the bundle and imports its URL", async () => {
const { outDir } = await bundle(
`import url from "./worker.mjs?url"; export default url;`,
{ "worker.mjs": "self.onmessage = () => {};" },
);
const assets = fs.readdirSync(path.join(outDir, "assets"));
expect(assets).toHaveLength(1);
expect(assets[0]).toMatch(/^worker-[0-9a-f]{8}\.js$/);
const code = fs.readFileSync(path.join(outDir, "frontend.js"), "utf8");
expect(code).toContain(`new URL("./assets/${assets[0]}", import.meta.url)`);
});
it("refuses a file the asset route would not serve", async () => {
await expect(
bundle(`import url from "./image.png?url"; export default url;`, {
"image.png": "not really",
}),
).rejects.toThrow(/only script files/);
});
});
@@ -0,0 +1,268 @@
/**
* The quick upgrade check: a 2.8 database with rows in the tables plugins
* adopt, host columns plugins took over and settings keys they moved boots
* into 2.9.0, and the data reads back through the plugins' own APIs.
*/
import express from "express";
import request from "supertest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
assertPluginsBuilt,
bootCore,
create28Database,
type BootedCore,
} from "./built-harness.js";
const BOOT_TIMEOUT = 180_000;
const USER = "user-upgrade";
let core: BootedCore;
let app: express.Express;
let token: string;
const ids: Record<string, number> = {};
function seed() {
const { sqlite, insert } = create28Database();
insert("users", {
id: USER,
username: "admin",
password_hash: "not-a-real-hash",
is_admin: 1,
});
// A host that changed every plugin-owned switch away from its default...
ids.custom = insert("ssh_data", {
user_id: USER,
name: "custom",
ip: "10.0.0.1",
port: 22,
username: "root",
auth_type: "password",
enable_terminal: 1,
enable_command_history: 0,
enable_terminal_toolbar: 0,
enable_session_logging: 0,
allow_session_sharing: 0,
enable_file_manager: 0,
default_path: "/srv/data",
scp_legacy: 1,
enable_tunnel: 1,
tunnel_connections: [
{
sourcePort: 8080,
endpointPort: 80,
endpointHost: "custom",
autoStart: false,
},
],
enable_web_ui: 1,
web_ui_config: {
endpoints: [
{ id: "grafana", label: "Grafana", port: 3000, protocol: "http" },
],
},
enable_tmux_monitor: 1,
enable_docker: 1,
});
// ...and one that never touched them, which must keep 2.8's defaults.
ids.plain = insert("ssh_data", {
user_id: USER,
name: "plain",
ip: "10.0.0.2",
port: 22,
username: "root",
auth_type: "password",
});
insert("snippets", {
user_id: USER,
name: "upgrade-snippet",
content: "uptime",
});
insert("fleets", { user_id: USER, name: "upgrade-fleet" });
insert("user_workspaces", {
user_id: USER,
name: "upgrade-workspace",
payload: {},
});
insert("network_topology", {
user_id: USER,
topology: { nodes: [{ id: "upgrade-node" }], edges: [] },
});
insert("automations", {
user_id: USER,
name: "upgrade-automation",
enabled: 0,
definition: {
version: 1,
trigger: { type: "manual" },
steps: [],
},
});
insert("homepage_items", {
user_id: USER,
type_id: "clock",
title: "upgrade-widget",
config: {},
});
insert("vault_profiles", {
user_id: USER,
name: "upgrade-vault",
vault_addr: "https://vault.example.com",
ssh_role: "admin",
});
insert("termix_identities", { user_id: USER, handle: "upgrade-handle" });
insert("sso_providers", {
name: "upgrade-sso",
type: "oidc",
config: {
issuerUrl: "https://id.example.com",
clientId: "termix",
clientSecret: "secret",
},
});
insert("settings", { key: "terminal_session_timeout_minutes", value: "45" });
insert("settings", { key: "step_ca_url", value: "https://ca.example.com" });
return sqlite;
}
beforeAll(async () => {
assertPluginsBuilt();
vi.resetModules();
core = await bootCore(seed());
const { AuthManager } = await import("../../utils/auth-manager.js");
const auth = AuthManager.getInstance();
await auth.registerUser(USER);
await auth.unlockWithSystemKey(USER);
token = await auth.generateJWTToken(USER, {
deviceType: "web",
deviceInfo: "upgrade test",
});
const { mountPluginApi } =
await import("../../database/routes/plugin-api-routes.js");
const { default: pluginRoutes } =
await import("../../database/routes/plugins.js");
app = express();
app.use(express.json());
app.use("/plugins", pluginRoutes);
mountPluginApi(app);
}, BOOT_TIMEOUT);
afterAll(async () => {
await core?.shutdown();
}, BOOT_TIMEOUT);
function get(path: string) {
return request(app).get(path).set("Authorization", `Bearer ${token}`);
}
async function hostSettings(pluginId: string, hostId: number) {
const response = await get(`/plugins/${pluginId}/settings/host/${hostId}`);
expect(response.status, `${pluginId} host settings`).toBe(200);
return response.body.values as Record<string, unknown>;
}
describe("upgrading a 2.8 database", () => {
it("renames every adopted table the 2.8 schema had", () => {
const tables = new Set(
(
core.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.all() as Array<{ name: string }>
).map((row) => row.name),
);
for (const legacy of [
"snippets",
"fleets",
"user_workspaces",
"network_topology",
"automations",
"homepage_items",
"vault_profiles",
"termix_identities",
"sso_providers",
]) {
expect(tables.has(legacy), `${legacy} was adopted`).toBe(false);
}
});
it("reads the adopted rows back through each plugin's routes", async () => {
const checks: Array<[string, string]> = [
["/plugin-api/snippets/", "upgrade-snippet"],
["/plugin-api/fleets/", "upgrade-fleet"],
["/plugin-api/workspaces/", "upgrade-workspace"],
["/plugin-api/network-topology/", "upgrade-node"],
["/plugin-api/automations/", "upgrade-automation"],
["/plugin-api/homepage/items", "upgrade-widget"],
["/plugin-api/vault/profiles", "upgrade-vault"],
["/plugin-api/termix-identity/me", "upgrade-handle"],
["/plugin-api/sso/providers", "upgrade-sso"],
];
for (const [path, marker] of checks) {
const response = await get(path);
expect(response.status, path).toBe(200);
expect(JSON.stringify(response.body), path).toContain(marker);
}
});
it("moves every changed host switch into its plugin's host settings", async () => {
expect(await hostSettings("ssh-terminal", ids.custom)).toMatchObject({
enableTerminal: true,
enableCommandHistory: false,
enableTerminalToolbar: false,
});
expect(await hostSettings("session-recording", ids.custom)).toMatchObject({
enableSessionRecording: false,
});
expect(await hostSettings("session-sharing", ids.custom)).toMatchObject({
allowSessionSharing: false,
});
expect(await hostSettings("file-manager", ids.custom)).toMatchObject({
enableFileManager: false,
defaultPath: "/srv/data",
scpLegacy: true,
});
const tunnels = await hostSettings("tunnels", ids.custom);
expect(tunnels.enableTunnel).toBe(true);
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
const web = await hostSettings("web-endpoint", ids.custom);
expect(web.enableWebUi).toBe(true);
expect(JSON.stringify(web.webUiConfig)).toContain("Grafana");
expect(await hostSettings("tmux-monitor", ids.custom)).toMatchObject({
enableTmuxMonitor: true,
});
expect(await hostSettings("docker", ids.custom)).toMatchObject({
enableDocker: true,
});
});
it("keeps 2.8's defaults for a host that never changed them", async () => {
expect(await hostSettings("file-manager", ids.plain)).toMatchObject({
enableFileManager: true,
});
expect(await hostSettings("ssh-terminal", ids.plain)).toMatchObject({
enableTerminal: true,
enableCommandHistory: true,
});
expect(await hostSettings("session-recording", ids.plain)).toMatchObject({
enableSessionRecording: true,
});
expect(await hostSettings("session-sharing", ids.plain)).toMatchObject({
allowSessionSharing: true,
});
});
it("moves core settings keys into plugin settings", async () => {
const terminal = await get("/plugins/ssh-terminal/settings/admin");
expect(terminal.status).toBe(200);
expect(terminal.body.values.sessionTimeoutMinutes).toBe(45);
const stepCa = await get("/plugins/step-ca/settings/admin");
expect(stepCa.status).toBe(200);
expect(stepCa.body.values.caUrl).toBe("https://ca.example.com");
});
});
+9
View File
@@ -340,3 +340,12 @@ describe("parsePluginWsUrl", () => {
expect(ws.parsePluginWsUrl(undefined)).toBeNull();
});
});
describe("ctx.ws clashes", () => {
it("refuses the same path twice", () => {
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED);
expect(() =>
ws.registerPluginWsRoute("sample-plugin", "/twice", () => {}, DECLARED),
).toThrow(/already registered/);
});
});