chore: lock the plugin boundary and remove 2.9.0 leftovers

This commit is contained in:
LukeGus committed 2026-09-25 07:24:46 -05:00
1 parent 6489b62ece
commit 2961297989
399 files changed
+3964 -5627

No files matched your search

@@ -1,7 +1,6 @@
/**
* The 2.8 login URLs identity providers and 2.8 clients still call. They
* forward to whatever replaced them and, with the method's plugin gone, sign
* nobody in.
* The 2.8 login URLs 2.8 clients still call. They forward to the login
* pipeline and, with the method's plugin gone, sign nobody in.
*/
import http from "node:http";
@@ -64,43 +63,6 @@ function registerSso() {
});
}
describe("the old identity provider URLs", () => {
it("forwards the callback to the sso plugin, query intact", async () => {
const response = await get("/users/oidc/callback?code=abc&state=xyz");
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(
"/plugin-api/sso/callback?code=abc&state=xyz",
);
});
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
const response = await fetch(`${base}/users/oidc/callback`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
});
it("forwards back-channel logout to the sso plugin", async () => {
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
method: "POST",
redirect: "manual",
});
expect(response.status).toBe(308);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/backchannel-logout$/,
);
});
it("forwards the public config to the sso plugin", async () => {
const response = await get("/users/oidc-config");
expect(response.status).toBe(307);
expect(response.headers.get("location")).toMatch(
/\/plugin-api\/sso\/config$/,
);
});
});
describe("the old client routes", () => {
it("starts an SSO login through the oidc method", async () => {
registerSso();
@@ -1,116 +0,0 @@
/**
* The OPKSSH, Step CA and Vault redirect URIs identity providers were registered with before
* 2.9 must keep reaching the plugin's callback, query intact, and the Termix
* ID resolver URLs servers fetch must keep reaching the plugin's resolver.
*/
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const { registerHostAuthCompatRoutes, registerTermixIdCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
let server: http.Server;
let base: string;
beforeEach(async () => {
const router = express.Router();
registerHostAuthCompatRoutes(router);
const app = express();
app.use("/host", router);
const termixId = express.Router();
registerTermixIdCompatRoutes(termixId);
app.use("/termix-id", termixId);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
describe("the old OPKSSH callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/opkssh-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/opkssh/callback?code=abc&state=xyz",
);
});
it("keeps a subpath", async () => {
const response = await fetch(`${base}/host/opkssh-callback/req-1/done`, {
redirect: "manual",
});
expect(response.headers.get("location")).toBe(
"/plugin-api/opkssh/callback/req-1/done",
);
});
});
describe("the old Step CA callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const response = await fetch(
`${base}/host/step-ca-callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/step-ca/callback?code=abc&state=xyz",
);
});
});
describe("the old Vault callback", () => {
it("forwards to the plugin's callback with the query", async () => {
const { registerVaultCompatRoutes } =
await import("../../../database/routes/host-compat-routes.js");
const router = express.Router();
registerVaultCompatRoutes(router);
const app = express();
app.use("/vault", router);
const vaultServer = http.createServer(app);
await new Promise<void>((resolve) => vaultServer.listen(0, resolve));
const port = (vaultServer.address() as AddressInfo).port;
try {
const response = await fetch(
`http://127.0.0.1:${port}/vault/oidc/callback?code=abc&state=xyz`,
{ redirect: "manual" },
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
"/plugin-api/vault/oidc/callback?code=abc&state=xyz",
);
} finally {
await new Promise<void>((resolve) => vaultServer.close(() => resolve()));
}
});
});
describe("the old Termix ID resolver URLs", () => {
it.each([
["/termix-id/u/alice", "/plugin-api/termix-identity/u/alice"],
[
"/termix-id/u/alice/ED25519",
"/plugin-api/termix-identity/u/alice/ED25519",
],
["/termix-id/u/alice/ca", "/plugin-api/termix-identity/u/alice/ca"],
])("permanently redirects %s", async (path, target) => {
const response = await fetch(`${base}${path}`, { redirect: "manual" });
expect(response.status).toBe(308);
expect(response.headers.get("location")).toBe(target);
});
it("does not forward the old management routes", async () => {
const response = await fetch(`${base}/termix-id/me`, {
redirect: "manual",
});
expect(response.status).toBe(404);
});
});
@@ -1,6 +1,8 @@
/**
* contributes.http.legacyPaths: an old URL under "/<plugin id>/" reaches the
* plugin's router with that prefix removed, and nothing else is caught.
* contributes.http.legacyRedirects: an old URL anywhere redirects to the
* plugin's own URL, keeping the subpath and the query.
*/
import express from "express";
@@ -16,10 +18,20 @@ vi.mock("../../../plugins/http.js", () => ({
const { mountPluginLegacyPaths } =
await import("../../../database/routes/plugin-api-routes.js");
function appWith(active: Array<{ id: string; legacyPaths: string[] }>) {
type Active = Parameters<typeof mountPluginLegacyPaths>[1] extends () => Array<
infer T
>
? T
: never;
function appWith(
active: Array<Omit<Active, "legacyRedirects"> & Partial<Active>>,
) {
const app = express();
app.get("/core/route", (_req, res) => res.json({ from: "core" }));
mountPluginLegacyPaths(app, () => active);
mountPluginLegacyPaths(app, () =>
active.map((entry) => ({ legacyRedirects: [], ...entry })),
);
app.use((_req, res) => res.status(404).json({ from: "fallthrough" }));
return app;
}
@@ -58,3 +70,37 @@ describe("mountPluginLegacyPaths", () => {
expect(response.status).toBe(404);
});
});
describe("legacy redirects", () => {
const app = appWith([
{
id: "idp",
legacyPaths: [],
legacyRedirects: [
{ from: "/host/idp-callback", to: "/callback" },
{ from: "/old-id/u", to: "/u", status: 308 },
],
},
]);
it("redirects to the plugin's URL with the query", async () => {
const response = await request(app).get(
"/host/idp-callback?code=abc&state=xyz",
);
expect(response.status).toBe(307);
expect(response.headers.location).toBe(
"/plugin-api/idp/callback?code=abc&state=xyz",
);
});
it("keeps a subpath and honours a permanent status", async () => {
const response = await request(app).get("/old-id/u/alice/ca");
expect(response.status).toBe(308);
expect(response.headers.location).toBe("/plugin-api/idp/u/alice/ca");
});
it("does not catch a path that only shares a prefix", async () => {
const response = await request(app).get("/host/idp-callbacks");
expect(response.body).toEqual({ from: "fallthrough" });
});
});
@@ -35,7 +35,7 @@ const state = vi.hoisted(() => ({
active: new Set<string>(),
}));
vi.mock("../../../plugins/boot-migrations.js", () => ({
vi.mock("../../../upgrade/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
+13 -13
View File
@@ -128,7 +128,7 @@ function startBackend(
let body = "";
req.on("data", (d) => (body += d));
req.on("end", () => {
if (!opts.failAll && req.url?.endsWith("/ssh/downloadFileStream")) {
if (!opts.failAll && req.url?.endsWith("/downloadFileStream")) {
// Headers go out immediately so the client opens its temp file;
// the body may be delayed to keep the transfer in flight.
res.writeHead(200, { "Content-Length": payload.length });
@@ -150,7 +150,7 @@ function startBackend(
server.listen(0, "127.0.0.1", () => {
const address = server.address() as { port: number };
resolve({
url: `http://127.0.0.1:${address.port}/ssh/file_manager`,
url: `http://127.0.0.1:${address.port}/plugin-api/file-manager`,
seen,
close: () => new Promise((r) => server.close(() => r())),
});
@@ -160,25 +160,25 @@ function startBackend(
describe("local-files transfer target resolution", () => {
const resolve = localFiles.createTargetResolver({
localBaseUrl: "http://127.0.0.1:30004/ssh/file_manager",
localBaseUrl: "http://127.0.0.1:30001/plugin-api/file-manager",
getRemoteSyncConfig: () => ({ serverUrl: "https://termix.example.com/" }),
getRemoteSyncJwt: () => "remote-jwt",
});
it("only reaches the two file-manager streaming routes on the local backend", () => {
expect(resolve({ origin: "local", route: "uploadFileStream" })).toEqual({
url: "http://127.0.0.1:30004/ssh/file_manager/ssh/uploadFileStream",
url: "http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
headers: { "X-Electron-App": "true" },
});
expect(resolve({ origin: "local", route: "downloadFileStream" }).url).toBe(
"http://127.0.0.1:30004/ssh/file_manager/ssh/downloadFileStream",
"http://127.0.0.1:30001/plugin-api/file-manager/downloadFileStream",
);
});
it("derives the remote target from the configured sync server and attaches its JWT itself", () => {
const target = resolve({ origin: "remote", route: "downloadFileStream" });
expect(target.url).toBe(
"https://termix.example.com/ssh/file_manager/ssh/downloadFileStream",
"https://termix.example.com/plugin-api/file-manager/downloadFileStream",
);
expect(target.headers.Authorization).toBe("Bearer remote-jwt");
});
@@ -242,7 +242,7 @@ describe("local-files transfer target resolution", () => {
});
expect(target.headers.Authorization).toBe(`Bearer ${jwt}`);
expect(target.url).toBe(
"http://127.0.0.1:30004/ssh/file_manager/ssh/uploadFileStream",
"http://127.0.0.1:30001/plugin-api/file-manager/uploadFileStream",
);
// Absent or empty: no header at all (the session cookie is the fallback).
for (const authToken of [undefined, null, ""]) {
@@ -355,7 +355,7 @@ describe("local-files download boundary", () => {
});
expect(result.success).toBe(true);
const last = backend.seen[backend.seen.length - 1];
expect(last.url).toBe("/ssh/file_manager/ssh/downloadFileStream");
expect(last.url).toBe("/plugin-api/file-manager/downloadFileStream");
expect(last.headers.authorization).toBeUndefined();
expect(last.headers.cookie).toBeUndefined();
expect(last.headers["x-electron-app"]).toBe("true");
@@ -952,7 +952,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
});
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
transferId: "up-1",
@@ -964,7 +964,7 @@ describe("local-files upload boundary", () => {
headers: { Authorization: "Bearer leaked" },
});
expect(result.success).toBe(true);
expect(received.url).toBe("/ssh/file_manager/ssh/uploadFileStream");
expect(received.url).toBe("/plugin-api/file-manager/uploadFileStream");
expect(received.headers?.authorization).toBeUndefined();
expect(received.fields).toEqual({
sessionId: "42",
@@ -1006,7 +1006,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
});
const withoutToken = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
transferId: "up-auth-0",
@@ -1105,7 +1105,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: `http://127.0.0.1:${port}/ssh/file_manager`,
localBaseUrl: `http://127.0.0.1:${port}/plugin-api/file-manager`,
});
const results = await Promise.all(
files.map((f) =>
@@ -1131,7 +1131,7 @@ describe("local-files upload boundary", () => {
const handlers = localFiles.createLocalFileHandlers({
net: fakeNet,
shell: {},
localBaseUrl: "http://127.0.0.1:1/ssh/file_manager",
localBaseUrl: "http://127.0.0.1:1/plugin-api/file-manager",
});
const result = await handlers[localFiles.IPC.UPLOAD](fakeEvent, {
transferId: "up-2",
@@ -66,7 +66,7 @@ vi.mock("../../../hosts/ssh-connection-pool.js", () => ({
import {
buildConnectConfig,
getPurposeDefaults,
getProfileDefaults,
} from "../../../hosts/connect/build-connect-config.js";
import {
connectHost,
@@ -76,6 +76,7 @@ import {
} from "../../../hosts/connect/connect-host.js";
import {
getSshAuthProvider,
isQuickConnectAuthType,
listCredentialTypes,
registerSshAuthProvider,
setSshAuthTypeOwnerSource,
@@ -137,11 +138,12 @@ function registerSignInProvider(hasCert: boolean) {
async function build(
target: SshConnectHost,
purpose: Parameters<typeof buildConnectConfig>[1]["purpose"] = "terminal",
profile: Parameters<typeof buildConnectConfig>[1]["profile"] = "terminal",
) {
return buildConnectConfig(target, {
userId: "user-1",
purpose,
purpose: "test",
profile,
client: new FakeSshClient() as never,
});
}
@@ -349,7 +351,7 @@ describe("buildConnectConfig per auth type", () => {
it("jump hops skip keyboard-interactive for auth type none", async () => {
const { config } = await build(
host({ authType: "none", password: null }),
"jump-host",
"jump",
);
expect(config.tryKeyboard).toBe(false);
expect(mocks.createHostVerifier).toHaveBeenCalledWith(
@@ -364,29 +366,33 @@ describe("buildConnectConfig per auth type", () => {
});
});
describe("purpose defaults", () => {
describe("profile defaults", () => {
it("keeps each transport's old keepalive numbers", () => {
expect(getPurposeDefaults("terminal")).toMatchObject({
expect(getProfileDefaults("terminal")).toMatchObject({
keepaliveIntervalMs: 30000,
keepaliveCountMax: 5,
});
expect(getPurposeDefaults("file-manager")).toMatchObject({
expect(getProfileDefaults("session")).toMatchObject({
keepaliveIntervalMs: 60000,
keepaliveCountMax: 5,
});
expect(getPurposeDefaults("fleet")).toMatchObject({
expect(getProfileDefaults("background")).toMatchObject({
keepaliveIntervalMs: 30000,
keepaliveCountMax: 3,
});
});
it("uses the background profile when none is given", () => {
expect(getProfileDefaults()).toEqual(getProfileDefaults("background"));
});
it("host keepalive settings apply where the transport honoured them", async () => {
const withSettings = host({
terminalConfig: { keepaliveInterval: 10, keepaliveCountMax: 2 },
});
const terminal = await build(withSettings, "terminal");
expect(terminal.config.keepaliveInterval).toBe(10000);
const fleet = await build(withSettings, "fleet");
const fleet = await build(withSettings, "background");
expect(fleet.config.keepaliveInterval).toBe(30000);
});
});
@@ -415,6 +421,31 @@ describe("provider registry", () => {
expect(outcome).toMatchObject({ code: "provider-missing" });
});
it("offers core types and opted-in plugin types to Quick Connect", () => {
ensureCoreSshAuthProviders();
for (const type of ["password", "key", "credential", "agent", "none"]) {
expect(isQuickConnectAuthType(type)).toBe(true);
}
const plain = registerSshAuthProvider({
type: "needs-saved-host",
pluginId: "fixture-plugin",
labelKey: "x",
prepare: async () => ({ status: "ready" }),
});
const quick = registerSshAuthProvider({
type: "works-unsaved",
pluginId: "fixture-plugin",
labelKey: "x",
quickConnect: true,
prepare: async () => ({ status: "ready" }),
});
expect(isQuickConnectAuthType("needs-saved-host")).toBe(false);
expect(isQuickConnectAuthType("works-unsaved")).toBe(true);
expect(isQuickConnectAuthType("missing")).toBe(false);
plain();
quick();
});
it("refuses to let a second plugin take over a type", () => {
ensureCoreSshAuthProviders();
expect(() =>
+1 -1
View File
@@ -127,7 +127,7 @@ export async function bootCore(seed: Database.Database): Promise<BootedCore> {
await initializePlugins();
const { runPluginDataMigrations } =
await import("../../plugins/boot-migrations.js");
await import("../../upgrade/boot-migrations.js");
await runPluginDataMigrations();
return {
@@ -653,3 +653,36 @@ describe("named service providers", () => {
}
});
});
describe("contributes.http.legacyRedirects", () => {
const withRedirects = (legacyRedirects: unknown) =>
validateManifest(base({ contributes: { http: { legacyRedirects } } }));
it("accepts an old URL outside the plugin's namespace", () => {
expect(
withRedirects([
{ from: "/users/oidc/callback", to: "/callback", status: 308 },
{ from: "/host/old-callback", to: "/callback" },
]),
).toEqual([]);
});
it("refuses the plugin framework's own prefixes", () => {
expect(
withRedirects([{ from: "/plugin-api/other/x", to: "/x" }]).join(),
).toMatch(/cannot be under plugin-api/);
});
it("refuses a relative path, a bad status and unknown keys", () => {
const errors = withRedirects([
{ from: "users/x", to: "/x" },
{ from: "/a", to: "x" },
{ from: "/a", to: "/x", status: 302 },
{ from: "/a", to: "/x", extra: true },
]).join();
expect(errors).toMatch(/from must be an absolute path/);
expect(errors).toMatch(/to must be a path/);
expect(errors).toMatch(/status must be 307 or 308/);
expect(errors).toMatch(/extra/);
});
});
@@ -22,7 +22,7 @@ const state = vi.hoisted(() => ({
warnings: [] as string[],
}));
vi.mock("../../plugins/boot-migrations.js", () => ({
vi.mock("../../upgrade/boot-migrations.js", () => ({
runPluginDataMigrations: async () => {},
}));
@@ -224,7 +224,7 @@ describe("namespace escalation is refused", () => {
["admin.users.manage", /reserved core group/],
["hosts.view", /reserved core group/],
["credentials.read", /reserved core group/],
["snippets.edit", /reserved core group/],
["admin.plugins.manage", /reserved core group/],
])("refuses the core name %s", (name, pattern) => {
const { errors } = parseManifest(manifest("sample", [permission(name)]));
+137
View File
@@ -0,0 +1,137 @@
/**
* A user's rows in plugin tables travel with their export and come back on
* import, found through the table definitions plugins registered rather than
* any table name core knows.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
defineTable,
encryptedText,
id,
refUser,
text,
varchar,
} from "@termix/plugin-sdk/db";
let sqlite: Database.Database;
vi.mock("../../database/db/index.js", () => ({
getDb: () => drizzle(sqlite),
}));
vi.mock("../../utils/logger.js", () => ({
pluginLogger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const { registerTable, resetPluginData } =
await import("../../plugins/data.js");
const {
importUserPluginRows,
listUserOwnedTables,
readUserPluginRows,
writeUserPluginTables,
} = await import("../../plugins/user-data.js");
const { createTableSql } = await import("../../plugins/table-builder.js");
// Stands for an adopted table; adoptLegacyTable only accepts real core names.
const bookmarks = {
...defineTable("bookmark", {
id: id(),
userId: refUser(),
path: varchar(200).notNull(),
note: text(),
token: encryptedText(),
}),
adopts: "bookmarks_legacy",
};
const shared = defineTable("shared_thing", { id: id(), label: text() });
function createTables(target: Database.Database) {
target.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
target.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
for (const definition of [bookmarks, shared]) {
for (const statement of createTableSql("sqlite", "fixture", definition)) {
target.exec(statement);
}
}
}
beforeEach(() => {
sqlite = new Database(":memory:");
createTables(sqlite);
sqlite.exec("INSERT INTO users (id) VALUES ('u1'), ('u2')");
sqlite.exec(`INSERT INTO p_fixture_bookmark (user_id, path, note, token)
VALUES ('u1', '/etc', 'config', 'sealed'), ('u2', '/var', null, null)`);
registerTable("fixture", bookmarks);
registerTable("fixture", shared);
});
afterEach(() => {
resetPluginData();
sqlite.close();
});
describe("plugin user data", () => {
it("lists only tables with a user column, without keys or secrets", () => {
const [owned] = listUserOwnedTables();
expect(listUserOwnedTables()).toHaveLength(1);
expect(owned).toMatchObject({
pluginId: "fixture",
table: "p_fixture_bookmark",
legacyName: "bookmarks_legacy",
userColumn: "user_id",
columns: ["user_id", "path", "note"],
});
});
it("reads one user's rows", async () => {
expect(await readUserPluginRows("u1")).toEqual({
p_fixture_bookmark: [{ user_id: "u1", path: "/etc", note: "config" }],
});
});
it("round-trips through an export file into another user", async () => {
// The export file has the user row, as database.ts writes it first.
const file = new Database(":memory:");
file.exec("CREATE TABLE users (id TEXT PRIMARY KEY)");
file.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
file.exec("INSERT INTO users (id) VALUES ('u1')");
expect(await writeUserPluginTables(file, "u1")).toBe(1);
sqlite.exec("DELETE FROM p_fixture_bookmark");
const result = await importUserPluginRows(file, "u2");
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
expect(
sqlite.prepare("SELECT user_id, path FROM p_fixture_bookmark").all(),
).toEqual([{ user_id: "u2", path: "/etc" }]);
const again = await importUserPluginRows(file, "u2");
expect(again).toMatchObject({ imported: 0, skipped: 1 });
});
it("reads a pre-2.9 export by the legacy table name", async () => {
const file = new Database(":memory:");
file.exec(
"CREATE TABLE bookmarks_legacy (id INTEGER PRIMARY KEY, user_id TEXT, path TEXT, note TEXT)",
);
file.exec(
"INSERT INTO bookmarks_legacy (user_id, path, note) VALUES ('old', '/srv', 'x')",
);
const result = await importUserPluginRows(file, "u1");
expect(result.imported).toBe(1);
expect(
sqlite
.prepare(
"SELECT user_id, note FROM p_fixture_bookmark WHERE path = '/srv'",
)
.get(),
).toEqual({ user_id: "u1", note: "x" });
});
});
@@ -56,7 +56,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
import { runAcmeSslSettingsMigration } from "../../upgrade/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
@@ -130,7 +130,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
allowlistToText,
runAiSettingsMigration,
} from "../../utils/crypto-migration/ai-settings-migration.js";
} from "../../upgrade/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
@@ -66,7 +66,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
dockerSettingsFromRow,
runDockerSettingsMigration,
} from "../../utils/crypto-migration/docker-settings-migration.js";
} from "../../upgrade/docker-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
@@ -1,13 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
createAuthState,
type AuthState,
} from "../../auth/auth-test-helpers.js";
import { createAuthState, type AuthState } from "../auth/auth-test-helpers.js";
const h = vi.hoisted(() => ({ state: null as unknown as AuthState }));
vi.mock("../../../database/repositories/factory.js", async () => {
const helpers = await import("../../auth/auth-test-helpers.js");
vi.mock("../../database/repositories/factory.js", async () => {
const helpers = await import("../auth/auth-test-helpers.js");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
@@ -22,12 +19,12 @@ vi.mock("../../../database/repositories/factory.js", async () => {
]),
);
});
vi.mock("../../../utils/logger.js", () => ({
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
const { runExternalIdentityMigration, parseLegacyIdentifier } =
await import("../../../utils/crypto-migration/external-identity-migration.js");
await import("../../upgrade/external-identity-migration.js");
function user(id: string, extra: Record<string, unknown>) {
h.state.users.set(id, {
@@ -80,7 +80,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runFileManagerSettingsMigration } =
await import("../../utils/crypto-migration/file-manager-settings-migration.js");
await import("../../upgrade/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -70,7 +70,7 @@ vi.mock("../../utils/logger.js", () => ({
}));
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
await import("../../upgrade/host-metrics-settings-migration.js");
let sqlite: Database.Database;
@@ -17,7 +17,7 @@ vi.mock("../../utils/logger.js", () => {
});
const { runLdapProviderMigration } =
await import("../../utils/crypto-migration/ldap-provider-migration.js");
await import("../../upgrade/ldap-provider-migration.js");
let sqlite: Database.Database;
@@ -39,7 +39,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
import { runOpksshConfigMigration } from "../../upgrade/opkssh-config-migration.js";
let dataDir: string;
const legacy = () => path.join(dataDir, ".opk", "config.yml");
@@ -81,7 +81,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runProxmoxSettingsMigration } =
await import("../../utils/crypto-migration/proxmox-settings-migration.js");
await import("../../upgrade/proxmox-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -88,7 +88,7 @@ vi.mock("../../utils/logger.js", () => ({
}));
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
await import("../../utils/crypto-migration/remote-desktop-settings-migration.js");
await import("../../upgrade/remote-desktop-settings-migration.js");
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
@@ -117,7 +117,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
import { runSecretSourcesTokenMigration } from "../../upgrade/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
@@ -73,7 +73,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runSessionRecordingSettingsMigration } =
await import("../../utils/crypto-migration/session-recording-settings-migration.js");
await import("../../upgrade/session-recording-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -71,7 +71,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runSessionSharingSettingsMigration } =
await import("../../utils/crypto-migration/session-sharing-settings-migration.js");
await import("../../upgrade/session-sharing-settings-migration.js");
function stored(scopeId: string | null, key: string): unknown {
const row = settingsRows.find(
@@ -96,7 +96,7 @@ vi.mock("../../utils/system-secret-crypto.js", () => ({
}));
const { runSshTerminalSettingsMigration } =
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
await import("../../upgrade/ssh-terminal-settings-migration.js");
function admin(key: string): unknown {
const row = find("ssh-terminal", "admin", null, key);
@@ -44,7 +44,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
import { runStepCaSettingsMigration } from "../../upgrade/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
@@ -86,7 +86,7 @@ vi.mock("../../utils/system-secret-crypto.js", () => ({
}));
const { runTailscaleSettingsMigration } =
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
await import("../../upgrade/tailscale-settings-migration.js");
function storedValue(key: string): unknown {
const row = pluginRows.find((entry) => entry.key === key);
@@ -29,7 +29,7 @@ vi.mock("../../utils/logger.js", () => {
});
const { runTermixIdentityCaMigration } =
await import("../../utils/crypto-migration/termix-identity-ca-migration.js");
await import("../../upgrade/termix-identity-ca-migration.js");
const PEM = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n";
@@ -72,7 +72,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
await import("../../upgrade/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -29,8 +29,7 @@ vi.mock("../../utils/logger.js", () => {
return { databaseLogger: log };
});
const { runTotpMigration } =
await import("../../utils/crypto-migration/totp-migration.js");
const { runTotpMigration } = await import("../../upgrade/totp-migration.js");
let sqlite: Database.Database;
@@ -76,7 +76,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runTunnelsSettingsMigration } =
await import("../../utils/crypto-migration/tunnels-settings-migration.js");
await import("../../upgrade/tunnels-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -69,7 +69,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
import { runVaultSettingsMigration } from "../../upgrade/vault-settings-migration.js";
const setting = (scopeId: string | null, key: string) =>
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
@@ -65,7 +65,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../utils/crypto-migration/wake-on-lan-settings-migration.js";
} from "../../upgrade/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
@@ -78,7 +78,7 @@ vi.mock("../../utils/logger.js", () => ({
import {
runWarpgateSettingsMigration,
usesWarpgate,
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
} from "../../upgrade/warpgate-settings-migration.js";
const enabled = (hostId: number) =>
state.pluginRows.some(
@@ -74,7 +74,7 @@ vi.mock("../../database/db/index.js", () => ({
}));
const { runWebEndpointSettingsMigration } =
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
await import("../../upgrade/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
@@ -186,7 +186,6 @@ describe("analytics", () => {
properties: expect.objectContaining({
user_count: 0,
host_count: 0,
used_terminal: 0,
}),
});
});
@@ -49,26 +49,6 @@ function legacyDatabase(): Database.Database {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE session_recordings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL,
user_id TEXT NOT NULL,
access_id INTEGER,
started_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at TEXT,
duration INTEGER,
commands TEXT,
dangerous_actions TEXT,
recording_path TEXT,
protocol TEXT NOT NULL DEFAULT 'ssh',
format TEXT NOT NULL DEFAULT 'text',
terminated_by_owner INTEGER DEFAULT 0,
termination_reason TEXT,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (access_id) REFERENCES host_access (id) ON DELETE SET NULL
);
INSERT INTO users (id, username) VALUES ('u-1', 'alice'), ('u-2', 'bob');
INSERT INTO ssh_data (id, name) VALUES (1, 'prod-db');
@@ -78,12 +58,6 @@ function legacyDatabase(): Database.Database {
('u-1', 'alice', 'host.delete', 'host', '1', 1, '2026-07-01 10:00:00'),
('u-1', 'alice', 'credential.view', 'credential', '9', 1, '2026-07-02 11:00:00'),
('u-2', 'bob', 'host.create', 'host', '2', 1, '2026-07-03 12:00:00');
INSERT INTO session_recordings
(host_id, user_id, started_at, recording_path, protocol, format)
VALUES
(1, 'u-1', '2026-07-01 10:00:00', '/rec/a.guac', 'ssh', 'text'),
(1, 'u-2', '2026-07-03 12:00:00', '/rec/b.guac', 'ssh', 'text');
`);
return sqlite;
}
@@ -93,15 +67,10 @@ describe("audit retention migration", () => {
db = legacyDatabase();
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(true);
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(true);
expect(migrateAuditRetention(db)).toEqual([
"audit_logs",
"session_recordings",
]);
expect(migrateAuditRetention(db)).toEqual(["audit_logs"]);
expect(userDeleteIsDestructive(db, "audit_logs")).toBe(false);
expect(userDeleteIsDestructive(db, "session_recordings")).toBe(false);
});
it("keeps the audit trail when the user is deleted", () => {
@@ -126,23 +95,6 @@ describe("audit retention migration", () => {
expect(rows[2].user_id).toBe("u-2");
});
it("backfills a username onto recordings so they stay attributable", () => {
db = legacyDatabase();
migrateAuditRetention(db);
db.exec("DELETE FROM users WHERE id = 'u-1'");
const rows = db
.prepare(
"SELECT user_id, username, recording_path FROM session_recordings ORDER BY started_at",
)
.all() as { user_id: string | null; username: string | null }[];
expect(rows).toHaveLength(2);
expect(rows[0].user_id).toBeNull();
expect(rows[0].username).toBe("alice");
});
it("loses no data in the copy", () => {
db = legacyDatabase();
const before = db
@@ -158,18 +110,6 @@ describe("audit retention migration", () => {
expect(after).toEqual(before);
});
it("still cascades recordings when their host is deleted", () => {
db = legacyDatabase();
migrateAuditRetention(db);
db.exec("PRAGMA foreign_keys = ON");
db.exec("DELETE FROM ssh_data WHERE id = 1");
expect(
db.prepare("SELECT COUNT(*) AS n FROM session_recordings").get(),
).toEqual({ n: 0 });
});
it("is idempotent and leaves an already-migrated database alone", () => {
db = legacyDatabase();
migrateAuditRetention(db);