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,144 +0,0 @@
/**
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
* and leaves the old row, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{
key: string;
value: string | null;
encrypted: boolean;
}>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
set: async (key: string, value: string) => {
state.legacy[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value, encrypted });
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
acme_ssl_settings: JSON.stringify({
enabled: true,
domain: "termix.example.com",
email: "admin@example.com",
challengeType: "dns-cloudflare",
cloudflareToken: "cf-token",
lastIssuedAt: "2026-01-01T00:00:00.000Z",
}),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runAcmeSslSettingsMigration", () => {
it("moves every field and encrypts the token", async () => {
const result = await runAcmeSslSettingsMigration();
expect(result.moved.sort()).toEqual(
[
"autoRenew",
"challengeType",
"cloudflareToken",
"domain",
"email",
].sort(),
);
expect(value("domain")).toBe("termix.example.com");
expect(value("email")).toBe("admin@example.com");
expect(value("challengeType")).toBe("dns-cloudflare");
expect(value("autoRenew")).toBe(true);
expect(value("cloudflareToken")).toBe("enc:cf-token");
expect(
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
).toBe(true);
const left = JSON.parse(state.legacy.acme_ssl_settings);
expect(left.cloudflareToken).toBeUndefined();
expect(left.domain).toBe("termix.example.com");
});
it("maps the webroot challenge and turns renewal off for manual", async () => {
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
domain: "a.example",
email: "a@example.com",
challengeType: "http-webroot",
});
await runAcmeSslSettingsMigration();
expect(value("challengeType")).toBe("http-01");
expect(value("autoRenew")).toBe(true);
state.pluginRows = [];
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
challengeType: "manual",
});
await runAcmeSslSettingsMigration();
expect(value("autoRenew")).toBe(false);
expect(value("challengeType")).toBeUndefined();
});
it("changes nothing on a second run or over values already set", async () => {
await runAcmeSslSettingsMigration();
const snapshot = JSON.stringify(state.pluginRows);
const second = await runAcmeSslSettingsMigration();
expect(second.moved).toEqual([]);
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
});
it("does nothing before the plugin row exists or without a legacy row", async () => {
state.pluginInstalled = false;
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
state.pluginInstalled = true;
state.legacy = {};
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,257 +0,0 @@
/**
* The AI settings migration.
*
* An upgrade must be lossless: the admin switch, the private endpoint list,
* every user's opt-in, every host's switch and every provider key have to
* reach the ai plugin, keys encrypted and gone from the table. Running it
* twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
coreSettings: new Map<string, string>(),
preferences: [] as Array<Record<string, unknown>>,
hosts: [] as Array<Record<string, unknown>>,
providers: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "ai" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
// sql.identifier() is a Name chunk carrying the table name.
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
if (sqlText.includes("FROM user_preferences")) return state.preferences;
if (sqlText.includes("FROM ssh_data")) return state.hosts;
for (const [table, rows] of state.providers) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.api_key);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.providers) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "number");
for (const row of rows) if (row.id === id) row.api_key = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
allowlistToText,
runAiSettingsMigration,
} from "../../utils/crypto-migration/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === scope && entry.scopeId === scopeId && entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.coreSettings = new Map([
["ai_globally_enabled", "true"],
["ai_private_endpoint_allowlist", '["localhost", "ollama.lan"]'],
]);
state.preferences = [
{ user_id: "u1", ai_assistant_enabled: 1, ai_read_only_commands: 0 },
{ user_id: "u2", ai_assistant_enabled: null, ai_read_only_commands: null },
];
state.hosts = [
{ id: 5, enable_ai_assistant: 1 },
{ id: 6, enable_ai_assistant: 0 },
];
state.providers = new Map([
[
"ai_providers",
[
{ id: 3, user_id: "u1", api_key: "enc:sk-one" },
{ id: 4, user_id: "u2", api_key: "sk-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("allowlistToText", () => {
it("turns the stored JSON array into one host per line", () => {
expect(allowlistToText('[" a ", "", "b"]')).toBe("a\nb");
expect(allowlistToText("[]")).toBe("");
expect(allowlistToText("nope")).toBeNull();
});
});
describe("runAiSettingsMigration", () => {
it("moves admin, user and host settings", async () => {
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(true);
expect(value("admin", null, "privateEndpoints")).toBe(
"localhost\nollama.lan",
);
expect(value("user", "u1", "enabled")).toBe(true);
expect(value("user", "u1", "allowReadOnlyCommands")).toBe(false);
// Never asked stays unset, which the plugin reads as not enabled.
expect(value("user", "u2", "enabled")).toBeUndefined();
expect(value("host", "5", "enableAiAssistant")).toBe(true);
expect(value("host", "6", "enableAiAssistant")).toBeUndefined();
});
it("moves each provider key into its owner's secrets, encrypted, and clears the column", async () => {
await runAiSettingsMigration();
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "provider:3",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:sk-one"),
});
expect(value("secret", "u2", "provider:4")).toBe("sysenc:sk-plain");
expect(
state.providers.get("ai_providers")!.every((row) => row.api_key === null),
).toBe(true);
});
it("leaves a key alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
await runAiSettingsMigration();
expect(value("secret", "u2", "provider:4")).toBeUndefined();
const row = state.providers.get("ai_providers")!.find((r) => r.id === 4);
expect(row?.api_key).toBe("sk-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.providers = new Map([
["p_ai_providers", [{ id: 8, user_id: "u1", api_key: "enc:sk-late" }]],
]);
await runAiSettingsMigration();
expect(value("secret", "u1", "provider:8")).toBe("sysenc:sk-late");
});
it("changes nothing on a second run", async () => {
await runAiSettingsMigration();
const first = JSON.stringify(state.pluginRows);
await runAiSettingsMigration();
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does not overwrite a value the plugin already has", async () => {
state.pluginRows.push({
pluginId: "ai",
scope: "admin",
scopeId: null,
key: "globallyEnabled",
value: "false",
encrypted: false,
});
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(false);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runAiSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -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);
@@ -1,117 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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");
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
return Object.fromEntries(
names.map((name) => [
name,
(...args: unknown[]) =>
(
helpers.fakeFactory(h.state) as Record<
string,
(...a: unknown[]) => unknown
>
)[name](...args),
]),
);
});
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");
function user(id: string, extra: Record<string, unknown>) {
h.state.users.set(id, {
id,
username: id,
passwordHash: "",
isAdmin: false,
isOidc: true,
...extra,
});
}
beforeEach(() => {
h.state = createAuthState();
});
describe("parseLegacyIdentifier", () => {
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
providerId: "ldap:4",
subject: "bob",
});
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
providerId: "7",
subject: "42",
});
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
providerId: "ldap:4",
subject: "cn=a:b",
});
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
providerId: "3",
subject: "sub-123",
});
expect(parseLegacyIdentifier("sub-123", null)).toEqual({
providerId: "legacy-oidc",
subject: "sub-123",
});
expect(parseLegacyIdentifier("github:null:42", null)).toEqual({
providerId: "legacy-oidc",
subject: "42",
});
});
});
describe("runExternalIdentityMigration", () => {
it("moves every identifier without touching the old data", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob", ssoProviderId: 4 });
user("gh-user", { oidcIdentifier: "github:7:42", ssoProviderId: 7 });
user("oidc-user", {
oidcIdentifier: "sub-123",
ssoProviderId: 3,
});
user("env-user", { oidcIdentifier: "someone@example.com" });
user("local", { isOidc: false, passwordHash: "hash" });
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 4, skipped: 0 });
expect(
h.state.identities.map(({ userId, providerId, subject }) => ({
userId,
providerId,
subject,
})),
).toEqual([
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
{ userId: "gh-user", providerId: "7", subject: "42" },
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
{
userId: "env-user",
providerId: "legacy-oidc",
subject: "someone@example.com",
},
]);
// Lossless: the old column stays.
expect(h.state.users.get("ldap-user")!.oidcIdentifier).toBe("ldap:4:bob");
});
it("is idempotent", async () => {
user("ldap-user", { oidcIdentifier: "ldap:4:bob" });
await runExternalIdentityMigration();
const second = await runExternalIdentityMigration();
expect(second).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -1,197 +0,0 @@
/**
* The Docker host settings migration.
*
* An upgrade must be lossless: every host with Docker on, and every host on
* Podman, must come through into the docker plugin's host settings, whatever
* shape the engine returned the flag in. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "docker" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: enable_docker");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
dockerSettingsFromRow,
runDockerSettingsMigration,
} from "../../utils/crypto-migration/docker-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("dockerSettingsFromRow", () => {
it("reads SQLite integers, real booleans and strings", () => {
expect(
dockerSettingsFromRow({ id: 1, enable_docker: 1, docker_config: null }),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: true,
docker_config: null,
}),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({ id: 1, enable_docker: 0, docker_config: null }),
).toEqual({});
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: false,
docker_config: null,
}),
).toEqual({});
});
it("keeps a Podman runtime and ignores the default or broken config", () => {
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: '{"runtime":"podman"}',
}),
).toEqual({ enableDocker: true, containerRuntime: "podman" });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: '{"runtime":"docker"}',
}),
).toEqual({ enableDocker: true });
expect(
dockerSettingsFromRow({
id: 1,
enable_docker: 1,
docker_config: "{oops",
}),
).toEqual({ enableDocker: true });
});
});
describe("runDockerSettingsMigration", () => {
it("moves every host's docker options and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, enable_docker: 1, docker_config: null },
{ id: 2, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
{ id: 3, enable_docker: 0, docker_config: null },
];
const result = await runDockerSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ enableDocker: true });
expect(hostValues(2)).toEqual({
enableDocker: true,
containerRuntime: "podman",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, enable_docker: 1, docker_config: '{"runtime":"podman"}' },
];
await runDockerSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runDockerSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "docker",
scope: "host",
scopeId: "1",
key: "enableDocker",
value: "false",
});
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
await runDockerSettingsMigration();
expect(hostValues(1)).toEqual({ enableDocker: false });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [{ id: 1, enable_docker: 1, docker_config: null }];
state.pluginInstalled = false;
expect(await runDockerSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runDockerSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,163 +0,0 @@
/**
* The file-manager host-columns migration.
*
* An upgrade must be lossless: a host that had file manager disabled, a
* custom default path, or SCP legacy mode on must keep that configuration
* once the ssh_data columns are dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_file_manager: boolean;
default_path: string | null;
scp_legacy: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_file_manager === false ||
row.default_path !== null ||
row.scp_legacy,
),
}),
}));
const { runFileManagerSettingsMigration } =
await import("../../utils/crypto-migration/file-manager-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runFileManagerSettingsMigration", () => {
it("moves a host with file manager disabled into plugin settings", async () => {
hostRows.push({
id: 1,
enable_file_manager: false,
default_path: "/srv/app",
scp_legacy: true,
});
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(1, "defaultPath")).toBe("/srv/app");
expect(storedValue(1, "scpLegacy")).toBe(true);
});
it("does nothing on a fresh install with no non-default hosts", async () => {
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
});
await runFileManagerSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runFileManagerSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_file_manager: false,
default_path: null,
scp_legacy: false,
},
{
id: 2,
enable_file_manager: true,
default_path: "/data",
scp_legacy: false,
},
);
const result = await runFileManagerSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableFileManager")).toBe(false);
expect(storedValue(2, "defaultPath")).toBe("/data");
});
});
@@ -1,200 +0,0 @@
/**
* Host Metrics' settings leaving core: the two admin keys, the new-host
* default inside host_defaults, and the metrics half of every host's
* stats_config. Nothing may be lost, and a second run must change nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
pluginInstalled: true,
db: null as unknown,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "host-metrics" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else state.pluginRows.push({ scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
let sqlite: Database.Database;
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows = [];
state.pluginInstalled = true;
sqlite = new Database(":memory:");
sqlite.exec(
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
);
state.db = drizzle(sqlite);
});
describe("hostSettingsFromStatsConfig", () => {
it("keeps only what differs from the defaults", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify({
statusCheckEnabled: false,
metricsEnabled: false,
metricsInterval: 45,
useGlobalMetricsInterval: false,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
}),
),
).toEqual({
metricsEnabled: false,
metricsInterval: 45,
enabledWidgets: ["cpu", "disk"],
excludedMounts: ["/snap"],
monitoredMounts: [{ path: "/data", label: "Data" }],
});
});
it("ignores the host's interval while it followed the global one", () => {
expect(
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
).toEqual({});
});
it("reads a value that was stringified twice", () => {
expect(
hostSettingsFromStatsConfig(
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
),
).toEqual({ metricsEnabled: false });
});
});
describe("runHostMetricsSettingsMigration", () => {
it("moves the admin keys, the new-host default and every host", async () => {
state.coreSettings.set("global_metrics_interval", "20");
state.coreSettings.set("metrics_history_retention_days", "30");
state.coreSettings.set(
"host_defaults",
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
);
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ metricsEnabled: false }));
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
insert.run(4, null);
const result = await runHostMetricsSettingsMigration();
expect(result.moved.sort()).toEqual([
"global_metrics_interval",
"host_defaults.metricsEnabled",
"metrics_history_retention_days",
]);
expect(value("admin", null, "metricsInterval")).toBe(20);
expect(value("admin", null, "historyRetentionDays")).toBe(30);
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
expect(value("host", "1", "metricsEnabled")).toBe(false);
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
// Only the status half, which is core's: nothing for the plugin.
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
expect(result.hostsMoved).toBe(2);
});
it("changes nothing the second time", async () => {
state.coreSettings.set("global_metrics_interval", "20");
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ metricsEnabled: false }));
await runHostMetricsSettingsMigration();
// Changed through the plugin after the upgrade.
const interval = find("admin", null, "metricsInterval")!;
interval.value = "90";
const enabled = find("host", "1", "metricsEnabled")!;
enabled.value = "true";
const second = await runHostMetricsSettingsMigration();
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
expect(value("admin", null, "metricsInterval")).toBe(90);
expect(value("host", "1", "metricsEnabled")).toBe(true);
});
it("waits until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("global_metrics_interval", "20");
expect(await runHostMetricsSettingsMigration()).toEqual({
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
it("skips values out of range", async () => {
state.coreSettings.set("global_metrics_interval", "1");
state.coreSettings.set("metrics_history_retention_days", "900");
await runHostMetricsSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,179 +0,0 @@
/**
* The 2.8 to 2.9 LDAP split. LDAP rows in sso_providers (adopted or not yet)
* move into the ldap plugin's table with their ids, their identities carry
* the "ldap:" prefix, the SSO rows stay, and running it twice changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({ db: null as unknown }));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runLdapProviderMigration } =
await import("../../utils/crypto-migration/ldap-provider-migration.js");
let sqlite: Database.Database;
const LEGACY_DDL = `
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
display_order INTEGER NOT NULL DEFAULT 0,
config TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
const TARGET_DDL = `
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL,
"enabled" integer NOT NULL DEFAULT 1,
"display_order" integer NOT NULL DEFAULT 0,
"config" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`;
function setup(options: { adopted: boolean; target: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
${LEGACY_DDL}
CREATE TABLE user_external_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
subject TEXT NOT NULL,
email TEXT
);
`);
const insert = sqlite.prepare(
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
);
insert.run(3, "Keycloak", "oidc", 1, '{"client_id":"termix"}');
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
insert.run(5, "Old LDAP", "ldap", 0, '{"host":"old.example"}');
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-oidc", "3", "sub-1");
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-ldap", "ldap:4", "bob");
if (options.adopted) {
sqlite.exec(`ALTER TABLE sso_providers RENAME TO p_sso_providers;`);
}
if (options.target) sqlite.exec(TARGET_DDL);
h.db = drizzle(sqlite);
}
const all = (query: string) => sqlite.prepare(query).all();
describe("runLdapProviderMigration", () => {
beforeEach(() => {
vi.unstubAllEnvs();
});
it("does nothing until the ldap plugin's table exists", async () => {
setup({ adopted: false, target: false });
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM sso_providers")).toHaveLength(3);
});
it.each([
["the legacy table", false],
["the adopted table", true],
])("moves LDAP rows out of %s with their ids", async (_label, adopted) => {
setup({ adopted, target: true });
const source = adopted ? "p_sso_providers" : "sso_providers";
expect(await runLdapProviderMigration()).toEqual({
moved: 2,
renumbered: 0,
});
expect(
all("SELECT id, name, enabled, config FROM p_ldap_providers ORDER BY id"),
).toEqual([
{
id: 4,
name: "Corp LDAP",
enabled: 1,
config: '{"host":"ldap.example"}',
},
{ id: 5, name: "Old LDAP", enabled: 0, config: '{"host":"old.example"}' },
]);
expect(all(`SELECT id, type FROM ${source}`)).toEqual([
{ id: 3, type: "oidc" },
]);
expect(
all("SELECT user_id, provider_id FROM user_external_identities"),
).toEqual([
{ user_id: "u-oidc", provider_id: "3" },
{ user_id: "u-ldap", provider_id: "ldap:4" },
]);
});
it("renames bare identities left from before the prefix", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
"UPDATE user_external_identities SET provider_id = '4' WHERE user_id = 'u-ldap'",
);
await runLdapProviderMigration();
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-ldap'",
),
).toEqual([{ provider_id: "ldap:4" }]);
});
it("gives a row a new id when the plugin already used it", async () => {
setup({ adopted: true, target: true });
sqlite.exec(
`INSERT INTO p_ldap_providers (id, name, config) VALUES (4, 'New', '{}')`,
);
sqlite
.prepare(
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
)
.run("u-bare", "4", "carol");
const result = await runLdapProviderMigration();
expect(result).toEqual({ moved: 2, renumbered: 1 });
const moved = all(
"SELECT id FROM p_ldap_providers WHERE name = 'Corp LDAP'",
) as Array<{ id: number }>;
expect(moved[0].id).not.toBe(4);
expect(
all(
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-bare'",
),
).toEqual([{ provider_id: `ldap:${moved[0].id}` }]);
});
it("is idempotent", async () => {
setup({ adopted: true, target: true });
await runLdapProviderMigration();
expect(await runLdapProviderMigration()).toEqual({
moved: 0,
renumbered: 0,
});
expect(all("SELECT id FROM p_ldap_providers")).toHaveLength(2);
});
});
@@ -1,96 +0,0 @@
/**
* The OPKSSH config move. A 2.8 install's config must reach the opkssh
* plugin's folder, the old file must stay for a downgrade, and the install
* must keep the redirect URI its identity providers know. Running it twice
* must not overwrite anything.
*/
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
settings: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "opkssh" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, _id: string | null, key: string) =>
state.settings.has(key) ? { key, value: state.settings.get(key) } : null,
set: async (
_p: string,
_s: string,
_id: string | null,
key: string,
value: string | null,
) => {
state.settings.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runOpksshConfigMigration } from "../../utils/crypto-migration/opkssh-config-migration.js";
let dataDir: string;
const legacy = () => path.join(dataDir, ".opk", "config.yml");
const target = () => path.join(dataDir, "plugins", "opkssh", "config.yml");
beforeEach(async () => {
state.settings = new Map();
state.pluginInstalled = true;
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "opkssh-config-"));
});
afterEach(async () => {
await fs.rm(dataDir, { recursive: true, force: true });
});
describe("runOpksshConfigMigration", () => {
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers:\n - alias: google\n");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: true,
legacyCallback: true,
});
expect(await fs.readFile(target(), "utf8")).toContain("alias: google");
expect(await fs.readFile(legacy(), "utf8")).toContain("alias: google");
expect(state.settings.get("legacyCallback")).toBe("true");
await fs.writeFile(target(), "providers:\n - alias: edited\n");
state.settings.set("legacyCallback", "false");
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(await fs.readFile(target(), "utf8")).toContain("alias: edited");
expect(state.settings.get("legacyCallback")).toBe("false");
});
it("does nothing on an install that never set up OPKSSH", async () => {
expect(await runOpksshConfigMigration(dataDir)).toEqual({
copied: false,
legacyCallback: false,
});
expect(state.settings.size).toBe(0);
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
await fs.mkdir(path.dirname(legacy()), { recursive: true });
await fs.writeFile(legacy(), "providers: []\n");
await runOpksshConfigMigration(dataDir);
await expect(fs.access(target())).rejects.toThrow();
});
});
@@ -1,171 +0,0 @@
/**
* The Proxmox host-columns migration.
*
* An upgrade must be lossless: a host that already had Proxmox discovery or
* stats configured must keep that configuration once the ssh_data columns
* are dropped. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_proxmox: boolean;
proxmox_config: string | null;
enable_proxmox_stats: boolean;
proxmox_stats_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_proxmox ||
row.proxmox_config !== null ||
row.enable_proxmox_stats ||
row.proxmox_stats_config !== null,
),
}),
}));
const { runProxmoxSettingsMigration } =
await import("../../utils/crypto-migration/proxmox-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runProxmoxSettingsMigration", () => {
it("moves an enabled host's columns into plugin settings", async () => {
hostRows.push({
id: 1,
enable_proxmox: true,
proxmox_config: JSON.stringify({ defaultAuthType: "password" }),
enable_proxmox_stats: true,
proxmox_stats_config: JSON.stringify({ nodeName: "pve1" }),
});
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableProxmox")).toBe(true);
expect(storedValue(1, "proxmoxConfig")).toEqual({
defaultAuthType: "password",
});
expect(storedValue(1, "enableProxmoxStats")).toBe(true);
expect(storedValue(1, "proxmoxStatsConfig")).toEqual({ nodeName: "pve1" });
});
it("does nothing on a fresh install with no proxmox hosts", async () => {
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({
id: 2,
enable_proxmox: false,
proxmox_config: JSON.stringify({ autoSyncEnabled: true }),
enable_proxmox_stats: false,
proxmox_stats_config: null,
});
await runProxmoxSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runProxmoxSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("moves each host independently", async () => {
hostRows.push(
{
id: 1,
enable_proxmox: true,
proxmox_config: null,
enable_proxmox_stats: false,
proxmox_stats_config: null,
},
{
id: 2,
enable_proxmox: false,
proxmox_config: null,
enable_proxmox_stats: true,
proxmox_stats_config: JSON.stringify({ nodeName: "pve2" }),
},
);
const result = await runProxmoxSettingsMigration();
expect(result.moved).toBe(2);
expect(storedValue(1, "enableProxmox")).toBe(true);
expect(storedValue(2, "proxmoxStatsConfig")).toEqual({ nodeName: "pve2" });
});
});
@@ -1,256 +0,0 @@
/**
* The Remote Desktop settings migration.
*
* An upgrade must be lossless: the admin switch and guacd address, each
* user's RDP defaults and every host's RDP/VNC/Telnet options must come
* through into the plugin's own settings, whatever shape an old host had.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const state = vi.hoisted(() => ({
coreSettings: new Map<string, string>(),
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
userRows: [] as Array<{ user_id: string; rdp_defaults: string | null }>,
statements: [] as string[],
pluginInstalled: true,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.pluginRows.find(
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "remote-desktop" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query.queryChunks ?? query);
return text.includes("user_preferences") ? state.userRows : state.hostRows;
},
runStatement: async (query: unknown) => {
state.statements.push(JSON.stringify(query));
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runRemoteDesktopSettingsMigration, hostSettingsFromRow } =
await import("../../utils/crypto-migration/remote-desktop-settings-migration.js");
const value = (scope: string, scopeId: string | null, key: string) => {
const row = find(scope, scopeId, key) as Row | undefined;
return row?.value == null ? undefined : JSON.parse(row.value);
};
function host(overrides: Record<string, unknown>) {
return {
id: 1,
port: 22,
connection_type: "ssh",
enable_rdp: 0,
enable_vnc: 0,
enable_telnet: 0,
rdp_port: 3389,
vnc_port: 5900,
telnet_port: 23,
rdp_security: null,
rdp_ignore_cert: 0,
security: null,
ignore_cert: 0,
guacamole_config: null,
enable_terminal_toolbar: 1,
...overrides,
};
}
beforeEach(() => {
state.coreSettings.clear();
state.pluginRows.length = 0;
state.hostRows.length = 0;
state.userRows.length = 0;
state.statements.length = 0;
state.pluginInstalled = true;
});
describe("runRemoteDesktopSettingsMigration", () => {
it("moves the admin switch and guacd address", async () => {
state.coreSettings.set("guac_enabled", "false");
state.coreSettings.set("guac_url", "guacd:4822");
const result = await runRemoteDesktopSettingsMigration();
expect(result.moved).toEqual(["guac_enabled", "guac_url"]);
expect(value("admin", null, "enabled")).toBe(false);
expect(value("admin", null, "guacdUrl")).toBe("guacd:4822");
});
it("moves a host's options, reading SQLite's 0 and 1 as booleans", async () => {
state.hostRows.push(
host({
id: 7,
enable_rdp: 1,
enable_vnc: 0,
rdp_port: 3390,
rdp_security: "nla",
rdp_ignore_cert: 1,
guacamole_config: JSON.stringify({ "color-depth": 24 }),
enable_terminal_toolbar: 0,
}),
);
const result = await runRemoteDesktopSettingsMigration();
expect(result.hostsMoved).toBe(1);
expect(value("host", "7", "enableRdp")).toBe(true);
expect(value("host", "7", "enableVnc")).toBeUndefined();
expect(value("host", "7", "rdpPort")).toBe(3390);
expect(value("host", "7", "rdpSecurity")).toBe("nla");
expect(value("host", "7", "rdpIgnoreCert")).toBe(true);
expect(value("host", "7", "guacamoleConfig")).toEqual({
"color-depth": 24,
});
expect(value("host", "7", "enableToolbar")).toBe(false);
});
it("leaves a plain SSH host alone", async () => {
state.hostRows.push(host({ id: 3 }));
const result = await runRemoteDesktopSettingsMigration();
expect(result.hostsMoved).toBe(0);
expect(state.pluginRows).toHaveLength(0);
});
it("reads real booleans too, as Postgres returns them", async () => {
state.hostRows.push(host({ id: 8, enable_vnc: true, vnc_port: 5901 }));
await runRemoteDesktopSettingsMigration();
expect(value("host", "8", "enableVnc")).toBe(true);
expect(value("host", "8", "vncPort")).toBe(5901);
});
it("moves each user's RDP defaults", async () => {
state.userRows.push({
user_id: "u1",
rdp_defaults: JSON.stringify({
colorDepth: 24,
resizeMethod: "reconnect",
disableCopy: true,
enableWallpaper: false,
}),
});
state.userRows.push({ user_id: "u2", rdp_defaults: null });
const result = await runRemoteDesktopSettingsMigration();
expect(result.usersMoved).toBe(1);
expect(value("user", "u1", "colorDepth")).toBe("24");
expect(value("user", "u1", "resizeMethod")).toBe("reconnect");
expect(value("user", "u1", "disableCopy")).toBe("on");
expect(value("user", "u1", "enableWallpaper")).toBe("off");
});
it("changes nothing when run twice", async () => {
state.coreSettings.set("guac_url", "guacd:4822");
state.hostRows.push(host({ id: 7, enable_rdp: 1, rdp_port: 3390 }));
state.userRows.push({
user_id: "u1",
rdp_defaults: JSON.stringify({ colorDepth: 16 }),
});
await runRemoteDesktopSettingsMigration();
const firstRun = JSON.stringify(state.pluginRows);
// Changed after the first run: must not be overwritten by the old values.
find("host", "7", "rdpPort")!.value = JSON.stringify(4000);
const second = await runRemoteDesktopSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.usersMoved).toBe(0);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(value("host", "7", "rdpPort")).toBe(4000);
expect(JSON.parse(firstRun)).toHaveLength(state.pluginRows.length);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
state.coreSettings.set("guac_url", "guacd:4822");
state.hostRows.push(host({ id: 7, enable_rdp: 1 }));
await runRemoteDesktopSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
describe("hostSettingsFromRow", () => {
it("turns a connection_type-only host into its protocol switch and port", () => {
expect(
hostSettingsFromRow(
host({ connection_type: "vnc", port: 5905, vnc_port: null }),
),
).toEqual({ enableVnc: true, vncPort: 5905 });
});
it("folds the older security and ignore_cert columns in", () => {
expect(
hostSettingsFromRow(
host({
enable_rdp: 1,
rdp_security: null,
security: "tls",
rdp_ignore_cert: null,
ignore_cert: 1,
}),
),
).toEqual({ enableRdp: true, rdpSecurity: "tls", rdpIgnoreCert: true });
});
});
@@ -1,206 +0,0 @@
/**
* The secret source token migration.
*
* An upgrade must be lossless: every source's access token has to reach the
* secret-sources plugin's ctx.secrets, encrypted, and be gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const state = vi.hoisted(() => ({
pluginRows: [] as Row[],
sources: new Map<string, Array<Record<string, unknown>>>(),
pluginInstalled: true,
deks: new Set<string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "secret-sources" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows.push({
pluginId,
scope,
scopeId,
key,
value,
encrypted,
});
},
}),
}));
/** The SQL text of a drizzle sql`` query, identifiers included. */
function text(query: { queryChunks: unknown[] }): string {
return query.queryChunks
.map((chunk) => {
if (typeof chunk === "string") return chunk;
const value = (chunk as { value?: unknown; name?: unknown }) ?? {};
if (Array.isArray(value.value)) return value.value.join("");
if (typeof value.value === "string") return value.value;
if (typeof value.name === "string") return value.name;
return String(chunk);
})
.join("");
}
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (new RegExp(`FROM ${table} `).test(sqlText)) {
return rows.filter((row) => row.token);
}
}
throw new Error(`no such table in: ${sqlText}`);
},
runStatement: async (query: { queryChunks: unknown[] }) => {
const sqlText = text(query);
for (const [table, rows] of state.sources) {
if (!sqlText.includes(`UPDATE ${table} `)) continue;
const id = query.queryChunks.find((chunk) => typeof chunk === "string");
for (const row of rows) if (row.id === id) row.token = null;
}
},
}));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: {
getUserDataKey: (userId: string) =>
state.deks.has(userId) ? Buffer.from(userId) : null,
},
}));
vi.mock("../../utils/lazy-field-encryption.js", () => ({
LazyFieldEncryption: {
// Stands in for FieldCrypto: "enc:<plain>" decrypts, anything else is a
// legacy plaintext value.
safeGetFieldValue: (value: string) => value.replace(/^enc:/, ""),
},
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSecretSourcesTokenMigration } from "../../utils/crypto-migration/secret-sources-token-migration.js";
const value = (scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
(entry) =>
entry.scope === "secret" &&
entry.scopeId === scopeId &&
entry.key === key,
);
return row ? JSON.parse(row.value ?? "null") : undefined;
};
beforeEach(() => {
state.pluginRows = [];
state.sources = new Map([
[
"secret_sources",
[
{ id: "src-1", user_id: "u1", token: "enc:tok-one" },
{ id: "src-2", user_id: "u2", token: "tok-plain" },
],
],
]);
state.pluginInstalled = true;
state.deks = new Set(["u1", "u2"]);
});
describe("runSecretSourcesTokenMigration", () => {
it("moves each source's token into its owner's secrets, encrypted, and clears the column", async () => {
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 2 });
const secret = state.pluginRows.find(
(row) => row.scope === "secret" && row.key === "source:src-1",
);
expect(secret).toMatchObject({
scopeId: "u1",
encrypted: true,
value: JSON.stringify("sysenc:tok-one"),
});
expect(value("u2", "source:src-2")).toBe("sysenc:tok-plain");
expect(
state.sources.get("secret_sources")!.every((row) => row.token === null),
).toBe(true);
});
it("leaves a token alone while its owner's data key is unavailable", async () => {
state.deks = new Set(["u1"]);
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 1 });
expect(value("u2", "source:src-2")).toBeUndefined();
const row = state.sources
.get("secret_sources")!
.find((r) => r.id === "src-2");
expect(row?.token).toBe("tok-plain");
});
it("reads the adopted table once the plugin has renamed it", async () => {
state.sources = new Map([
[
"p_secret_sources_sources",
[{ id: "src-9", user_id: "u1", token: "enc:tok-late" }],
],
]);
await runSecretSourcesTokenMigration();
expect(value("u1", "source:src-9")).toBe("sysenc:tok-late");
});
it("changes nothing on a second run", async () => {
await runSecretSourcesTokenMigration();
const first = JSON.stringify(state.pluginRows);
const second = await runSecretSourcesTokenMigration();
expect(second).toEqual({ moved: 0 });
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
const result = await runSecretSourcesTokenMigration();
expect(result).toEqual({ moved: 0 });
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,169 +0,0 @@
/**
* The session logging host-column migration.
*
* The legacy column defaulted to true, the plugin's enable switch defaults
* to false, so every host needs an explicit row, not just the ones that
* turned it off. Running it twice must not duplicate or clobber what it
* moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_session_logging: boolean | number | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows,
}),
}));
const { runSessionRecordingSettingsMigration } =
await import("../../utils/crypto-migration/session-recording-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runSessionRecordingSettingsMigration", () => {
it("moves an enabled host's switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_session_logging: true });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableSessionRecording")).toBe(true);
expect(
settingsRows.every((row) => row.pluginId === "session-recording"),
).toBe(true);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("moves a disabled host's switch too, since the defaults flipped", async () => {
hostRows.push({ id: 3, enable_session_logging: false });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(3, "enableSessionRecording")).toBe(false);
});
it("reads SQLite 0 and 1 as off and on", async () => {
hostRows.push({ id: 6, enable_session_logging: 0 });
hostRows.push({ id: 7, enable_session_logging: 1 });
await runSessionRecordingSettingsMigration();
expect(storedValue(6, "enableSessionRecording")).toBe(false);
expect(storedValue(7, "enableSessionRecording")).toBe(true);
});
it("treats a null legacy value as on, matching the old column default", async () => {
hostRows.push({ id: 4, enable_session_logging: null });
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "enableSessionRecording")).toBe(true);
});
it("does nothing on a fresh install", async () => {
const result = await runSessionRecordingSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_session_logging: true });
await runSessionRecordingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionRecordingSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_session_logging: true });
await pluginSettingsRepository.set(
"session-recording",
"host",
"5",
"enableSessionRecording",
"false",
);
const result = await runSessionRecordingSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableSessionRecording")).toBe(false);
});
});
@@ -1,151 +0,0 @@
/**
* The session sharing settings migration.
*
* An upgrade must be lossless: an instance with sharing switched off, and a
* host with sharing switched off, must stay off once the plugin reads its own
* settings. Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const blockedHostIds: number[] = [];
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => blockedHostIds.map((id) => ({ id })),
}),
}));
const { runSessionSharingSettingsMigration } =
await import("../../utils/crypto-migration/session-sharing-settings-migration.js");
function stored(scopeId: string | null, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === scopeId && entry.key === key,
);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
blockedHostIds.length = 0;
coreSettings.clear();
settingsRows.length = 0;
});
describe("runSessionSharingSettingsMigration", () => {
it("moves a disabled global switch into the admin setting", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
const result = await runSessionSharingSettingsMigration();
expect(result.movedGlobal).toBe(true);
expect(stored(null, "globallyEnabled")).toBe(false);
expect(settingsRows[0]).toMatchObject({
pluginId: "session-sharing",
scope: "admin",
});
});
it("moves hosts that turned sharing off into host settings", async () => {
blockedHostIds.push(4, 9);
const result = await runSessionSharingSettingsMigration();
expect(result.movedHosts).toBe(2);
expect(stored("4", "allowSessionSharing")).toBe(false);
expect(stored("9", "allowSessionSharing")).toBe(false);
});
it("writes nothing when sharing was on everywhere", async () => {
coreSettings.set("session_sharing_globally_enabled", "true");
const result = await runSessionSharingSettingsMigration();
expect(result).toEqual({ movedGlobal: false, movedHosts: 0, skipped: 0 });
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("session_sharing_globally_enabled", "false");
blockedHostIds.push(2);
await runSessionSharingSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runSessionSharingSettingsMigration();
expect(second.movedGlobal).toBe(false);
expect(second.movedHosts).toBe(0);
expect(second.skipped).toBe(2);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
blockedHostIds.push(5);
await pluginSettingsRepository.set(
"session-sharing",
"host",
"5",
"allowSessionSharing",
"true",
);
const result = await runSessionSharingSettingsMigration();
expect(result.skipped).toBe(1);
expect(stored("5", "allowSessionSharing")).toBe(true);
});
});
@@ -1,217 +0,0 @@
/**
* The SSH terminal settings migration.
*
* An upgrade must be lossless: an install's session timeout, command history
* switch, touch tuning and image storage settings, and every host that turned
* a terminal switch off, must come through into the plugin's own settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
interface HostRow {
id: number;
enable_terminal: number | null;
enable_terminal_toolbar: number | null;
enable_command_history: number | null;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
const hostRows: HostRow[] = [];
let pluginInstalled = true;
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) =>
row.enable_terminal === 0 ||
row.enable_terminal_toolbar === 0 ||
row.enable_command_history === 0,
),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (plaintext: string) =>
plaintext.startsWith("sysenc:") ? plaintext : `sysenc:${plaintext}`,
}));
const { runSshTerminalSettingsMigration } =
await import("../../utils/crypto-migration/ssh-terminal-settings-migration.js");
function admin(key: string): unknown {
const row = find("ssh-terminal", "admin", null, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function host(id: number, key: string): unknown {
const row = find("ssh-terminal", "host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
hostRows.length = 0;
pluginInstalled = true;
});
describe("runSshTerminalSettingsMigration", () => {
it("moves the admin settings with their types, the local dir as a secret", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
coreSettings.set("terminal_session_persistence_enabled", "false");
coreSettings.set("command_history_enabled", "false");
coreSettings.set(
"touch_input_settings",
JSON.stringify({ enabled: false, dragThresholdPx: 12 }),
);
coreSettings.set("terminal_image_storage_mode", "local");
coreSettings.set("terminal_image_local_dir", "/data/images");
coreSettings.set("terminal_image_max_count", "50");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toHaveLength(7);
expect(admin("sessionTimeoutMinutes")).toBe(120);
expect(admin("sessionPersistence")).toBe(false);
expect(admin("commandHistoryEnabled")).toBe(false);
expect(admin("touchInput")).toEqual({
enabled: false,
dragThresholdPx: 12,
});
expect(admin("imageStorageMode")).toBe("local");
expect(admin("imageMaxCount")).toBe(50);
const localDir = find("ssh-terminal", "admin", null, "imageLocalDir");
expect(localDir?.encrypted).toBe(true);
expect(JSON.parse(localDir!.value!)).toBe("sysenc:/data/images");
});
it("copies each host that switched something off, keeping the rest on", async () => {
hostRows.push(
{
id: 1,
enable_terminal: 1,
enable_terminal_toolbar: 0,
enable_command_history: 1,
},
{
id: 2,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 0,
},
{
id: 3,
enable_terminal: 1,
enable_terminal_toolbar: 1,
enable_command_history: 1,
},
);
const result = await runSshTerminalSettingsMigration();
expect(result.hostsMoved).toBe(2);
expect(host(1, "enableTerminal")).toBe(true);
expect(host(1, "enableTerminalToolbar")).toBe(false);
expect(host(1, "enableCommandHistory")).toBe(true);
expect(host(2, "enableTerminal")).toBe(false);
expect(host(2, "enableCommandHistory")).toBe(false);
// All on is the plugin's default, so it needs no row.
expect(host(3, "enableTerminal")).toBeUndefined();
});
it("changes nothing on a second run, even after the admin changed a value", async () => {
coreSettings.set("terminal_session_timeout_minutes", "120");
hostRows.push({
id: 1,
enable_terminal: 0,
enable_terminal_toolbar: 1,
enable_command_history: 1,
});
await runSshTerminalSettingsMigration();
const changed = find(
"ssh-terminal",
"admin",
null,
"sessionTimeoutMinutes",
);
changed!.value = JSON.stringify(15);
const rowsBefore = pluginRows.length;
const second = await runSshTerminalSettingsMigration();
expect(second.moved).toEqual([]);
expect(second.hostsMoved).toBe(0);
expect(second.hostsSkipped).toBe(1);
expect(pluginRows).toHaveLength(rowsBefore);
expect(admin("sessionTimeoutMinutes")).toBe(15);
});
it("does nothing until the plugin row exists", async () => {
pluginInstalled = false;
coreSettings.set("terminal_session_timeout_minutes", "120");
const result = await runSshTerminalSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
});
@@ -1,116 +0,0 @@
/**
* The Step CA settings migration: a 2.8 CA configuration and its private
* endpoint allowlist come through into the step-ca plugin's admin settings,
* the old redirect URI stays in use, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{ key: string; value: string | null }>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "step-ca" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value });
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runStepCaSettingsMigration } from "../../utils/crypto-migration/step-ca-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
step_ca_url: "https://ca.internal:9000",
step_ca_fingerprint: "a".repeat(64),
step_ca_provisioner: "oidc",
step_ca_private_endpoint_allowlist: JSON.stringify([
"ca.internal",
"SSO.internal",
]),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runStepCaSettingsMigration", () => {
it("moves the 2.8 settings and keeps the old redirect URI", async () => {
const result = await runStepCaSettingsMigration();
expect(result.moved).toEqual([
"step_ca_url",
"step_ca_fingerprint",
"step_ca_provisioner",
"step_ca_private_endpoint_allowlist",
]);
expect(result.legacyCallback).toBe(true);
expect(value("caUrl")).toBe("https://ca.internal:9000");
expect(value("fingerprint")).toBe("a".repeat(64));
expect(value("provisioner")).toBe("oidc");
expect(value("privateEndpoints")).toBe("ca.internal, sso.internal");
expect(value("legacyCallback")).toBe(true);
});
it("changes nothing the second time", async () => {
await runStepCaSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const again = await runStepCaSettingsMigration();
expect(again).toEqual({ moved: [], legacyCallback: false });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("never overwrites a value set in the plugin", async () => {
state.pluginRows = [
{ key: "caUrl", value: JSON.stringify("https://new.ca") },
{ key: "legacyCallback", value: JSON.stringify(false) },
];
await runStepCaSettingsMigration();
expect(value("caUrl")).toBe("https://new.ca");
expect(value("legacyCallback")).toBe(false);
expect(value("provisioner")).toBe("oidc");
});
it("leaves an install that never used Step CA alone", async () => {
state.legacy = {};
const result = await runStepCaSettingsMigration();
expect(result).toEqual({ moved: [], legacyCallback: false });
expect(state.pluginRows).toEqual([]);
});
it("does nothing until the plugin row exists", async () => {
state.pluginInstalled = false;
await runStepCaSettingsMigration();
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,228 +0,0 @@
/**
* The Tailscale settings migration.
*
* An upgrade must be lossless: an install that had a working API key must keep
* working without anyone re-entering it. That is the whole point of the test,
* and running it twice proves a restart cannot corrupt what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
encrypted: boolean;
}
const coreSettings = new Map<string, string>();
const pluginRows: Row[] = [];
let pluginInstalled = true;
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
const existing = pluginRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
if (existing) {
existing.value = value;
existing.encrypted = encrypted;
return;
}
pluginRows.push({ pluginId, scope, scopeId, key, value, encrypted });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "tailscale" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
delete: async (key: string) => coreSettings.delete(key),
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (plaintext: string) =>
!plaintext || plaintext.startsWith("sysenc:v1:")
? plaintext
: `sysenc:v1:${Buffer.from(plaintext, "utf8").toString("base64")}`,
decryptSystemSecret: async (stored: string) =>
stored.startsWith("sysenc:v1:")
? Buffer.from(stored.slice("sysenc:v1:".length), "base64").toString(
"utf8",
)
: stored,
}));
const { runTailscaleSettingsMigration } =
await import("../../utils/crypto-migration/tailscale-settings-migration.js");
function storedValue(key: string): unknown {
const row = pluginRows.find((entry) => entry.key === key);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
coreSettings.clear();
pluginRows.length = 0;
pluginInstalled = true;
});
describe("runTailscaleSettingsMigration", () => {
it("moves an existing key and base URL into plugin settings", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([
"tailscale_api_key",
"tailscale_api_base_url",
]);
expect(storedValue("apiKey")).toMatch(/^sysenc:v1:/);
expect(storedValue("apiBaseUrl")).toBe("https://headscale.example");
});
it("encrypts the key, which was stored in plaintext before", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiKey")!;
expect(row.encrypted).toBe(true);
expect(row.value).not.toContain("tskey-api-legacy");
});
it("leaves the base URL readable, since it is not a secret", async () => {
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const row = pluginRows.find((entry) => entry.key === "apiBaseUrl")!;
expect(row.encrypted).toBe(false);
});
it("removes the legacy rows so no plaintext key is left behind", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
expect(coreSettings.has("tailscale_api_key")).toBe(false);
expect(coreSettings.has("tailscale_api_base_url")).toBe(false);
});
it("is idempotent: a second run changes nothing", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
coreSettings.set("tailscale_api_base_url", "https://headscale.example");
await runTailscaleSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(pluginRows));
const second = await runTailscaleSettingsMigration();
expect(second.moved).toEqual([]);
expect(pluginRows).toEqual(afterFirst);
});
it("cannot double-encrypt the key across runs", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
await runTailscaleSettingsMigration();
const first = storedValue("apiKey");
await runTailscaleSettingsMigration();
expect(storedValue("apiKey")).toBe(first);
expect(String(storedValue("apiKey")).match(/sysenc:v1:/g)).toHaveLength(1);
});
it("drops a stale legacy row when the value was already moved", async () => {
// An interrupted run, or a downgrade that rewrote the old key.
await pluginSettingsRepository.set(
"tailscale",
"admin",
null,
"apiKey",
JSON.stringify("sysenc:v1:already"),
true,
);
coreSettings.set("tailscale_api_key", "tskey-plaintext-leftover");
const result = await runTailscaleSettingsMigration();
expect(result.skipped).toContain("tailscale_api_key");
expect(storedValue("apiKey")).toBe("sysenc:v1:already");
expect(coreSettings.has("tailscale_api_key")).toBe(false);
});
it("does nothing on a fresh install with no legacy rows", async () => {
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("skips an empty legacy value rather than storing one", async () => {
coreSettings.set("tailscale_api_key", "");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
});
it("waits for the plugin row, because of the foreign key", async () => {
pluginInstalled = false;
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual([]);
expect(pluginRows).toEqual([]);
// Still there for the next boot, once the plugin is seeded.
expect(coreSettings.get("tailscale_api_key")).toBe("tskey-api-legacy");
});
it("moves each key independently", async () => {
coreSettings.set("tailscale_api_key", "tskey-api-legacy");
const result = await runTailscaleSettingsMigration();
expect(result.moved).toEqual(["tailscale_api_key"]);
expect(storedValue("apiBaseUrl")).toBeUndefined();
});
});
@@ -1,114 +0,0 @@
/**
* The 2.8 to 2.9 Termix ID CA move. A CA private key encrypted with the
* owner's data key must end up sealed with the installation key in the
* plugin's adopted table, readable, and a second run changes nothing.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
isSystemEncrypted: (value: string) => value.startsWith("sysenc:v1:"),
encryptSystemSecret: async (value: string) => `sysenc:v1:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTermixIdentityCaMigration } =
await import("../../utils/crypto-migration/termix-identity-ca-migration.js");
const PEM = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n";
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_termix_identity_ca (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_id INTEGER NOT NULL UNIQUE,
user_id TEXT NOT NULL,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
validity_days INTEGER NOT NULL DEFAULT 90
);
`);
}
h.db = drizzle(sqlite);
}
function addCa(id: number, userId: string, withKey = true) {
const dek = crypto.randomBytes(32);
if (withKey) h.deks.set(userId, dek);
sqlite
.prepare(
"INSERT INTO p_termix_identity_ca (id, identity_id, user_id, public_key, private_key) VALUES (?, ?, ?, 'ssh-ed25519 AAAA', ?)",
)
.run(
id,
id,
userId,
FieldCrypto.encryptField(PEM, dek, String(id), "privateKey"),
);
}
const keyOf = (id: number) =>
(
sqlite
.prepare("SELECT private_key FROM p_termix_identity_ca WHERE id = ?")
.get(id) as { private_key: string }
).private_key;
beforeEach(() => {
h.deks.clear();
});
describe("runTermixIdentityCaMigration", () => {
it("does nothing before the plugin adopted the table", async () => {
setup({ pluginTable: false });
expect(await runTermixIdentityCaMigration()).toBe(0);
});
it("reseals a data-key CA with the installation key", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
expect(await runTermixIdentityCaMigration()).toBe(1);
expect(keyOf(1)).toBe(`sysenc:v1:${PEM}`);
});
it("changes nothing on a second run", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
await runTermixIdentityCaMigration();
const first = keyOf(1);
expect(await runTermixIdentityCaMigration()).toBe(0);
expect(keyOf(1)).toBe(first);
});
it("leaves a row whose owner's key is not open for the next run", async () => {
setup({ pluginTable: true });
addCa(1, "alice");
addCa(2, "bob", false);
const locked = keyOf(2);
expect(await runTermixIdentityCaMigration()).toBe(1);
expect(keyOf(2)).toBe(locked);
});
});
@@ -1,149 +0,0 @@
/**
* The tmux monitor host-column migration.
*
* An upgrade must be lossless: a host with the monitor on must keep it on
* once the ssh_data column is dropped. Running it twice must not duplicate
* or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tmux_monitor: boolean;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () => hostRows.filter((row) => row.enable_tmux_monitor),
}),
}));
const { runTmuxMonitorSettingsMigration } =
await import("../../utils/crypto-migration/tmux-monitor-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTmuxMonitorSettingsMigration", () => {
it("moves a host's enabled switch into plugin settings", async () => {
hostRows.push({ id: 1, enable_tmux_monitor: true });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTmuxMonitor")).toBe(true);
expect(settingsRows.every((row) => row.pluginId === "tmux-monitor")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("leaves a disabled host untouched", async () => {
hostRows.push({ id: 3, enable_tmux_monitor: false });
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(storedValue(3, "enableTmuxMonitor")).toBeUndefined();
});
it("does nothing on a fresh install", async () => {
const result = await runTmuxMonitorSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tmux_monitor: true });
await runTmuxMonitorSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTmuxMonitorSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({ id: 5, enable_tmux_monitor: true });
await pluginSettingsRepository.set(
"tmux-monitor",
"host",
"5",
"enableTmuxMonitor",
"false",
);
const result = await runTmuxMonitorSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTmuxMonitor")).toBe(false);
});
});
@@ -1,208 +0,0 @@
/**
* The 2.8 to 2.9 TOTP move. A user who had TOTP on must still be asked for it
* after upgrading: their enrolment row points at the totp plugin, and their
* secret and backup codes reach the plugin's table readable. Running it twice
* changes nothing, and without the plugin's table the enrolment still moves
* so the login fails closed.
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FieldCrypto } from "../../utils/field-crypto.js";
const h = vi.hoisted(() => ({
db: null as unknown,
deks: new Map<string, Buffer>(),
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
vi.mock("../../utils/data-crypto.js", () => ({
DataCrypto: { getUserDataKey: (userId: string) => h.deks.get(userId) },
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `sysenc:${value}`,
}));
vi.mock("../../utils/logger.js", () => {
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
return { databaseLogger: log };
});
const { runTotpMigration } =
await import("../../utils/crypto-migration/totp-migration.js");
let sqlite: Database.Database;
function setup({ pluginTable }: { pluginTable: boolean }) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
totp_secret TEXT,
totp_enabled INTEGER NOT NULL DEFAULT 0,
totp_backup_codes TEXT
);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL,
factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, plugin_id, factor_id)
);
`);
if (pluginTable) {
sqlite.exec(`
CREATE TABLE p_totp_enrollments (
user_id TEXT PRIMARY KEY NOT NULL,
secret TEXT,
pending_secret TEXT,
backup_codes TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
}
h.db = drizzle(sqlite);
}
function addTotpUser(id: string, secret: string, codes: string[]) {
const dek = crypto.randomBytes(32);
h.deks.set(id, dek);
sqlite
.prepare(
"INSERT INTO users (id, username, totp_secret, totp_enabled, totp_backup_codes) VALUES (?, ?, ?, 1, ?)",
)
.run(
id,
id,
FieldCrypto.encryptField(secret, dek, id, "totpSecret"),
FieldCrypto.encryptField(
JSON.stringify(codes),
dek,
id,
"totpBackupCodes",
),
);
}
const factors = () =>
sqlite
.prepare(
"SELECT user_id, plugin_id, factor_id FROM user_second_factors ORDER BY user_id",
)
.all();
beforeEach(() => {
h.deks.clear();
});
describe("runTotpMigration", () => {
it("keeps a 2.8 TOTP user behind their factor, with a readable secret", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111", "BBBB2222"]);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
sqlite.exec(`INSERT INTO users (id, username) VALUES ('carol', 'carol')`);
// A8 recorded alice under core; bob only has the flag.
sqlite.exec(
`INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('alice', 'core', 'totp')`,
);
expect(await runTotpMigration()).toEqual({ factors: 2, secrets: 2 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
{ user_id: "bob", plugin_id: "totp", factor_id: "totp" },
]);
const alice = sqlite
.prepare("SELECT * FROM p_totp_enrollments WHERE user_id = 'alice'")
.get() as Record<string, string>;
expect(alice.secret).toBe("sysenc:JBSWY3DPEHPK3PXP");
expect(alice.backup_codes).toBe(
`sysenc:${JSON.stringify(["AAAA1111", "BBBB2222"])}`,
);
expect(alice.pending_secret).toBeNull();
expect(
sqlite
.prepare(
"SELECT totp_enabled, totp_secret, totp_backup_codes FROM users WHERE id = 'alice'",
)
.get(),
).toEqual({ totp_enabled: 0, totp_secret: null, totp_backup_codes: null });
// A second boot changes nothing.
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
expect(factors()).toHaveLength(2);
expect(
sqlite.prepare("SELECT COUNT(*) AS n FROM p_totp_enrollments").get(),
).toEqual({ n: 2 });
});
it("moves the enrolment but keeps the columns while the plugin table is missing", async () => {
setup({ pluginTable: false });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", ["AAAA1111"]);
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
expect(factors()).toEqual([
{ user_id: "alice", plugin_id: "totp", factor_id: "totp" },
]);
const row = sqlite
.prepare("SELECT totp_enabled, totp_secret FROM users WHERE id = 'alice'")
.get() as { totp_enabled: number; totp_secret: string };
expect(row.totp_enabled).toBe(1);
expect(row.totp_secret).toBeTruthy();
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
it("skips a user whose data key is unavailable and retries later", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
const dek = h.deks.get("alice")!;
h.deks.delete("alice");
expect(await runTotpMigration()).toEqual({ factors: 1, secrets: 0 });
h.deks.set("alice", dek);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 1 });
});
it("moves only the named user when a login opens their key", async () => {
setup({ pluginTable: true });
addTotpUser("alice", "JBSWY3DPEHPK3PXP", []);
addTotpUser("bob", "KRSXG5CTMVRXEZLU", []);
expect(await runTotpMigration("alice")).toEqual({ factors: 1, secrets: 1 });
const moved = sqlite
.prepare("SELECT user_id FROM p_totp_enrollments")
.all();
expect(moved).toEqual([{ user_id: "alice" }]);
});
it("drops a duplicate core row when the plugin row already exists", async () => {
setup({ pluginTable: true });
sqlite.exec(`
INSERT INTO users (id, username) VALUES ('dave', 'dave');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'core', 'totp');
INSERT INTO user_second_factors (user_id, plugin_id, factor_id) VALUES ('dave', 'totp', 'totp');
`);
await runTotpMigration();
expect(factors()).toEqual([
{ user_id: "dave", plugin_id: "totp", factor_id: "totp" },
]);
});
it("does nothing on a fresh install without the legacy columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
CREATE TABLE user_second_factors (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL,
plugin_id TEXT NOT NULL, factor_id TEXT NOT NULL,
enrolled_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
h.db = drizzle(sqlite);
expect(await runTotpMigration()).toEqual({ factors: 0, secrets: 0 });
});
});
@@ -1,185 +0,0 @@
/**
* The tunnel host-columns migration.
*
* An upgrade must be lossless: a host with tunnels on and a saved tunnel list
* must keep both once the ssh_data columns are dropped. Running it twice
* must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_tunnel: boolean;
tunnel_connections: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter(
(row) => row.enable_tunnel || row.tunnel_connections !== null,
),
}),
}));
const { runTunnelsSettingsMigration } =
await import("../../utils/crypto-migration/tunnels-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const tunnel = {
scope: "s2s",
mode: "local",
sourcePort: 8080,
endpointHost: "db",
endpointPort: 5432,
maxRetries: 3,
retryInterval: 10,
autoStart: true,
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runTunnelsSettingsMigration", () => {
it("moves a host's switch and saved tunnels into plugin settings", async () => {
hostRows.push({
id: 1,
enable_tunnel: true,
tunnel_connections: JSON.stringify([tunnel]),
});
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableTunnel")).toBe(true);
expect(storedValue(1, "tunnelConnections")).toEqual([tunnel]);
expect(settingsRows.every((row) => row.pluginId === "tunnels")).toBe(true);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved tunnels and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_tunnel: false,
tunnel_connections: JSON.stringify([tunnel]),
});
await runTunnelsSettingsMigration();
expect(storedValue(3, "enableTunnel")).toBe(false);
expect(storedValue(3, "tunnelConnections")).toEqual([tunnel]);
});
it("stores an unreadable tunnel list as empty rather than failing", async () => {
hostRows.push({ id: 4, enable_tunnel: true, tunnel_connections: "{nope" });
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "tunnelConnections")).toEqual([]);
});
it("does nothing on a fresh install", async () => {
const result = await runTunnelsSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_tunnel: true, tunnel_connections: null });
await runTunnelsSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runTunnelsSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_tunnel: true,
tunnel_connections: JSON.stringify([tunnel]),
});
await pluginSettingsRepository.set(
"tunnels",
"host",
"5",
"enableTunnel",
"false",
);
const result = await runTunnelsSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableTunnel")).toBe(false);
expect(storedValue(5, "tunnelConnections")).toBeUndefined();
});
});
@@ -1,152 +0,0 @@
/**
* The Vault host settings migration.
*
* Every host's ssh_data.vault_profile_id must come through into the vault
* plugin's profileId host setting, and an install that used Vault keeps the
* 2.8 redirect URI. Running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
legacyProfiles: 0,
adoptedProfiles: 0,
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "vault" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: unknown) => {
const text = JSON.stringify(query);
if (text.includes("vault_profile_id")) {
if (state.columnGone) throw new Error("no such column");
return state.hostRows.filter((row) => row.vault_profile_id != null);
}
const count = text.includes("p_vault_profiles")
? state.adoptedProfiles
: state.legacyProfiles;
return Array.from({ length: Math.min(count, 1) }, () => ({ "1": 1 }));
},
runStatement: async () => {},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runVaultSettingsMigration } from "../../utils/crypto-migration/vault-settings-migration.js";
const setting = (scopeId: string | null, key: string) =>
state.pluginRows.find((row) => row.scopeId === scopeId && row.key === key)
?.value;
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.legacyProfiles = 0;
state.adoptedProfiles = 0;
state.pluginInstalled = true;
state.columnGone = false;
});
describe("runVaultSettingsMigration", () => {
it("moves every host's profile, keeps the old redirect URI, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, vault_profile_id: 7 },
{ id: 2, vault_profile_id: null },
{ id: 3, vault_profile_id: "9" },
];
state.adoptedProfiles = 2;
const first = await runVaultSettingsMigration();
expect(first).toEqual({
hostsMoved: 2,
hostsSkipped: 0,
legacyCallback: true,
});
expect(setting("1", "profileId")).toBe("7");
expect(setting("2", "profileId")).toBeUndefined();
expect(setting("3", "profileId")).toBe("9");
expect(setting(null, "legacyCallback")).toBe("true");
const rows = state.pluginRows.length;
const second = await runVaultSettingsMigration();
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
legacyCallback: false,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("keeps a host's value already set in the plugin", async () => {
state.pluginRows.push({
scope: "host",
scopeId: "1",
key: "profileId",
value: "4",
});
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(setting("1", "profileId")).toBe("4");
});
it("keeps the old redirect URI for profiles the plugin has not adopted yet", async () => {
state.legacyProfiles = 1;
const result = await runVaultSettingsMigration();
expect(result.legacyCallback).toBe(true);
});
it("uses the new redirect URI on a fresh install, and profiles made later do not change that", async () => {
state.columnGone = true;
const first = await runVaultSettingsMigration();
expect(first.legacyCallback).toBe(false);
expect(setting(null, "legacyCallback")).toBe("false");
state.adoptedProfiles = 3;
await runVaultSettingsMigration();
expect(setting(null, "legacyCallback")).toBe("false");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, vault_profile_id: 7 }];
await runVaultSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -1,190 +0,0 @@
/**
* The Wake-on-LAN host settings migration.
*
* An upgrade must be lossless: every host with a MAC address or a broadcast
* address must come through into the wake-on-lan plugin's host settings.
* Running it twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
pluginInstalled: true,
columnsGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "wake-on-lan" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
state.pluginRows.filter(
(row) => row.scope === scope && row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
);
if (existing) {
existing.value = value;
return;
}
state.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async () => {
if (state.columnsGone) throw new Error("no such column: mac_address");
return state.hostRows;
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
wakeOnLanSettingsFromRow,
runWakeOnLanSettingsMigration,
} from "../../utils/crypto-migration/wake-on-lan-settings-migration.js";
const hostValues = (hostId: number) =>
Object.fromEntries(
state.pluginRows
.filter((row) => row.scope === "host" && row.scopeId === String(hostId))
.map((row) => [row.key, JSON.parse(row.value ?? "null")]),
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.pluginInstalled = true;
state.columnsGone = false;
});
describe("wakeOnLanSettingsFromRow", () => {
it("carries over a MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: null,
}),
).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
});
it("carries over a broadcast address alongside the MAC address", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: "aa:bb:cc:dd:ee:ff",
wol_broadcast_address: "192.168.1.255",
}),
).toEqual({
macAddress: "aa:bb:cc:dd:ee:ff",
broadcastAddress: "192.168.1.255",
});
});
it("produces nothing for a host with neither set", () => {
expect(
wakeOnLanSettingsFromRow({
id: 1,
mac_address: null,
wol_broadcast_address: null,
}),
).toEqual({});
});
});
describe("runWakeOnLanSettingsMigration", () => {
it("moves every host's wake-on-lan settings and skips hosts with nothing set", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
{
id: 2,
mac_address: "11:22:33:44:55:66",
wol_broadcast_address: "192.168.1.255",
},
{ id: 3, mac_address: null, wol_broadcast_address: null },
];
const result = await runWakeOnLanSettingsMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0 });
expect(hostValues(1)).toEqual({ macAddress: "aa:bb:cc:dd:ee:ff" });
expect(hostValues(2)).toEqual({
macAddress: "11:22:33:44:55:66",
broadcastAddress: "192.168.1.255",
});
expect(hostValues(3)).toEqual({});
});
it("changes nothing when run twice", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
const before = JSON.stringify(state.pluginRows);
const second = await runWakeOnLanSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1 });
expect(JSON.stringify(state.pluginRows)).toBe(before);
});
it("keeps a value already saved in the plugin", async () => {
state.pluginRows.push({
pluginId: "wake-on-lan",
scope: "host",
scopeId: "1",
key: "macAddress",
value: JSON.stringify("11:11:11:11:11:11"),
});
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
await runWakeOnLanSettingsMigration();
expect(hostValues(1)).toEqual({ macAddress: "11:11:11:11:11:11" });
});
it("does nothing before the plugin row exists or once the columns are gone", async () => {
state.hostRows = [
{ id: 1, mac_address: "aa:bb:cc:dd:ee:ff", wol_broadcast_address: null },
];
state.pluginInstalled = false;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
state.pluginInstalled = true;
state.columnsGone = true;
expect(await runWakeOnLanSettingsMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
});
expect(state.pluginRows).toEqual([]);
});
});
@@ -1,161 +0,0 @@
/**
* The Warpgate host settings migration.
*
* Every host with use_warpgate on, whatever shape the engine returned it in,
* and every pre-2.7 host still on auth type "warpgate", must come through
* into the warpgate plugin's useWarpgate host setting. Running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
pluginRows: [] as Array<{
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}>,
hostRows: [] as Array<Record<string, unknown>>,
statements: [] as string[],
pluginInstalled: true,
columnGone: false,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "warpgate" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
state.pluginRows.find(
(row) =>
row.scope === scope && row.scopeId === scopeId && row.key === key,
) ?? null,
set: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
state.pluginRows.push({ scope, scopeId, key, value });
},
}),
}));
vi.mock("../../utils/crypto-migration/raw-rows.js", () => ({
selectRows: async (query: { queryChunks?: unknown[] }) => {
const text = JSON.stringify(query);
if (text.includes("use_warpgate") && state.columnGone) {
throw new Error("no such column: use_warpgate");
}
return state.hostRows.filter(
(row) => !text.includes("WHERE") || row.auth_type === "warpgate",
);
},
runStatement: async (query: unknown) => {
const text = JSON.stringify(query);
state.statements.push(text);
const id = (query as { queryChunks: unknown[] }).queryChunks.find(
(chunk) => typeof chunk === "number",
);
const row = state.hostRows.find((candidate) => candidate.id === id);
if (row) row.auth_type = "none";
},
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
runWarpgateSettingsMigration,
usesWarpgate,
} from "../../utils/crypto-migration/warpgate-settings-migration.js";
const enabled = (hostId: number) =>
state.pluginRows.some(
(row) =>
row.scopeId === String(hostId) &&
row.key === "useWarpgate" &&
row.value === "true",
);
beforeEach(() => {
state.pluginRows = [];
state.hostRows = [];
state.statements = [];
state.pluginInstalled = true;
state.columnGone = false;
});
describe("usesWarpgate", () => {
it("reads the flag in every shape the engines return", () => {
for (const flag of [true, 1, "1", "true"]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(true);
}
for (const flag of [false, 0, "0", null]) {
expect(
usesWarpgate({ id: 1, use_warpgate: flag, auth_type: "none" }),
).toBe(false);
}
expect(usesWarpgate({ id: 1, auth_type: "warpgate" })).toBe(true);
});
});
describe("runWarpgateSettingsMigration", () => {
it("moves every flagged host and fixes the old auth type, and a second run changes nothing", async () => {
state.hostRows = [
{ id: 1, use_warpgate: 1, auth_type: "password" },
{ id: 2, use_warpgate: 0, auth_type: "key" },
{ id: 3, use_warpgate: true, auth_type: "none" },
{ id: 4, use_warpgate: 0, auth_type: "warpgate" },
];
const first = await runWarpgateSettingsMigration();
expect(first).toEqual({
hostsMoved: 3,
hostsSkipped: 0,
authTypesFixed: 1,
});
expect(enabled(1)).toBe(true);
expect(enabled(2)).toBe(false);
expect(enabled(3)).toBe(true);
expect(enabled(4)).toBe(true);
expect(state.hostRows[3].auth_type).toBe("none");
const rows = state.pluginRows.length;
const second = await runWarpgateSettingsMigration();
// Host 4 is on auth type "none" now, so only the flagged two are seen.
expect(second).toEqual({
hostsMoved: 0,
hostsSkipped: 2,
authTypesFixed: 0,
});
expect(state.pluginRows).toHaveLength(rows);
});
it("still fixes old auth types on an install without the column", async () => {
state.columnGone = true;
state.hostRows = [{ id: 5, auth_type: "warpgate" }];
await runWarpgateSettingsMigration();
expect(enabled(5)).toBe(true);
expect(state.hostRows[0].auth_type).toBe("none");
});
it("waits for the plugin row", async () => {
state.pluginInstalled = false;
state.hostRows = [{ id: 1, use_warpgate: 1, auth_type: "password" }];
await runWarpgateSettingsMigration();
expect(state.pluginRows).toHaveLength(0);
});
});
@@ -1,184 +0,0 @@
/**
* The web endpoint host-columns migration.
*
* An upgrade must be lossless: a host with web endpoints on and a saved
* config must keep both once the ssh_data columns are dropped. Running it
* twice must not duplicate or clobber what it moved.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface HostRow {
id: number;
enable_web_ui: boolean;
web_ui_config: string | null;
}
interface SettingsRow {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const hostRows: HostRow[] = [];
const settingsRows: SettingsRow[] = [];
const find = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) =>
settingsRows.find(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
const pluginSettingsRepository = {
get: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(pluginId, scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, scope, scopeId, key);
if (existing) {
existing.value = value;
return;
}
settingsRows.push({ pluginId, scope, scopeId, key, value });
},
};
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginSettingsRepository: () => pluginSettingsRepository,
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async () =>
hostRows.filter((row) => row.enable_web_ui || row.web_ui_config !== null),
}),
}));
const { runWebEndpointSettingsMigration } =
await import("../../utils/crypto-migration/web-endpoint-settings-migration.js");
function storedValue(hostId: number, key: string): unknown {
const row = settingsRows.find(
(entry) => entry.scopeId === String(hostId) && entry.key === key,
);
return row?.value === null || row?.value === undefined
? undefined
: JSON.parse(row.value);
}
const endpoint = {
id: "e1",
label: "Proxmox",
scheme: "https",
port: 8006,
path: "/",
access: "direct",
render: "embedded",
};
beforeEach(() => {
hostRows.length = 0;
settingsRows.length = 0;
});
describe("runWebEndpointSettingsMigration", () => {
it("moves a host's switch and saved config into plugin settings", async () => {
hostRows.push({
id: 1,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(1, "enableWebUi")).toBe(true);
expect(storedValue(1, "webUiConfig")).toEqual({ endpoints: [endpoint] });
expect(settingsRows.every((row) => row.pluginId === "web-endpoint")).toBe(
true,
);
expect(settingsRows.every((row) => row.scope === "host")).toBe(true);
});
it("keeps a disabled host's saved config and its disabled switch", async () => {
hostRows.push({
id: 3,
enable_web_ui: false,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await runWebEndpointSettingsMigration();
expect(storedValue(3, "enableWebUi")).toBe(false);
expect(storedValue(3, "webUiConfig")).toEqual({ endpoints: [endpoint] });
});
it("stores an unreadable config as null rather than failing", async () => {
hostRows.push({ id: 4, enable_web_ui: true, web_ui_config: "{nope" });
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(1);
expect(storedValue(4, "webUiConfig")).toBeNull();
});
it("does nothing on a fresh install", async () => {
const result = await runWebEndpointSettingsMigration();
expect(result.moved).toBe(0);
expect(settingsRows).toEqual([]);
});
it("is idempotent: a second run changes nothing", async () => {
hostRows.push({ id: 2, enable_web_ui: true, web_ui_config: null });
await runWebEndpointSettingsMigration();
const afterFirst = JSON.parse(JSON.stringify(settingsRows));
const second = await runWebEndpointSettingsMigration();
expect(second.moved).toBe(0);
expect(second.skipped).toBe(1);
expect(settingsRows).toEqual(afterFirst);
});
it("does not overwrite a value the plugin already saved", async () => {
hostRows.push({
id: 5,
enable_web_ui: true,
web_ui_config: JSON.stringify({ endpoints: [endpoint] }),
});
await pluginSettingsRepository.set(
"web-endpoint",
"host",
"5",
"enableWebUi",
"false",
);
const result = await runWebEndpointSettingsMigration();
expect(result.skipped).toBe(1);
expect(storedValue(5, "enableWebUi")).toBe(false);
expect(storedValue(5, "webUiConfig")).toBeUndefined();
});
});