mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
chore: move plugins to their own repos and drop the plugins folder
This commit is contained in:
1 parent
29a946d02e
commit
e02b5fe6e4
3167 files changed
+286
-440749
No files matched your search
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
import { DATA_DIR_TOKEN } from "../fixtures/upgrade/rows.js";
|
||||
import { remoteTestDialect, seedRemote28 } from "./remote-seed.js";
|
||||
|
||||
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");
|
||||
/** The committed 2.8 install: db.sqlite, the fixture.env it was keyed with and its files. */
|
||||
export const UPGRADE_FIXTURE_DIR = path.join(__dirname, "../fixtures/upgrade");
|
||||
|
||||
/** 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; null on Postgres and MySQL. */
|
||||
sqlite: Database.Database | null;
|
||||
/** The 2.8 database file exactly as it was written to DATA_DIR, if one was. */
|
||||
seed: Buffer | null;
|
||||
/** keepDataDir writes the database to disk and leaves DATA_DIR for another boot. */
|
||||
shutdown: (options?: { keepDataDir?: boolean }) => Promise<void>;
|
||||
}
|
||||
|
||||
/** A 2.8 data directory to boot: a seeded database, or a folder holding one. */
|
||||
export type BootSource =
|
||||
Database.Database | { fixtureDir: string } | { reuseDataDir: string };
|
||||
|
||||
/**
|
||||
* Lays a committed 2.8 install (fixtures/upgrade) into DATA_DIR: its key file
|
||||
* and the files its rows point at, with the fixture's DATA_DIR token in those
|
||||
* rows swapped for the real path, as 2.8 stored them absolute.
|
||||
*/
|
||||
function placeFixture(fixtureDir: string, dataDir: string): Buffer {
|
||||
fs.copyFileSync(
|
||||
path.join(fixtureDir, "fixture.env"),
|
||||
path.join(dataDir, ".env"),
|
||||
);
|
||||
fs.cpSync(path.join(fixtureDir, "files"), dataDir, { recursive: true });
|
||||
|
||||
const sqlite = new Database(
|
||||
fs.readFileSync(path.join(fixtureDir, "db.sqlite")),
|
||||
);
|
||||
try {
|
||||
sqlite
|
||||
.prepare(
|
||||
"UPDATE session_recordings SET recording_path = replace(recording_path, ?, ?)",
|
||||
)
|
||||
.run(DATA_DIR_TOKEN, dataDir.split(path.sep).join("/"));
|
||||
return sqlite.serialize();
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
const SYSTEM_SECRETS = [
|
||||
"JWT_SECRET",
|
||||
"DATABASE_KEY",
|
||||
"ENCRYPTION_KEY",
|
||||
"INTERNAL_AUTH_TOKEN",
|
||||
];
|
||||
|
||||
/**
|
||||
* Writes the seeded database into a fresh DATA_DIR and runs starter.ts's boot
|
||||
* sequence up to, not including, opening the HTTP server: the pre-upgrade
|
||||
* backup, the database, core's migrations, then the plugins and their data
|
||||
* moves. Call vi.resetModules() before, so module state from other tests is
|
||||
* gone.
|
||||
*/
|
||||
export async function bootCore(source: BootSource): Promise<BootedCore> {
|
||||
const dataDir =
|
||||
"reuseDataDir" in source
|
||||
? source.reuseDataDir
|
||||
: 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;
|
||||
// SystemCrypto prefers these over DATA_DIR/.env, and a previous boot in
|
||||
// this worker set them.
|
||||
for (const key of SYSTEM_SECRETS) delete process.env[key];
|
||||
|
||||
let seed: Buffer | null = null;
|
||||
if (source instanceof Database) {
|
||||
seed = source.serialize();
|
||||
} else if ("fixtureDir" in source) {
|
||||
seed = placeFixture(source.fixtureDir, dataDir);
|
||||
}
|
||||
|
||||
// TEST_DIALECT=postgres|mysql puts the same 2.8 data on a real server.
|
||||
const remote = remoteTestDialect();
|
||||
if (remote) {
|
||||
const url = process.env.TEST_DATABASE_URL;
|
||||
if (!url) throw new Error(`TEST_DIALECT=${remote} needs TEST_DATABASE_URL`);
|
||||
process.env.DATABASE_DIALECT = remote;
|
||||
process.env.DATABASE_URL = url;
|
||||
if (seed) await seedRemote28(remote, url, seed);
|
||||
} else if (seed) {
|
||||
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed);
|
||||
}
|
||||
|
||||
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 { backupBeforeUpgrade, runCoreBootMigrations } =
|
||||
await import("../../boot.js");
|
||||
await backupBeforeUpgrade({ dataDir, version: "2.9.0-test" });
|
||||
|
||||
const db = await import("../../database/db/index.js");
|
||||
await db.initializeDatabase();
|
||||
|
||||
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("../../upgrade/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
|
||||
return {
|
||||
dataDir,
|
||||
sqlite: remote ? null : db.getSqlite(),
|
||||
seed,
|
||||
shutdown: async ({ keepDataDir = false } = {}) => {
|
||||
await shutdownPlugins();
|
||||
if (keepDataDir) {
|
||||
if (!remote) {
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, "db.sqlite"),
|
||||
db.getSqlite().serialize(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,153 +0,0 @@
|
||||
/**
|
||||
* The bundled plugins, as they ship.
|
||||
*
|
||||
* This reads plugins/ in the repo rather than a fixture: the manifests are
|
||||
* checked-in artifacts, and a typo in one would otherwise only show up when
|
||||
* the server tried to boot.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { getBundledPluginsDir } from "../../plugins/paths.js";
|
||||
import { parseManifest } from "../../plugins/manifest.js";
|
||||
import { isKnownCapability } from "@termix/plugin-sdk/capabilities";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
pluginLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const BUNDLED_DIR = getBundledPluginsDir();
|
||||
|
||||
function bundledIds(): string[] {
|
||||
return fs
|
||||
.readdirSync(BUNDLED_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.filter((id) => fs.existsSync(path.join(BUNDLED_DIR, id, "manifest.json")));
|
||||
}
|
||||
|
||||
function readManifest(pluginId: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(BUNDLED_DIR, pluginId, "manifest.json"), "utf8"),
|
||||
);
|
||||
}
|
||||
|
||||
const ids = bundledIds();
|
||||
|
||||
describe("bundled plugins", () => {
|
||||
it("finds the bundled plugin directory", () => {
|
||||
expect(ids.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(ids)("%s has a manifest that parses", (id) => {
|
||||
const { manifest, errors } = parseManifest(readManifest(id));
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.id).toBe(id);
|
||||
});
|
||||
|
||||
it.each(ids)("%s declares only catalog capabilities", (id) => {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
|
||||
for (const capability of manifest?.capabilities ?? []) {
|
||||
expect(isKnownCapability(capability)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(ids)("%s ships the entry points its manifest names", (id) => {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
const dir = path.join(BUNDLED_DIR, id);
|
||||
|
||||
expect(fs.existsSync(path.join(dir, manifest!.backend!))).toBe(true);
|
||||
expect(fs.existsSync(path.join(dir, manifest!.frontend!))).toBe(true);
|
||||
});
|
||||
|
||||
// process:transport-owner was the v1 marker for the in-process tier. There
|
||||
// is one tier now, so nothing should still be asking for it.
|
||||
it.each(ids)("%s does not declare a v1 permission", (id) => {
|
||||
const raw = readManifest(id);
|
||||
|
||||
expect(raw.permissions).toBeUndefined();
|
||||
expect(raw.sidecars).toBeUndefined();
|
||||
expect(JSON.stringify(raw)).not.toContain("transport-owner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("declared dependencies", () => {
|
||||
it("names a plugin that is actually bundled", () => {
|
||||
for (const id of ids) {
|
||||
const { manifest } = parseManifest(readManifest(id));
|
||||
for (const dependency of Object.keys(manifest?.dependencies ?? {})) {
|
||||
expect(ids).toContain(dependency);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ai reaches automations through the automations.access service, so it
|
||||
// keeps working when automations is off.
|
||||
it("keeps ai's use of automations optional", () => {
|
||||
if (!ids.includes("ai")) return;
|
||||
|
||||
const { manifest } = parseManifest(readManifest("ai"));
|
||||
|
||||
expect(manifest?.dependencies?.automations).toBeUndefined();
|
||||
expect(manifest?.optionalDependencies?.automations).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugins that own a port", () => {
|
||||
// Every one of these had its server created at module scope at some point.
|
||||
// The rule is that it belongs inside activate, or disable leaves the port
|
||||
// held and re-enable fails.
|
||||
it.each(["ssh-terminal", "docker", "host-metrics", "remote-desktop"])(
|
||||
"%s creates no listener at module scope",
|
||||
(id) => {
|
||||
if (!ids.includes(id)) return;
|
||||
|
||||
const entry = path.join(BUNDLED_DIR, id, "src", "backend", "index.ts");
|
||||
const source = fs.readFileSync(entry, "utf8");
|
||||
const moduleScope = source.slice(
|
||||
0,
|
||||
source.indexOf("export async function activate"),
|
||||
);
|
||||
|
||||
expect(moduleScope).not.toMatch(/\.listen\(/);
|
||||
expect(moduleScope).not.toMatch(/new WebSocketServer/);
|
||||
},
|
||||
);
|
||||
|
||||
it("docker does not register a process signal handler", () => {
|
||||
if (!ids.includes("docker")) return;
|
||||
|
||||
// Core owns shutdown: gracefulShutdown calls shutdownPlugins, which runs
|
||||
// every plugin's deactivate. A plugin calling process.exit() itself
|
||||
// skipped the rest of that sequence.
|
||||
for (const file of ["index.ts", "console.ts"]) {
|
||||
const candidate = path.join(
|
||||
BUNDLED_DIR,
|
||||
"docker",
|
||||
"src",
|
||||
"backend",
|
||||
file,
|
||||
);
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
|
||||
// Comments are stripped first: the rule is worth explaining in prose
|
||||
// right where it applies, and that prose names the thing it forbids.
|
||||
const code = fs
|
||||
.readFileSync(candidate, "utf8")
|
||||
.replace(/\/\*[\s\S]*?\*\//g, "")
|
||||
.replace(/^\s*\/\/.*$/gm, "");
|
||||
|
||||
expect(code).not.toMatch(/process\.on\(\s*["']SIG/);
|
||||
expect(code).not.toMatch(/process\.exit\(/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,8 +9,6 @@
|
||||
|
||||
import type { PluginLoader } from "../../plugins/loader.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Fixture } from "./fixture-plugin.js";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
@@ -355,83 +353,6 @@ describe("the duplicate ai group is gone", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("shipped manifests keep their permission ids", () => {
|
||||
const root = path.resolve(import.meta.dirname, "../../../../plugins");
|
||||
|
||||
function idsFor(pluginId: string): string[] {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(path.join(root, pluginId, "manifest.json"), "utf8"),
|
||||
);
|
||||
const { manifest: parsed, errors } = parseManifest(raw);
|
||||
expect(errors).toEqual([]);
|
||||
return (parsed?.contributes?.permissions ?? []).map((entry) =>
|
||||
qualifyPermission(pluginId, entry.name),
|
||||
);
|
||||
}
|
||||
|
||||
// A role holding these today must keep working, which is only true because
|
||||
// <pluginId>.<name> reproduces the old id byte for byte.
|
||||
it("ai", () => {
|
||||
expect(idsFor("ai")).toEqual([
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
"ai.services.use",
|
||||
"ai.secrets.share",
|
||||
"ai.agents",
|
||||
]);
|
||||
});
|
||||
|
||||
it("automations", () => {
|
||||
expect(idsFor("automations")).toEqual([
|
||||
"automations.view",
|
||||
"automations.create",
|
||||
"automations.edit",
|
||||
"automations.delete",
|
||||
"automations.run",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tailscale", () => {
|
||||
expect(idsFor("tailscale")).toEqual(["tailscale.devices.view"]);
|
||||
});
|
||||
|
||||
it("snippets", () => {
|
||||
expect(idsFor("snippets")).toEqual([
|
||||
"snippets.view",
|
||||
"snippets.create",
|
||||
"snippets.edit",
|
||||
"snippets.delete",
|
||||
"snippets.share",
|
||||
]);
|
||||
});
|
||||
|
||||
it("a role holding the old ids still validates once registered", () => {
|
||||
for (const pluginId of ["ai", "automations", "tailscale", "snippets"]) {
|
||||
const ids = idsFor(pluginId);
|
||||
registerPluginPermissions({
|
||||
group: pluginId,
|
||||
pluginId,
|
||||
label: pluginId,
|
||||
permissions: ids,
|
||||
});
|
||||
}
|
||||
|
||||
const stored = [
|
||||
"ai.use",
|
||||
"ai.manage_providers",
|
||||
"ai.apply_proposals",
|
||||
"automations.run",
|
||||
"automations.view",
|
||||
"tailscale.devices.view",
|
||||
"snippets.view",
|
||||
"snippets.share",
|
||||
];
|
||||
|
||||
expect(stored.filter((entry) => !isValidPermission(entry))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("role defaults apply exactly once", () => {
|
||||
let fixture: Fixture;
|
||||
let loader: PluginLoader;
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
/**
|
||||
* Puts a 2.8 install on a real Postgres or MySQL server for the upgrade test:
|
||||
* wipes the database, applies release-2.8.0-tag's own drizzle migrations
|
||||
* (UPGRADE_LEGACY_MIGRATIONS_DIR, which scripts/upgrade-check.sh extracts
|
||||
* from git), then copies every row of the SQLite fixture across. After that
|
||||
* the database is exactly what a 2.8 server left behind, and the normal boot
|
||||
* takes it the rest of the way.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { sql, type SQL } from "drizzle-orm";
|
||||
import { ROWS } from "../fixtures/upgrade/rows.js";
|
||||
|
||||
export type RemoteDialect = "postgres" | "mysql";
|
||||
|
||||
type Remote = { execute: (query: SQL) => Promise<unknown> };
|
||||
|
||||
/** TEST_DIALECT, the same switch the repository tests use. */
|
||||
export function remoteTestDialect(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): RemoteDialect | null {
|
||||
const value = env.TEST_DIALECT?.trim().toLowerCase();
|
||||
return value === "postgres" || value === "mysql" ? value : null;
|
||||
}
|
||||
|
||||
async function rows<T>(db: Remote, query: SQL): Promise<T[]> {
|
||||
const result = (await db.execute(query)) as { rows?: T[] } | T[];
|
||||
if (Array.isArray(result)) {
|
||||
return (Array.isArray(result[0]) ? result[0] : result) as T[];
|
||||
}
|
||||
return result.rows ?? [];
|
||||
}
|
||||
|
||||
async function wipe(db: Remote, dialect: RemoteDialect): Promise<void> {
|
||||
if (dialect === "postgres") {
|
||||
await db.execute(sql.raw("DROP SCHEMA IF EXISTS drizzle CASCADE"));
|
||||
await db.execute(sql.raw("DROP SCHEMA public CASCADE"));
|
||||
await db.execute(sql.raw("CREATE SCHEMA public"));
|
||||
return;
|
||||
}
|
||||
const [{ name }] = await rows<{ name: string }>(
|
||||
db,
|
||||
sql.raw("SELECT DATABASE() AS name"),
|
||||
);
|
||||
await db.execute(sql.raw(`DROP DATABASE \`${name}\``));
|
||||
await db.execute(sql.raw(`CREATE DATABASE \`${name}\``));
|
||||
await db.execute(sql.raw(`USE \`${name}\``));
|
||||
}
|
||||
|
||||
async function columnTypes(
|
||||
db: Remote,
|
||||
dialect: RemoteDialect,
|
||||
table: string,
|
||||
): Promise<Map<string, string>> {
|
||||
const schema =
|
||||
dialect === "postgres" ? sql`current_schema()` : sql`DATABASE()`;
|
||||
const found = await rows<{ name: string; type: string }>(
|
||||
db,
|
||||
sql`SELECT column_name AS name, data_type AS type FROM information_schema.columns WHERE table_schema = ${schema} AND table_name = ${table}`,
|
||||
);
|
||||
return new Map(found.map((column) => [column.name, column.type]));
|
||||
}
|
||||
|
||||
async function resyncSequence(db: Remote, table: string): Promise<void> {
|
||||
const [sequence] = await rows<{ name: string | null }>(
|
||||
db,
|
||||
sql.raw(
|
||||
`SELECT pg_get_serial_sequence('"${table}"', 'id') AS name FROM information_schema.columns ` +
|
||||
`WHERE table_schema = current_schema() AND table_name = '${table}' AND column_name = 'id'`,
|
||||
),
|
||||
);
|
||||
if (!sequence?.name) return;
|
||||
await db.execute(
|
||||
sql.raw(
|
||||
`SELECT setval('${sequence.name}', COALESCE((SELECT MAX(id) FROM "${table}"), 0) + 1, false)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function seedRemote28(
|
||||
dialect: RemoteDialect,
|
||||
url: string,
|
||||
fixture: Buffer,
|
||||
): Promise<void> {
|
||||
const legacyMigrations = process.env.UPGRADE_LEGACY_MIGRATIONS_DIR;
|
||||
if (!legacyMigrations) {
|
||||
throw new Error(
|
||||
"UPGRADE_LEGACY_MIGRATIONS_DIR must point at release-2.8.0-tag's drizzle folder. Run scripts/upgrade-check.sh.",
|
||||
);
|
||||
}
|
||||
|
||||
const { drizzle } =
|
||||
dialect === "postgres"
|
||||
? await import("drizzle-orm/node-postgres")
|
||||
: await import("drizzle-orm/mysql2");
|
||||
const db = (
|
||||
drizzle as unknown as (
|
||||
config: unknown,
|
||||
) => Remote & { $client: { end: () => Promise<void> } }
|
||||
)(
|
||||
dialect === "postgres"
|
||||
? { connection: url }
|
||||
: { connection: { uri: url, connectionLimit: 1 } },
|
||||
);
|
||||
const source = new Database(fixture, { readonly: true });
|
||||
|
||||
try {
|
||||
await wipe(db, dialect);
|
||||
|
||||
const { runRemoteMigrations } =
|
||||
await import("../../database/db/migrate.js");
|
||||
await runRemoteMigrations(dialect, db as never, {
|
||||
DRIZZLE_MIGRATIONS_DIR: legacyMigrations,
|
||||
});
|
||||
|
||||
for (const table of Object.keys(ROWS)) {
|
||||
const types = await columnTypes(db, dialect, table);
|
||||
if (types.size === 0) {
|
||||
throw new Error(`2.8's ${dialect} schema has no ${table} table`);
|
||||
}
|
||||
for (const row of source
|
||||
.prepare(`SELECT * FROM "${table}"`)
|
||||
.all() as Array<Record<string, unknown>>) {
|
||||
const columns = Object.keys(row).filter(
|
||||
(column) => types.has(column) && row[column] !== null,
|
||||
);
|
||||
const values = columns.map((column) =>
|
||||
types.get(column) === "boolean" ? Boolean(row[column]) : row[column],
|
||||
);
|
||||
await db.execute(
|
||||
sql`INSERT INTO ${sql.identifier(table)} (${sql.join(
|
||||
columns.map((column) => sql.identifier(column)),
|
||||
sql`, `,
|
||||
)}) VALUES (${sql.join(
|
||||
values.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
if (dialect === "postgres") await resyncSequence(db, table);
|
||||
}
|
||||
} finally {
|
||||
source.close();
|
||||
await db.$client.end();
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const notes = adoptLegacyTable(
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-sdk-testing-"));
|
||||
// The directory name is the plugin id, as it is in plugins/.
|
||||
// The directory name is the plugin id.
|
||||
pluginDir = path.join(root, "fleets");
|
||||
fs.mkdirSync(path.join(pluginDir, "migrations", "sqlite"), {
|
||||
recursive: true,
|
||||
|
||||
@@ -9,13 +9,11 @@ import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectOwnedIndexes,
|
||||
findUnownedTableWrites,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
db: null as unknown,
|
||||
@@ -155,39 +153,6 @@ describe("the migration checker", () => {
|
||||
).not.toEqual([]);
|
||||
});
|
||||
|
||||
it("passes every migration a bundled plugin ships", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugins",
|
||||
);
|
||||
for (const id of fs.readdirSync(plugins)) {
|
||||
const legacy = new Set(
|
||||
Object.entries(LEGACY_TABLE_OWNERS)
|
||||
.filter(([, owner]) => owner === id)
|
||||
.map(([table]) => table),
|
||||
);
|
||||
for (const dialect of ["sqlite", "postgres", "mysql"]) {
|
||||
const dir = path.join(plugins, id, "migrations", dialect);
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
const sqls = files.map((file) =>
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
);
|
||||
// The runner lets a later migration drop an index an earlier one made.
|
||||
const ownedIndexes = collectOwnedIndexes(id, sqls, legacy);
|
||||
files.forEach((file, index) => {
|
||||
expect(
|
||||
findUnownedTableWrites(id, sqls[index], legacy, ownedIndexes),
|
||||
`${id}/${dialect}/${file}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("gives a user plugin no legacy exemption at runtime", () => {
|
||||
const adopt = 'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"';
|
||||
expect(() =>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { PluginLoader, prefixesOverlap } from "../../../plugins/loader.js";
|
||||
import { parseManifest } from "../../../plugins/manifest.js";
|
||||
import { RESERVED_PLUGIN_IDS } from "@termix/plugin-sdk/manifest";
|
||||
@@ -95,22 +94,6 @@ describe("table prefixes", () => {
|
||||
loader.load(path.join(root, "foo-bar"), "user"),
|
||||
).rejects.toThrow(/overlaps "foo"/);
|
||||
});
|
||||
|
||||
it("no two bundled plugins overlap", () => {
|
||||
const plugins = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../../../../plugins",
|
||||
);
|
||||
const ids = fs
|
||||
.readdirSync(plugins, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
for (const a of ids) {
|
||||
for (const b of ids) {
|
||||
expect(prefixesOverlap(a, b), `${a} and ${b}`).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("paths stay inside the plugin", () => {
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* The committed 2.8 fixture still matches rows.ts and still fills every
|
||||
* table a plugin adopts. A new legacy table in LEGACY_TABLE_OWNERS fails here
|
||||
* until it gets fixture rows (npx tsx scripts/build-upgrade-fixture.ts).
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import { FieldCrypto } from "../../utils/field-crypto.js";
|
||||
import { UPGRADE_FIXTURE_DIR } from "./built-harness.js";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
RECORDING_FILES,
|
||||
ROWS,
|
||||
} from "../fixtures/upgrade/rows.js";
|
||||
|
||||
const sqlite = new Database(path.join(UPGRADE_FIXTURE_DIR, "db.sqlite"), {
|
||||
readonly: true,
|
||||
});
|
||||
|
||||
afterAll(() => sqlite.close());
|
||||
|
||||
const count = (table: string) =>
|
||||
(
|
||||
sqlite.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
|
||||
describe("the committed 2.8 upgrade fixture", () => {
|
||||
it("fills every table a plugin adopts", () => {
|
||||
for (const table of Object.keys(LEGACY_TABLE_OWNERS)) {
|
||||
expect(ROWS[table]?.length ?? 0, table).toBeGreaterThan(0);
|
||||
expect(count(table), table).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds exactly the rows in rows.ts", () => {
|
||||
for (const [table, rows] of Object.entries(ROWS)) {
|
||||
// Each user also has a wrapped data key in settings.
|
||||
const extra = table === "settings" ? ROWS.users.length : 0;
|
||||
expect(count(table), table).toBe(rows.length + extra);
|
||||
}
|
||||
});
|
||||
|
||||
it("stores the columns 2.8 encrypted as ciphertext", () => {
|
||||
for (const [table, column] of ENCRYPTED_COLUMNS) {
|
||||
const values = (
|
||||
sqlite
|
||||
.prepare(
|
||||
`SELECT "${column}" AS value FROM "${table}" WHERE "${column}" IS NOT NULL AND "${column}" <> ''`,
|
||||
)
|
||||
.all() as Array<{ value: string }>
|
||||
).map((row) => row.value);
|
||||
for (const value of values) {
|
||||
expect(FieldCrypto.isEncrypted(value), `${table}.${column}`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("ships the recording files its rows point at", () => {
|
||||
for (const relative of Object.values(RECORDING_FILES)) {
|
||||
expect(
|
||||
fs.existsSync(path.join(UPGRADE_FIXTURE_DIR, "files", relative)),
|
||||
relative,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,741 +0,0 @@
|
||||
/**
|
||||
* The 2.8 to 2.9.0 upgrade, end to end. The committed 2.8 install in
|
||||
* fixtures/upgrade boots through the pre-upgrade backup, core's migrations,
|
||||
* every built plugin's migrations and the data moves, and everything it held
|
||||
* reads back through the plugins' routes, their settings and their ctx. Then
|
||||
* the same data directory boots again and nothing changes.
|
||||
*
|
||||
* TEST_DIALECT=postgres|mysql with TEST_DATABASE_URL runs it against a real
|
||||
* server instead (scripts/upgrade-check.sh sets that up).
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { LEGACY_TABLE_OWNERS } from "@termix/plugin-sdk/db";
|
||||
import {
|
||||
assertPluginsBuilt,
|
||||
bootCore,
|
||||
BUILT_PLUGINS_DIR,
|
||||
UPGRADE_FIXTURE_DIR,
|
||||
type BootedCore,
|
||||
} from "./built-harness.js";
|
||||
import { remoteTestDialect } from "./remote-seed.js";
|
||||
import {
|
||||
FIXTURE_PASSWORD,
|
||||
HOST_AUTH_TYPES,
|
||||
HOSTS,
|
||||
LDAP_PROVIDER_ID,
|
||||
OPERATORS_PERMISSIONS,
|
||||
ROWS,
|
||||
SSO_PROVIDER_ID,
|
||||
SYNCED_ENTITY_TYPES_28,
|
||||
TOTP_BACKUP_CODES,
|
||||
TOTP_SECRET,
|
||||
USER_ROLE_PERMISSIONS_28,
|
||||
USERS,
|
||||
} from "../fixtures/upgrade/rows.js";
|
||||
|
||||
const BOOT_TIMEOUT = 240_000;
|
||||
|
||||
type UserKey = keyof typeof USERS;
|
||||
|
||||
interface Booted {
|
||||
core: BootedCore;
|
||||
app: express.Express;
|
||||
tokens: Record<UserKey, string>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/** legacy table -> the p_ table its plugin renamed it to. */
|
||||
function adoptedNames(): Map<string, string> {
|
||||
const names = new Map<string, string>();
|
||||
for (const id of new Set(Object.values(LEGACY_TABLE_OWNERS))) {
|
||||
const dir = path.join(BUILT_PLUGINS_DIR, id, "migrations", "sqlite");
|
||||
for (const file of fs.readdirSync(dir)) {
|
||||
const text = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const match of text.matchAll(
|
||||
/ALTER TABLE "([a-z0-9_]+)" RENAME TO "(p_[a-z0-9_]+)"/g,
|
||||
)) {
|
||||
names.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
async function boot(dataDir?: string): Promise<Booted> {
|
||||
vi.resetModules();
|
||||
const errors: string[] = [];
|
||||
const error = vi.spyOn(console, "error").mockImplementation((...args) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
});
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
let core: BootedCore;
|
||||
try {
|
||||
core = await bootCore(
|
||||
dataDir ? { reuseDataDir: dataDir } : { fixtureDir: UPGRADE_FIXTURE_DIR },
|
||||
);
|
||||
} finally {
|
||||
error.mockRestore();
|
||||
warn.mockRestore();
|
||||
}
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const auth = AuthManager.getInstance();
|
||||
const tokens = {} as Record<UserKey, string>;
|
||||
for (const [key, userId] of Object.entries(USERS)) {
|
||||
await auth.unlockWithSystemKey(userId);
|
||||
tokens[key as UserKey] = await auth.generateJWTToken(userId, {
|
||||
deviceType: "web",
|
||||
deviceInfo: "upgrade test",
|
||||
});
|
||||
}
|
||||
|
||||
const { mountPluginApi } =
|
||||
await import("../../database/routes/plugin-api-routes.js");
|
||||
const { default: pluginRoutes } =
|
||||
await import("../../database/routes/plugins.js");
|
||||
const { default: userRoutes } =
|
||||
await import("../../database/routes/users.js");
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/users", userRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
mountPluginApi(app);
|
||||
|
||||
return { core, app, tokens, errors };
|
||||
}
|
||||
|
||||
function get(booted: Booted, url: string, as: UserKey = "admin") {
|
||||
return request(booted.app)
|
||||
.get(url)
|
||||
.set("Authorization", `Bearer ${booted.tokens[as]}`);
|
||||
}
|
||||
|
||||
async function settings(
|
||||
booted: Booted,
|
||||
pluginId: string,
|
||||
scope: string,
|
||||
as: UserKey = "admin",
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await get(
|
||||
booted,
|
||||
`/plugins/${pluginId}/settings/${scope}`,
|
||||
as,
|
||||
);
|
||||
expect(response.status, `${pluginId} ${scope} settings`).toBe(200);
|
||||
return response.body.values as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A ctx for a running plugin, the one its own code gets. */
|
||||
async function pluginCtx(pluginId: string) {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const { createPluginContext } = await import("../../plugins/ctx.js");
|
||||
const loaded = getPluginRuntime().loader.get(pluginId);
|
||||
if (!loaded?.handle) throw new Error(`${pluginId} is not running`);
|
||||
return createPluginContext(loaded.manifest, loaded.handle);
|
||||
}
|
||||
|
||||
async function asUser<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
||||
const { runAsActor } = await import("../../plugins/actor.js");
|
||||
return runAsActor(userId, "asUser", fn);
|
||||
}
|
||||
|
||||
/** Every row of a plugin table, read through that plugin's ctx.db. */
|
||||
async function pluginRows(
|
||||
pluginId: string,
|
||||
table: string,
|
||||
): Promise<Array<Record<string, unknown>>> {
|
||||
const ctx = await pluginCtx(pluginId);
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const client = await ctx.db.client<any>();
|
||||
const query = sql`SELECT * FROM ${sql.identifier(table)}`;
|
||||
if (typeof client.all === "function") return client.all(query);
|
||||
const result = await client.execute(query);
|
||||
return Array.isArray(result) ? result[0] : result.rows;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
}
|
||||
|
||||
/** Bookkeeping counts, compared between the first and second boot. */
|
||||
async function counts(): Promise<Record<string, number>> {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const out: Record<string, number> = {};
|
||||
const tables = [
|
||||
"plugin_migrations",
|
||||
"plugin_settings",
|
||||
"user_second_factors",
|
||||
"user_external_identities",
|
||||
"roles",
|
||||
"user_roles",
|
||||
"ssh_data",
|
||||
...adoptedNames().values(),
|
||||
];
|
||||
for (const table of tables) {
|
||||
const [row] = await selectRows<{ n: number | string }>(
|
||||
sql`SELECT COUNT(*) AS n FROM ${sql.identifier(table)}`,
|
||||
);
|
||||
out[table] = Number(row.n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function totpCode(secret: string, at = Date.now()): string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
let bits = "";
|
||||
for (const char of secret.replace(/=+$/, "")) {
|
||||
bits += alphabet.indexOf(char).toString(2).padStart(5, "0");
|
||||
}
|
||||
const key = Buffer.from(
|
||||
bits.match(/.{8}/g)!.map((byte) => parseInt(byte, 2)),
|
||||
);
|
||||
const counter = Buffer.alloc(8);
|
||||
counter.writeBigUInt64BE(BigInt(Math.floor(at / 30_000)));
|
||||
const hmac = crypto.createHmac("sha1", key).update(counter).digest();
|
||||
const offset = hmac[hmac.length - 1] & 0xf;
|
||||
const value = (hmac.readUInt32BE(offset) & 0x7fffffff) % 1_000_000;
|
||||
return String(value).padStart(6, "0");
|
||||
}
|
||||
|
||||
/** Everything a 2.8 install had, checked against a booted 2.9.0. */
|
||||
function upgradeChecks(current: () => Booted, bootIndex: number) {
|
||||
it("boots without logging an error", () => {
|
||||
expect(current().errors).toEqual([]);
|
||||
});
|
||||
|
||||
it("activates every plugin", async () => {
|
||||
const { getPluginRuntime } = await import("../../plugins/index.js");
|
||||
const notActive = getPluginRuntime()
|
||||
.loader.list()
|
||||
.filter((plugin) => plugin.state !== "active")
|
||||
.map((plugin) => `${plugin.id}: ${plugin.state} ${plugin.lastError}`);
|
||||
expect(notActive).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every row of every adopted table, readable by its plugin", async () => {
|
||||
const names = adoptedNames();
|
||||
for (const [legacy, pluginId] of Object.entries(LEGACY_TABLE_OWNERS)) {
|
||||
const seeded = ROWS[legacy] ?? [];
|
||||
expect(seeded.length, `the fixture fills ${legacy}`).toBeGreaterThan(0);
|
||||
const table = names.get(legacy);
|
||||
expect(table, `${pluginId} adopts ${legacy}`).toBeDefined();
|
||||
// 2.8 kept LDAP directories in sso_providers; the ldap plugin takes them.
|
||||
const expected =
|
||||
legacy === "sso_providers"
|
||||
? seeded.filter((row) => row.type !== "ldap")
|
||||
: seeded;
|
||||
const rows = await pluginRows(pluginId, table!);
|
||||
const ids = rows.map((row) => String(row.id)).sort();
|
||||
expect(ids, `${legacy} -> ${table}`).toEqual(
|
||||
expected.map((row) => String(row.id)).sort(),
|
||||
);
|
||||
}
|
||||
const ldap = await pluginRows("ldap", "p_ldap_providers");
|
||||
expect(ldap.map((row) => String(row.id))).toEqual([
|
||||
String(LDAP_PROVIDER_ID),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the adopted rows back through each plugin's routes", async () => {
|
||||
const checks: Array<[string, string, UserKey?]> = [
|
||||
["/plugin-api/snippets/", "d4-snippet"],
|
||||
["/plugin-api/snippets/shared", "d4-shared-snippet", "alice"],
|
||||
["/plugin-api/fleets/", "d4-fleet"],
|
||||
["/plugin-api/workspaces/", "d4-workspace"],
|
||||
["/plugin-api/network-topology/", "d4-node"],
|
||||
["/plugin-api/automations/", "d4-automation"],
|
||||
["/plugin-api/homepage/items", "d4-widget"],
|
||||
["/plugin-api/homepage/service-links", "d4-link"],
|
||||
["/plugin-api/vault/profiles", "d4-vault"],
|
||||
["/plugin-api/termix-identity/me", "d4-handle"],
|
||||
["/plugin-api/sso/providers", "d4-sso"],
|
||||
["/plugin-api/ldap/providers", "d4-ldap"],
|
||||
[
|
||||
`/plugin-api/ssh-terminal/command-history/${HOSTS.password}`,
|
||||
"d4-history-command",
|
||||
],
|
||||
["/plugin-api/tunnels/presets", "d4-preset"],
|
||||
["/plugin-api/ai/providers", "d4-provider"],
|
||||
["/plugin-api/ai/conversations/1", "d4-message"],
|
||||
["/plugin-api/secret-sources", "d4-source"],
|
||||
["/plugin-api/session-sharing/rooms", "d4-room"],
|
||||
["/plugin-api/webauthn/credentials", "d4-passkey", "passkey"],
|
||||
["/plugin-api/alerts/channels", "d4-channel"],
|
||||
];
|
||||
const failures: string[] = [];
|
||||
for (const [url, marker, as] of checks) {
|
||||
const response = await get(current(), url, as);
|
||||
if (
|
||||
response.status !== 200 ||
|
||||
!JSON.stringify(response.body).includes(marker)
|
||||
) {
|
||||
failures.push(
|
||||
`${url} -> ${response.status} ${JSON.stringify(response.body).slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it("moves every host flag into its plugin's host settings", async () => {
|
||||
const booted = current();
|
||||
const host = (id: string) => settings(booted, id, `host/${HOSTS.password}`);
|
||||
expect(await host("ssh-terminal")).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: false,
|
||||
enableTerminalToolbar: false,
|
||||
// The look it saved in terminal_config, which took it off the user's.
|
||||
inheritAppearance: false,
|
||||
theme: "dracula",
|
||||
fontSize: 16,
|
||||
cursorStyle: "block",
|
||||
autoTmux: true,
|
||||
sudoPasswordAutoFill: true,
|
||||
localEcho: "on",
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "ssh-terminal", `host/${HOSTS.key}`),
|
||||
).toMatchObject({ inheritAppearance: true, passwordPromptAutoFill: false });
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: false,
|
||||
});
|
||||
expect(await host("session-sharing")).toMatchObject({
|
||||
allowSessionSharing: false,
|
||||
});
|
||||
expect(await host("file-manager")).toMatchObject({
|
||||
enableFileManager: false,
|
||||
defaultPath: "/srv/d4",
|
||||
scpLegacy: true,
|
||||
});
|
||||
const tunnels = await host("tunnels");
|
||||
expect(tunnels.enableTunnel).toBe(true);
|
||||
expect(JSON.stringify(tunnels.tunnelConnections)).toContain("8080");
|
||||
const web = await host("web-endpoint");
|
||||
expect(web.enableWebUi).toBe(true);
|
||||
expect(JSON.stringify(web.webUiConfig)).toContain("d4-grafana");
|
||||
expect(await host("tmux-monitor")).toMatchObject({
|
||||
enableTmuxMonitor: true,
|
||||
});
|
||||
expect(await host("docker")).toMatchObject({
|
||||
enableDocker: true,
|
||||
containerRuntime: "podman",
|
||||
});
|
||||
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
|
||||
expect(await host("snippets")).toMatchObject({
|
||||
startupSnippetId: 1,
|
||||
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
|
||||
});
|
||||
expect(await host("wake-on-lan")).toMatchObject({
|
||||
macAddress: "AA:BB:CC:DD:EE:04",
|
||||
broadcastAddress: "10.4.0.255",
|
||||
});
|
||||
expect(await host("host-metrics")).toMatchObject({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "memory"],
|
||||
});
|
||||
|
||||
const proxmox = await settings(booted, "proxmox", `host/${HOSTS.key}`);
|
||||
expect(proxmox.enableProxmox).toBe(true);
|
||||
expect(JSON.stringify(proxmox)).toContain("pve.d4.example");
|
||||
|
||||
expect(
|
||||
await settings(booted, "remote-desktop", `host/${HOSTS.desktop}`),
|
||||
).toMatchObject({
|
||||
enableRdp: true,
|
||||
enableVnc: true,
|
||||
enableTelnet: true,
|
||||
rdpPort: 3390,
|
||||
vncPort: 5901,
|
||||
telnetPort: 2323,
|
||||
});
|
||||
expect(
|
||||
await settings(booted, "remote-desktop", `host/${HOSTS.rdpOnly}`),
|
||||
).toMatchObject({ enableRdp: true });
|
||||
|
||||
expect(
|
||||
await settings(booted, "vault", `host/${HOSTS.vault}`),
|
||||
).toMatchObject({ profileId: 1 });
|
||||
expect(
|
||||
await settings(booted, "warpgate", `host/${HOSTS.warpgate}`),
|
||||
).toMatchObject({ useWarpgate: true });
|
||||
});
|
||||
|
||||
it("keeps 2.8's defaults on a host that never changed them", async () => {
|
||||
const booted = current();
|
||||
const host = (id: string) => settings(booted, id, `host/${HOSTS.plain}`);
|
||||
expect(await host("file-manager")).toMatchObject({
|
||||
enableFileManager: true,
|
||||
});
|
||||
expect(await host("ssh-terminal")).toMatchObject({
|
||||
enableTerminal: true,
|
||||
enableCommandHistory: true,
|
||||
enableTerminalToolbar: true,
|
||||
inheritAppearance: true,
|
||||
autoTmux: false,
|
||||
passwordPromptAutoFill: true,
|
||||
});
|
||||
expect(await host("session-recording")).toMatchObject({
|
||||
enableSessionRecording: true,
|
||||
});
|
||||
expect(await host("session-sharing")).toMatchObject({
|
||||
allowSessionSharing: true,
|
||||
});
|
||||
expect(await host("docker")).toMatchObject({ enableDocker: false });
|
||||
expect(await host("tmux-monitor")).toMatchObject({
|
||||
enableTmuxMonitor: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps each host's connection and secrets", async () => {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const hosts = await selectRows<{
|
||||
id: number;
|
||||
auth_type: string;
|
||||
enable_ssh: unknown;
|
||||
}>(sql`SELECT id, auth_type, enable_ssh FROM ssh_data ORDER BY id`);
|
||||
const byId = new Map(hosts.map((row) => [row.id, row]));
|
||||
for (const [id, authType] of Object.entries(HOST_AUTH_TYPES)) {
|
||||
// Warpgate became a plugin setting on a host with no SSH auth of its own.
|
||||
const want = authType === "warpgate" ? "none" : authType;
|
||||
expect(byId.get(Number(id))?.auth_type, `host ${id}`).toBe(want);
|
||||
}
|
||||
expect(Number(byId.get(HOSTS.rdpOnly)?.enable_ssh)).toBe(0);
|
||||
|
||||
// The connection options in terminal_config moved to core's ssh_options.
|
||||
const [options] = await selectRows<{ ssh_options: string | null }>(
|
||||
sql`SELECT ssh_options FROM ssh_data WHERE id = ${HOSTS.password}`,
|
||||
);
|
||||
expect(JSON.parse(options.ssh_options ?? "{}")).toEqual({
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 4,
|
||||
allowLegacyAlgorithms: false,
|
||||
agentForwarding: true,
|
||||
environmentVariables: [{ key: "D4", value: "yes" }],
|
||||
});
|
||||
|
||||
// Every 2.8 RDP, VNC and Telnet login reads back through
|
||||
// host_protocol_auth, the only place core reads them from now.
|
||||
const ctx = await pluginCtx("remote-desktop");
|
||||
const resolve = (hostId: number, protocol: string) =>
|
||||
asUser(USERS.admin, () =>
|
||||
ctx.credentials.resolveHostProtocol(hostId, protocol),
|
||||
);
|
||||
expect((await resolve(HOSTS.rdpOnly, "rdp"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "rdp"))?.auth).toEqual({
|
||||
authType: "direct",
|
||||
username: "d4-rdp-user",
|
||||
password: "d4-rdp-password",
|
||||
fields: { domain: "D4-DOMAIN" },
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "vnc"))?.auth).toMatchObject({
|
||||
authType: "direct",
|
||||
username: "d4-vnc-user",
|
||||
password: "d4-vnc-password",
|
||||
});
|
||||
expect((await resolve(HOSTS.desktop, "telnet"))?.auth).toMatchObject({
|
||||
authType: "credential",
|
||||
username: "deploy",
|
||||
});
|
||||
|
||||
const logins = await selectRows<{ host_id: number; protocol: string }>(
|
||||
sql`SELECT host_id, protocol FROM host_protocol_auth ORDER BY host_id, protocol`,
|
||||
);
|
||||
expect(
|
||||
logins.map((row) => `${Number(row.host_id)}:${row.protocol}`),
|
||||
).toEqual([
|
||||
`${HOSTS.rdpOnly}:rdp`,
|
||||
`${HOSTS.desktop}:rdp`,
|
||||
`${HOSTS.desktop}:telnet`,
|
||||
`${HOSTS.desktop}:vnc`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("registers an SSH auth provider for every 2.8 auth type", async () => {
|
||||
const { listSshAuthProviders } =
|
||||
await import("../../hosts/connect/auth-provider-registry.js");
|
||||
const types = new Set(listSshAuthProviders().map((p) => p.type));
|
||||
for (const authType of new Set(Object.values(HOST_AUTH_TYPES))) {
|
||||
if (authType === "warpgate") continue;
|
||||
expect(types, authType).toContain(authType);
|
||||
}
|
||||
});
|
||||
|
||||
it("plays back every 2.8 recording", async () => {
|
||||
const booted = current();
|
||||
const list = await get(booted, "/plugin-api/session-recording/");
|
||||
expect(
|
||||
(list.body.logs as Array<{ id: number }>).map((log) => log.id).sort(),
|
||||
).toEqual([1, 2]);
|
||||
for (const [id, kind] of [
|
||||
[1, "ssh"],
|
||||
[2, "rdp"],
|
||||
] as const) {
|
||||
const content = await get(
|
||||
booted,
|
||||
`/plugin-api/session-recording/${id}/content`,
|
||||
)
|
||||
.buffer(true)
|
||||
.parse((res, done) => {
|
||||
let text = "";
|
||||
res.on("data", (chunk: Buffer) => (text += chunk.toString()));
|
||||
res.on("end", () => done(null, text));
|
||||
});
|
||||
expect(content.status, `recording ${id}`).toBe(200);
|
||||
expect(content.body).toContain(`d4 ${kind} recording`);
|
||||
}
|
||||
});
|
||||
|
||||
it("puts every moved setting in its plugin's admin and user scope", async () => {
|
||||
const booted = current();
|
||||
expect(await settings(booted, "ssh-terminal", "admin")).toMatchObject({
|
||||
sessionTimeoutMinutes: 45,
|
||||
sessionPersistence: false,
|
||||
commandHistoryForNewHosts: false,
|
||||
imageMaxCount: 7,
|
||||
newHostFontSize: 20,
|
||||
newHostTheme: "nord",
|
||||
newHostAutoTmux: true,
|
||||
});
|
||||
expect(await settings(booted, "ssh-terminal", "user")).toMatchObject({
|
||||
terminalDefaults: { fontSize: 18, cursorBlink: false },
|
||||
customThemes: [expect.objectContaining({ id: "d4-theme" })],
|
||||
commandAutocomplete: true,
|
||||
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
|
||||
});
|
||||
expect(await settings(booted, "snippets", "user")).toMatchObject({
|
||||
confirmExecution: true,
|
||||
foldersCollapsed: false,
|
||||
});
|
||||
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
|
||||
caUrl: "https://ca.d4.example",
|
||||
fingerprint: "d4fingerprint",
|
||||
provisioner: "d4-provisioner",
|
||||
});
|
||||
expect(await settings(booted, "remote-desktop", "admin")).toMatchObject({
|
||||
enabled: true,
|
||||
guacdUrl: "guacd.d4.example:4822",
|
||||
});
|
||||
expect(await settings(booted, "host-metrics", "admin")).toMatchObject({
|
||||
metricsInterval: 40,
|
||||
historyRetentionDays: 14,
|
||||
enabledForNewHosts: false,
|
||||
});
|
||||
expect(await settings(booted, "ai", "admin")).toMatchObject({
|
||||
globallyEnabled: true,
|
||||
});
|
||||
expect(await settings(booted, "session-recording", "admin")).toMatchObject({
|
||||
retentionDays: 3650,
|
||||
});
|
||||
expect(
|
||||
JSON.stringify((await settings(booted, "ai", "admin")).privateEndpoints),
|
||||
).toContain("10.4.0.0/24");
|
||||
expect(await settings(booted, "session-sharing", "admin")).toMatchObject({
|
||||
globallyEnabled: false,
|
||||
});
|
||||
expect(await settings(booted, "telemetry", "admin")).toMatchObject({
|
||||
enabled: false,
|
||||
instanceId: "d4-instance-id",
|
||||
});
|
||||
expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({
|
||||
domain: "termix.d4.example",
|
||||
email: "d4@example.com",
|
||||
});
|
||||
expect(await settings(booted, "tailscale", "admin")).toMatchObject({
|
||||
apiBaseUrl: "https://headscale.d4.example",
|
||||
});
|
||||
expect(await settings(booted, "ai", "user")).toMatchObject({
|
||||
enabled: true,
|
||||
allowReadOnlyCommands: true,
|
||||
});
|
||||
expect(await settings(booted, "remote-desktop", "user")).toMatchObject({
|
||||
colorDepth: "16",
|
||||
resizeMethod: "reconnect",
|
||||
enableDrive: "on",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every secret readable by the plugin that owns it", async () => {
|
||||
const tailscale = await pluginCtx("tailscale");
|
||||
expect(await tailscale.settings.get("apiKey")).toBe("tskey-d4");
|
||||
const acme = await pluginCtx("acme-ssl");
|
||||
expect(JSON.stringify(await acme.settings.getAll("admin"))).toContain(
|
||||
"d4-cloudflare-token",
|
||||
);
|
||||
const ai = await pluginCtx("ai");
|
||||
expect(await asUser(USERS.admin, () => ai.secrets.get("provider:1"))).toBe(
|
||||
"sk-d4-provider-key",
|
||||
);
|
||||
const sources = await pluginCtx("secret-sources");
|
||||
expect(
|
||||
await asUser(USERS.admin, () => sources.secrets.get("source:d4-source")),
|
||||
).toBe("d4-source-token");
|
||||
});
|
||||
|
||||
it("moves every notification channel into alerts, config readable", async () => {
|
||||
const response = await get(current(), "/plugin-api/alerts/channels/1");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toMatchObject({
|
||||
name: "d4-channel",
|
||||
type: "ntfy",
|
||||
usable: true,
|
||||
config: { url: "https://ntfy.d4.example/d4" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps every role's permissions", async () => {
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const permissions = PermissionManager.getInstance();
|
||||
for (const permission of OPERATORS_PERMISSIONS) {
|
||||
expect(
|
||||
await permissions.hasPermission(USERS.alice, permission),
|
||||
`operators: ${permission}`,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const permission of USER_ROLE_PERMISSIONS_28) {
|
||||
expect(
|
||||
await permissions.hasPermission(USERS.passkey, permission),
|
||||
`user: ${permission}`,
|
||||
).toBe(true);
|
||||
}
|
||||
expect(await permissions.hasPermission(USERS.admin, "sso.manage")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("still asks a 2.8 TOTP user for a code, and accepts one", async () => {
|
||||
const booted = current();
|
||||
const login = await request(booted.app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.requires_totp).toBe(true);
|
||||
expect(JSON.stringify(login.body.second_factors)).toContain("totp");
|
||||
|
||||
const wrong = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({ temp_token: login.body.temp_token, totp_code: "000000" });
|
||||
expect(wrong.status).not.toBe(200);
|
||||
|
||||
const verify = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({
|
||||
temp_token: login.body.temp_token,
|
||||
totp_code: totpCode(TOTP_SECRET),
|
||||
});
|
||||
expect(verify.status).toBe(200);
|
||||
|
||||
const again = await request(booted.app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-totp", password: FIXTURE_PASSWORD });
|
||||
const backup = await request(booted.app)
|
||||
.post("/users/totp/verify-login")
|
||||
.send({
|
||||
temp_token: again.body.temp_token,
|
||||
totp_code: TOTP_BACKUP_CODES[bootIndex],
|
||||
});
|
||||
expect(backup.status).toBe(200);
|
||||
});
|
||||
|
||||
it("signs a password user without a second factor straight in", async () => {
|
||||
const login = await request(current().app)
|
||||
.post("/users/login")
|
||||
.send({ username: "d4-alice", password: FIXTURE_PASSWORD });
|
||||
expect(login.status).toBe(200);
|
||||
expect(login.body.requires_totp).toBeFalsy();
|
||||
});
|
||||
|
||||
it("links SSO and LDAP users to their providers", async () => {
|
||||
const { selectRows } =
|
||||
await import("../../utils/crypto-migration/raw-rows.js");
|
||||
const links = await selectRows<{
|
||||
user_id: string;
|
||||
provider_id: unknown;
|
||||
}>(sql`SELECT user_id, provider_id FROM user_external_identities`);
|
||||
const byUser = new Map(
|
||||
links.map((row) => [row.user_id, String(row.provider_id)]),
|
||||
);
|
||||
expect(byUser.get(USERS.sso)).toBe(String(SSO_PROVIDER_ID));
|
||||
// The ldap plugin signs in with "ldap:<provider>".
|
||||
expect(byUser.get(USERS.ldap)).toBe(`ldap:${LDAP_PROVIDER_ID}`);
|
||||
});
|
||||
|
||||
it("syncs every entity type 2.8 synced, under the same name", async () => {
|
||||
const { listEntityTypes } = await import("../../plugins/sync-registry.js");
|
||||
const { registerCoreSyncEntities } = await import("../../sync/entities.js");
|
||||
registerCoreSyncEntities();
|
||||
const known = new Set(listEntityTypes());
|
||||
for (const type of SYNCED_ENTITY_TYPES_28) {
|
||||
expect(known, type).toContain(type);
|
||||
}
|
||||
|
||||
const snippets = await pluginRows("snippets", "p_snippets_snippets");
|
||||
expect(snippets.map((row) => row.sync_id)).toContain("d4-sync-snippet");
|
||||
const vault = await pluginRows("vault", "p_vault_profiles");
|
||||
expect(vault.map((row) => row.sync_id)).toContain("d4-sync-vault");
|
||||
});
|
||||
}
|
||||
|
||||
let first: Booted;
|
||||
let firstCounts: Record<string, number>;
|
||||
let second: Booted;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertPluginsBuilt();
|
||||
first = await boot();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
afterAll(async () => {
|
||||
await (second ?? first)?.core.shutdown();
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
describe("a 2.8 install upgraded to 2.9.0", () => {
|
||||
// Only SQLite is backed up by Termix; a server database is the operator's.
|
||||
it.skipIf(remoteTestDialect())(
|
||||
"backed up the 2.8 database before anything touched it",
|
||||
async () => {
|
||||
const backups = path.join(first.core.dataDir, "backups");
|
||||
const dirs = fs
|
||||
.readdirSync(backups)
|
||||
.filter((name) => name.startsWith("pre-plugin-runtime-"));
|
||||
expect(dirs).toHaveLength(1);
|
||||
const copy = fs.readFileSync(path.join(backups, dirs[0], "db.sqlite"));
|
||||
expect(copy.equals(first.core.seed!)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
upgradeChecks(() => first, 0);
|
||||
});
|
||||
|
||||
describe("booting the upgraded install again", () => {
|
||||
beforeAll(async () => {
|
||||
firstCounts = await counts();
|
||||
const dataDir = first.core.dataDir;
|
||||
await first.core.shutdown({ keepDataDir: true });
|
||||
second = await boot(dataDir);
|
||||
}, BOOT_TIMEOUT);
|
||||
|
||||
it("changes nothing", async () => {
|
||||
expect(await counts()).toEqual(firstCounts);
|
||||
});
|
||||
|
||||
it.skipIf(remoteTestDialect())("takes no second backup", () => {
|
||||
const backups = fs.readdirSync(path.join(second.core.dataDir, "backups"));
|
||||
expect(
|
||||
backups.filter((name) => name.startsWith("pre-plugin-runtime-")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
upgradeChecks(() => second, 1);
|
||||
});
|
||||
Reference in new issue
Block a user