feat: drop the 2.8 columns, moves and aliases kept for a downgrade

This commit is contained in:
LukeGus committed 2026-10-05 10:21:11 -05:00
1 parent d3d8269cb7
commit 61f46d2517
196 files changed
+29909 -10511

No files matched your search

+17 -10
View File
@@ -5,9 +5,6 @@ export interface FakeUser {
username: string;
passwordHash: string;
isAdmin: boolean;
isOidc: boolean;
oidcIdentifier?: string | null;
ssoProviderId?: number | null;
}
/** In-memory stand-ins for the repositories the login pipeline touches. */
@@ -37,18 +34,28 @@ export function createAuthState() {
export type AuthState = ReturnType<typeof createAuthState>;
/** The user as UserRepository returns it, with isExternal filled in. */
function withIdentities(state: AuthState, user: FakeUser | undefined) {
if (!user) return null;
return {
...user,
isExternal: state.identities.some((row) => row.userId === user.id),
};
}
export function fakeFactory(state: AuthState) {
return {
createCurrentUserRepository: () => ({
findById: async (id: string) => state.users.get(id) ?? null,
findById: async (id: string) =>
withIdentities(state, state.users.get(id)),
findByUsername: async (username: string) =>
[...state.users.values()].find((u) => u.username === username) ?? null,
findByExternalIdentifier: async (identifier: string) =>
[...state.users.values()].find(
(u) => u.oidcIdentifier === identifier,
) ?? null,
withIdentities(
state,
[...state.users.values()].find((u) => u.username === username),
),
countAll: async () => state.users.size,
listAll: async () => [...state.users.values()],
listAll: async () =>
[...state.users.values()].map((u) => withIdentities(state, u)),
createFirstSsoUser: async (user: FakeUser) => {
const isFirstUser = state.users.size === 0;
const created = {
+9 -85
View File
@@ -119,7 +119,6 @@ function addUser(
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
...overrides,
};
h.state.users.set(user.id, user);
@@ -461,7 +460,7 @@ describe("external identities", () => {
email: "a@example.com",
name: "Alice",
legacyIdentifier: "sub-1",
ssoProviderId: 3,
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
};
// The sso plugin's method, which marks itself external.
@@ -487,7 +486,6 @@ describe("external identities", () => {
expect(user).toMatchObject({
username: "Alice",
isAdmin: true,
isOidc: true,
});
expect(h.state.identities).toEqual([
expect.objectContaining({
@@ -511,7 +509,7 @@ describe("external identities", () => {
}),
).rejects.toMatchObject({ code: "registration_disabled" });
h.state.settings.set("oidc_auto_provision", "true");
h.state.settings.set("external_auto_provision", "true");
await expect(
runLogin(
fakeRequest() as never,
@@ -521,45 +519,11 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it.each(["true", "false"])(
"does not duplicate an unscoped migrated account with provisioning %s",
async (enabled) => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
const { runExternalIdentityMigration } =
await import("../../upgrade/external-identity-migration.js");
await runExternalIdentityMigration();
h.state.settings.set("oidc_auto_provision", enabled);
await expect(
runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
}),
).rejects.toMatchObject({ code: "legacy_identity_conflict" });
expect([...h.state.users.keys()]).toEqual(["old"]);
expect(h.state.identities).toHaveLength(1);
expect(h.state.identities[0]).toMatchObject({
userId: "old",
providerId: "legacy-oidc",
subject: "sub-1",
});
},
);
it("keeps equal subjects on explicitly different providers separate", async () => {
addUser({
id: "other",
username: "Other Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: 9,
});
h.state.identities.push({
id: 1,
@@ -568,7 +532,7 @@ describe("external identities", () => {
subject: "sub-1",
email: null,
});
h.state.settings.set("oidc_auto_provision", "true");
h.state.settings.set("external_auto_provision", "true");
const result = await runLogin(fakeRequest() as never, external, {
methodId: "oidc",
rememberMe: false,
@@ -584,10 +548,7 @@ describe("external identities", () => {
addUser({
id: "old",
username: "Alice",
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-1",
ssoProviderId: null,
});
h.state.identities.push({
id: 1,
@@ -605,7 +566,7 @@ describe("external identities", () => {
});
it("re-checks the allowed list for existing users", async () => {
addUser({ id: "u9", username: "Alice", isOidc: true, passwordHash: "" });
addUser({ id: "u9", username: "Alice", passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "u9",
@@ -622,42 +583,10 @@ describe("external identities", () => {
).rejects.toMatchObject({ code: "user_not_allowed" });
});
it("finds an account from before the identity table by its old identifier", async () => {
addUser({
id: "old",
username: "ldap-bob",
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:bob",
});
const result = await runLogin(
fakeRequest() as never,
{
kind: "external",
provider: "4",
subject: "bob",
name: "Bob",
legacyIdentifier: "ldap:4:bob",
ssoProviderId: 4,
},
{ methodId: "ldap", rememberMe: false },
);
expect(result.kind).toBe("session");
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toEqual([
expect.objectContaining({
userId: "old",
providerId: "4",
subject: "bob",
}),
]);
});
it("keeps admin in step with the provider's admin group", async () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
isAdmin: true,
});
@@ -680,7 +609,6 @@ describe("external identities", () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
@@ -703,7 +631,6 @@ describe("external identities", () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
@@ -729,9 +656,7 @@ describe("redirect logins", () => {
subject: "sub-1",
name: "Alice",
returnTo: "https://termix.example",
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
};
it("sets the cookie and redirects with success, carrying the SSO claims", async () => {
@@ -748,9 +673,7 @@ describe("redirect logins", () => {
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
oidcSub: "sub-1",
oidcSid: "sid-1",
ssoProviderId: 3,
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
@@ -774,7 +697,6 @@ describe("redirect logins", () => {
addUser({
id: "u9",
username: "Alice",
isOidc: true,
passwordHash: "",
});
h.state.factors.push({ userId: "u9", pluginId: "totp", factorId: "totp" });
@@ -812,7 +734,9 @@ describe("redirect logins", () => {
expect(verify.statusCode).toBe(200);
expect(h.manager.generateJWTToken).toHaveBeenLastCalledWith(
"u9",
expect.objectContaining({ oidcSid: "sid-1", ssoProviderId: 3 }),
expect.objectContaining({
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
@@ -113,8 +113,8 @@ describe("generated schemas", () => {
expect(auditFks).toHaveLength(1);
expect(auditFks[0].onDelete).toBe("set null");
const folderFks = read(schema.sshFolders).foreignKeys;
expect(folderFks.map((fk) => fk.onDelete).sort()).toEqual([
const hostFks = read(schema.hosts).foreignKeys;
expect([...new Set(hostFks.map((fk) => fk.onDelete))].sort()).toEqual([
"cascade",
"set null",
]);
@@ -0,0 +1,199 @@
import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* A database 2.9 left behind still has the 2.8 columns it kept for a
* downgrade. Opening it in 26.10.0 drops them, keeps what still matters
* (a sudo password kept in terminal_config, an SSO sign-in, the hosts and
* folders themselves) and removes the settings rows the plugins copied.
*/
describe("dropping the columns 2.9.0 kept", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-retire-"));
vi.resetModules();
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_FILE_ENCRYPTION;
delete process.env.ALLOW_EMPTY_DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
function write29Database(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'available',
source TEXT NOT NULL DEFAULT 'community',
registry_id TEXT,
state TEXT NOT NULL DEFAULT 'disabled',
last_error TEXT,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_oidc INTEGER NOT NULL DEFAULT 0,
oidc_identifier TEXT,
client_secret TEXT
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE ssh_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
auth_type TEXT NOT NULL,
username TEXT
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
sudo_password TEXT,
terminal_config TEXT,
quick_actions TEXT,
tunnel_connections TEXT,
enable_docker INTEGER NOT NULL DEFAULT 0,
stats_config TEXT,
rdp_user TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE ssh_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
credential_id INTEGER REFERENCES ssh_credentials(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT,
terminal_defaults TEXT,
rdp_defaults TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash, is_oidc, oidc_identifier)
VALUES ('u1', 'alice', 'hash', 1, 'sub-1');
INSERT INTO ssh_credentials (id, user_id, name, auth_type)
VALUES (5, 'u1', 'cred', 'password');
INSERT INTO ssh_data (id, user_id, name, ip, port, username,
terminal_config, tunnel_connections, enable_docker, rdp_user)
VALUES
(1, 'u1', 'web', '10.0.0.1', 22, '$oidc.preferred_username',
'{"sudoPassword":"s3cret","theme":"nord"}', '[]', 1, 'admin'),
(2, 'u1', 'db', '10.0.0.2', 22, 'root', NULL, NULL, 0, NULL);
INSERT INTO ssh_folders (id, user_id, name, color, credential_id)
VALUES (3, 'u1', 'prod', '#fff', 5);
INSERT INTO user_preferences (user_id, theme, terminal_defaults, rdp_defaults)
VALUES ('u1', 'dark', '{}', '{}');
INSERT INTO settings (key, value) VALUES
('guac_url', 'guacd:4822'),
('analytics_enabled', 'true'),
('allow_registration', 'false');
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
const columns = (sqlite: Database.Database, table: string) =>
(sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{
name: string;
}>).map((col) => col.name);
it("drops the columns and rows and keeps the data that still matters", async () => {
write29Database();
const db = await import("../../../database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
expect(columns(sqlite, "users")).not.toContain("oidc_identifier");
expect(columns(sqlite, "users")).not.toContain("is_oidc");
expect(columns(sqlite, "ssh_data")).not.toEqual(
expect.arrayContaining(["terminal_config"]),
);
for (const gone of [
"terminal_config",
"quick_actions",
"tunnel_connections",
"enable_docker",
"stats_config",
]) {
expect(columns(sqlite, "ssh_data")).not.toContain(gone);
}
// A login moved only once its owner signs in stays until then.
expect(columns(sqlite, "ssh_data")).toContain("rdp_user");
expect(columns(sqlite, "ssh_folders")).not.toContain("credential_id");
expect(columns(sqlite, "user_preferences")).not.toContain("rdp_defaults");
expect(columns(sqlite, "user_preferences")).not.toContain(
"terminal_defaults",
);
const hosts = sqlite
.prepare("SELECT id, username, sudo_password FROM ssh_data ORDER BY id")
.all();
expect(hosts).toEqual([
{ id: 1, username: "$external.username", sudo_password: "s3cret" },
{ id: 2, username: "root", sudo_password: null },
]);
expect(
sqlite.prepare("SELECT name, color FROM ssh_folders").all(),
).toEqual([{ name: "prod", color: "#fff" }]);
expect(
sqlite.prepare("SELECT theme FROM user_preferences").get(),
).toEqual({ theme: "dark" });
const keys = (
sqlite.prepare("SELECT key FROM settings").all() as Array<{
key: string;
}>
).map((row) => row.key);
expect(keys).toContain("allow_registration");
expect(keys).not.toContain("guac_url");
expect(keys).not.toContain("analytics_enabled");
});
it("refuses a 2.8 database", async () => {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL,
password_hash TEXT NOT NULL);
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, user_id TEXT NOT NULL);
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
const db = await import("../../../database/db/index.js");
await expect(db.initializeDatabase()).rejects.toThrow(/2\.8 or older/);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
legacySudoMoves,
legacySudoPassword,
} from "../../../database/db/retired-columns.js";
describe("legacySudoPassword", () => {
it("reads the sudo password out of a terminal_config value", () => {
expect(legacySudoPassword('{"sudoPassword":"s3cret"}')).toBe("s3cret");
expect(legacySudoPassword({ sudoPassword: "s3cret" })).toBe("s3cret");
expect(legacySudoPassword(JSON.stringify('{"sudoPassword":"x"}'))).toBe(
"x",
);
});
it("finds nothing in anything else", () => {
expect(legacySudoPassword(null)).toBeNull();
expect(legacySudoPassword("{")).toBeNull();
expect(legacySudoPassword('{"sudoPassword":""}')).toBeNull();
expect(legacySudoPassword("[1]")).toBeNull();
});
});
describe("legacySudoMoves", () => {
it("moves only hosts with no sudo password of their own", () => {
expect(
legacySudoMoves([
{ id: 1, terminal_config: '{"sudoPassword":"a"}', sudo_password: null },
{ id: 2, terminal_config: '{"sudoPassword":"b"}', sudo_password: "own" },
{ id: 3, terminal_config: '{"theme":"nord"}', sudo_password: null },
]),
).toEqual([{ id: 1, sudoPassword: "a" }]);
});
});
@@ -33,10 +33,28 @@ describe("share_ssh_auth backfill", () => {
fs.rmSync(dataDir, { recursive: true, force: true });
});
/** A 2.6.0 database: hosts and shares exist, the column does not. */
/**
* Hosts and shares exist, the column does not. The plugins table is there
* because 26.10.0 only opens a database 2.9 has already upgraded.
*/
function writePreUpgradeDatabase(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
tier TEXT NOT NULL DEFAULT 'available',
source TEXT NOT NULL DEFAULT 'community',
registry_id TEXT,
state TEXT NOT NULL DEFAULT 'disabled',
last_error TEXT,
installed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
auto_update INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL
);
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import {
assertNotPre29Database,
PRE_29_DATABASE_ERROR,
} from "../../../database/db/upgrade-guard.js";
const tables = (...names: string[]) => (name: string) => names.includes(name);
describe("assertNotPre29Database", () => {
it("refuses a 2.8 database, which has hosts but no plugins table", async () => {
await expect(assertNotPre29Database(tables("ssh_data"))).rejects.toThrow(
PRE_29_DATABASE_ERROR,
);
});
it("opens a database 2.9 has upgraded", async () => {
await expect(
assertNotPre29Database(tables("ssh_data", "plugins")),
).resolves.toBeUndefined();
});
it("opens a new database", async () => {
await expect(assertNotPre29Database(tables())).resolves.toBeUndefined();
});
});
@@ -89,7 +89,6 @@ describe("HostFolderRepository", () => {
"prod",
"#abcdef",
"folder",
undefined,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -102,7 +101,6 @@ describe("HostFolderRepository", () => {
"new",
null,
null,
null,
"2026-03-01T00:00:00.000Z",
),
).resolves.toMatchObject({
@@ -112,28 +110,6 @@ describe("HostFolderRepository", () => {
expect(writes).toBe(2);
});
it("assigns a credential to a folder and resolves it for nested paths", async () => {
const { repository } = await createRepository();
await expect(
repository.upsertMetadata(
"user-1",
"prod",
undefined,
undefined,
1,
"2026-02-01T00:00:00.000Z",
),
).resolves.toMatchObject({
created: false,
folder: { credentialId: 1 },
});
const folders = await repository.listFolders("user-1");
const prodFolder = folders.find((f) => f.name === "prod");
expect(prodFolder?.credentialId).toBe(1);
});
it("lists and deletes hosts and folder records in a folder tree", async () => {
let writes = 0;
const { repository } = await createRepository(() => {
@@ -44,11 +44,6 @@ describe("HostResolutionRepository", () => {
(1, 'user-1', 'web', '10.0.0.1', 22, 'root', 'password', 7, '[{"autoStart":true}]'),
(2, 'user-1', 'db', '10.0.0.2', 22, 'admin', 'none', NULL, NULL),
(3, 'user-2', 'other', '10.0.0.3', 22, 'root', 'none', NULL, '[{"autoStart":false}]');
INSERT INTO ssh_folders (user_id, name, credential_id)
VALUES
('user-1', 'switches', 7),
('user-1', 'switches / floor1', NULL),
('user-1', 'no-cred', NULL);
INSERT INTO host_access (
host_id, user_id, granted_by, permission_level
)
@@ -330,26 +325,6 @@ describe("HostResolutionRepository", () => {
).resolves.toBeNull();
});
it("resolves a folder's assigned credential, walking up to parent folders", async () => {
const repository = await createRepository();
await expect(
repository.findFolderCredentialId("user-1", "switches"),
).resolves.toBe(7);
await expect(
repository.findFolderCredentialId("user-1", "switches / floor1"),
).resolves.toBe(7);
await expect(
repository.findFolderCredentialId("user-1", "no-cred"),
).resolves.toBeNull();
await expect(
repository.findFolderCredentialId("user-1", "unknown"),
).resolves.toBeNull();
await expect(
repository.findFolderCredentialId("user-1", ""),
).resolves.toBeNull();
});
describe("listCredentialsByIdsForUser", () => {
it("returns the owner's credentials keyed by id", async () => {
const repository = await createRepository();
@@ -19,11 +19,11 @@ describe("RbacAccessRepository", () => {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
INSERT INTO users (id, username, password_hash, is_admin)
VALUES
('admin', 'admin', 'hash', 1, 0),
('user-1', 'alice', 'hash', 0, 0),
('owner-1', 'owner', 'hash', 0, 0);
('admin', 'admin', 'hash', 1),
('user-1', 'alice', 'hash', 0),
('owner-1', 'owner', 'hash', 0);
INSERT INTO roles (id, name, display_name, is_system)
VALUES (7, 'ops', 'Operations', 0);
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
@@ -18,8 +18,8 @@ describe("RoleRepository", () => {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash, is_admin, is_oidc)
VALUES ('admin', 'admin', 'hash', 1, 0), ('user-1', 'user', 'hash', 0, 0);
INSERT INTO users (id, username, password_hash, is_admin)
VALUES ('admin', 'admin', 'hash', 1), ('user-1', 'user', 'hash', 0);
`);
return new RoleRepository(context, onWrite);
@@ -34,19 +34,19 @@ describe("UserPreferenceRepository", () => {
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
commandPaletteEnabled: true,
});
expect(created).toMatchObject({
userId: "user-1",
reopenTabsOnLogin: true,
theme: "dark",
storageMode: "local",
commandAutocomplete: true,
commandPaletteEnabled: true,
});
const updated = await repo.upsert("user-1", {
theme: "light",
commandAutocomplete: false,
commandPaletteEnabled: false,
updatedAt: "2026-06-27T00:00:00.000Z",
});
expect(updated).toMatchObject({
@@ -54,7 +54,7 @@ describe("UserPreferenceRepository", () => {
reopenTabsOnLogin: true,
theme: "light",
storageMode: "local",
commandAutocomplete: false,
commandPaletteEnabled: false,
});
});
@@ -56,7 +56,6 @@ describe("UserRepository and SessionRepository", () => {
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
expect(await repo.users.countAdmins()).toBe(1);
@@ -69,13 +68,15 @@ describe("UserRepository and SessionRepository", () => {
).toEqual(["user-1"]);
expect(await repo.users.listByIds([])).toEqual([]);
const updated = await repo.users.update("user-1", {
oidcIdentifier: "oidc:admin",
});
expect(updated?.oidcIdentifier).toBe("oidc:admin");
expect((await repo.users.findByExternalIdentifier("oidc:admin"))?.id).toBe(
"user-1",
);
const updated = await repo.users.update("user-1", { isAdmin: false });
expect(updated).toMatchObject({ isAdmin: false, isExternal: false });
await adapter!.exec(`
INSERT INTO user_external_identities (user_id, provider_id, subject)
VALUES ('user-1', '3', 'sub-1');
`);
expect((await repo.users.findById("user-1"))?.isExternal).toBe(true);
expect((await repo.users.listAll())[0].isExternal).toBe(true);
expect(await repo.users.delete("user-1")).toBe(true);
expect(await repo.users.findById("user-1")).toBeNull();
@@ -88,13 +89,11 @@ describe("UserRepository and SessionRepository", () => {
id: "user-1",
username: "first",
passwordHash: "hash",
isOidc: false,
});
const second = await repo.users.createFirstLocalUser({
id: "user-2",
username: "second",
passwordHash: "hash",
isOidc: false,
});
expect(first.isFirstUser).toBe(true);
@@ -112,27 +111,18 @@ describe("UserRepository and SessionRepository", () => {
username: "first",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:first",
ssoProviderId: 1,
});
const providerAdmin = await repo.users.createFirstSsoUser({
id: "user-2",
username: "provider-admin",
passwordHash: "",
isAdmin: true,
isOidc: true,
oidcIdentifier: "ldap:provider:admin",
ssoProviderId: 1,
});
const regular = await repo.users.createFirstSsoUser({
id: "user-3",
username: "regular",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:provider:regular",
ssoProviderId: 1,
});
expect(first.isFirstUser).toBe(true);
@@ -157,7 +147,6 @@ describe("UserRepository and SessionRepository", () => {
username: "admin",
passwordHash: "hash",
isAdmin: true,
isOidc: false,
});
await repo.users.update("user-1", { isAdmin: false });
await repo.users.delete("user-1");
@@ -172,7 +161,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
@@ -208,7 +196,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
@@ -250,7 +237,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
id: "session-1",
@@ -281,7 +267,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
for (const id of ["keep", "drop-1", "drop-2"]) {
@@ -308,7 +293,6 @@ describe("UserRepository and SessionRepository", () => {
username: "user",
passwordHash: "hash",
isAdmin: false,
isOidc: false,
});
await repo.sessions.create({
@@ -96,9 +96,8 @@ it.each([false, true])(
overwrite ? 19 : 71,
expect.objectContaining({ pluginSettings: host.pluginSettings }),
);
expect(JSON.parse(saved.terminalConfig)).toEqual({
macOptionIsMeta: false,
});
// The 2.8 terminalConfig reaches the plugin, never a host column.
expect(saved).not.toHaveProperty("terminalConfig");
},
);
@@ -284,7 +284,7 @@ describe("stripSensitiveFields", () => {
expect(result.hasKey).toBe(false);
});
it("detects sudo password stored only in nested terminalConfig", () => {
it("strips a sudo password nested in terminalConfig", () => {
const result = stripSensitiveFields({
name: "web",
terminalConfig: {
@@ -292,7 +292,7 @@ describe("stripSensitiveFields", () => {
sudoPassword: "nested-only-sudo",
},
});
expect(result.hasSudoPassword).toBe(true);
expect(result.hasSudoPassword).toBe(false);
expect(
(result.terminalConfig as Record<string, unknown>).sudoPassword,
).toBeUndefined();
@@ -483,25 +483,6 @@ describe("transformHostResponse terminal fields", () => {
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
});
it("reads the options out of terminal_config before the boot copy", () => {
const host = row({
sshOptions: null,
terminalConfig: JSON.stringify({ agentForwarding: true }),
});
expect(host.sshOptions).toEqual({ agentForwarding: true });
});
it("surfaces a 2.8 sudo password for the sanitizers to strip", () => {
const host = row({
sudoPassword: null,
terminalConfig: JSON.stringify({ sudoPassword: "legacy" }),
});
const stripped = stripSensitiveFields(host);
expect(stripped.hasSudoPassword).toBe(true);
expect(stripped).not.toHaveProperty("sudoPassword");
expect(JSON.stringify(stripped)).not.toContain("legacy");
});
it("hides the owner's agent socket from a shared recipient", () => {
const shared = sanitizeHostForRecipient(
row({
@@ -10,17 +10,11 @@ vi.mock("../../../database/repositories/factory.js", () => ({
}),
}));
const { mergeStoredTerminalFields, parseTerminalConfig } =
const { mergeStoredSshOptions, parseTerminalConfig } =
await import("../../../database/routes/host-terminal-fields.js");
beforeEach(() => {
state.stored = {
terminalConfig: JSON.stringify({
theme: "nord",
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 1,
}),
sshOptions: JSON.stringify({ agentSocketPath: "/owner/agent" }),
};
});
@@ -31,10 +25,10 @@ async function merge(
sshOptions?: string,
) {
const row: Record<string, unknown> = {
terminalConfig: "replaced wholesale",
terminalConfig: "from the payload",
...(sshOptions !== undefined ? { sshOptions } : {}),
};
const error = await mergeStoredTerminalFields(row, hostData, 1, "o", isOwner);
const error = await mergeStoredSshOptions(row, hostData, 1, "o", isOwner);
return { error, row };
}
@@ -47,43 +41,32 @@ describe("parseTerminalConfig", () => {
});
});
describe("mergeStoredTerminalFields", () => {
it("merges an editor save into the stored 2.8 keys instead of dropping them", async () => {
describe("mergeStoredSshOptions", () => {
it("never writes terminalConfig, which has no column", async () => {
const { error, row } = await merge({
terminalConfig: { startupSnippetId: 4 },
});
expect(error).toBeNull();
expect(JSON.parse(row.terminalConfig as string)).toEqual({
theme: "nord",
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 4,
});
});
it("leaves terminal_config alone when the payload has none", async () => {
const { row } = await merge({});
expect(row).not.toHaveProperty("terminalConfig");
});
it("drops the legacy sudo copy once the owner saves one on its own", async () => {
const { row } = await merge({ sudoPassword: "" });
expect(JSON.parse(row.terminalConfig as string)).not.toHaveProperty(
"sudoPassword",
it("leaves an owner's SSH options as sent", async () => {
const { row } = await merge(
{},
true,
JSON.stringify({ agentSocketPath: "/x" }),
);
expect(JSON.parse(row.sshOptions as string)).toEqual({
agentSocketPath: "/x",
});
});
it("keeps the owner's private keys through a shared editor's save", async () => {
it("keeps the owner's private SSH options through a shared editor's save", async () => {
const { row } = await merge(
{ terminalConfig: { startupSnippetId: 2 } },
{},
false,
JSON.stringify({ keepaliveInterval: 9 }),
JSON.stringify({ keepaliveInterval: 9, agentSocketPath: "/editor" }),
);
expect(JSON.parse(row.terminalConfig as string)).toMatchObject({
sudoPassword: "legacy-sudo",
agentSocketPath: "/owner/agent",
startupSnippetId: 2,
});
expect(JSON.parse(row.sshOptions as string)).toEqual({
keepaliveInterval: 9,
agentSocketPath: "/owner/agent",
@@ -13,10 +13,7 @@ interface User {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
passwordHash: string;
oidcIdentifier?: string | null;
clientId?: string;
}
const h = vi.hoisted(() => ({
@@ -30,14 +27,21 @@ const h = vi.hoisted(() => ({
revoked: [] as string[],
}));
function withIdentities(user: User | undefined) {
if (!user) return null;
return {
...user,
isExternal: h.identities.some((row) => row.userId === user.id),
};
}
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => h.users.get(id) ?? null,
findById: async (id: string) => withIdentities(h.users.get(id)),
findByUsername: async (username: string) =>
[...h.users.values()].find((user) => user.username === username) ?? null,
update: async (id: string, changes: Partial<User>) => {
Object.assign(h.users.get(id)!, changes);
},
withIdentities(
[...h.users.values()].find((user) => user.username === username),
),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async (userId: string) =>
@@ -84,7 +88,6 @@ beforeEach(async () => {
id: "admin",
username: "admin",
isAdmin: true,
isOidc: false,
passwordHash: "x",
},
],
@@ -94,9 +97,7 @@ beforeEach(async () => {
id: "sso",
username: "alice-sso",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "ldap:4:alice",
},
],
[
@@ -105,7 +106,6 @@ beforeEach(async () => {
id: "local",
username: "alice",
isAdmin: false,
isOidc: false,
passwordHash: "hash",
},
],
@@ -149,16 +149,12 @@ function post(path: string, body: unknown, user = "admin") {
}
describe("linking an external account to a password account", () => {
it("moves the identities and the old identifier, then removes the external account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
it("moves the identities, then removes the external account", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: true,
oidcIdentifier: "ldap:4:alice",
});
expect(h.identities).toEqual([
{ userId: "local", providerId: "ldap:4", subject: "alice" },
]);
@@ -166,13 +162,13 @@ describe("linking an external account to a password account", () => {
expect(h.deleted).toEqual(["sso"]);
});
it("takes the new path and field name", async () => {
const response = await post("/link-external-to-password", {
externalUserId: "sso",
it("no longer takes the 2.8 path and field name", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
expect(response.status).toBe(200);
expect(h.identities[0].userId).toBe("local");
expect(response.status).toBe(404);
expect(h.identities[0].userId).toBe("sso");
});
it("refuses a target that already signs in externally", async () => {
@@ -186,8 +182,8 @@ describe("linking an external account to a password account", () => {
it("is for admins only", async () => {
const response = await post(
"/link-oidc-to-password",
{ oidcUserId: "sso", targetUsername: "alice" },
"/link-external-to-password",
{ externalUserId: "sso", targetUsername: "alice" },
"local",
);
expect(response.status).toBe(403);
@@ -195,8 +191,8 @@ describe("linking an external account to a password account", () => {
});
it("refuses a target that is not a password account", async () => {
const response = await post("/link-oidc-to-password", {
oidcUserId: "sso",
const response = await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice-sso",
});
expect(response.status).toBe(400);
@@ -207,17 +203,13 @@ describe("linking an external account to a password account", () => {
id: "orig",
username: "bob",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-9",
});
h.users.set("dup", {
id: "dup",
username: "bob-1",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: null,
});
h.identities.push(
{ userId: "orig", providerId: "legacy-oidc", subject: "sub-9" },
@@ -231,7 +223,6 @@ describe("linking an external account to a password account", () => {
expect(response.status).toBe(200);
expect(h.deleted).toEqual(["dup"]);
expect(h.users.get("orig")!.oidcIdentifier).toBe("sub-9");
expect(
h.identities
.filter((row) => row.userId === "orig")
@@ -244,9 +235,7 @@ describe("linking an external account to a password account", () => {
id: "other",
username: "carol",
isAdmin: false,
isOidc: true,
passwordHash: "",
oidcIdentifier: "sub-x",
});
h.identities.push({
userId: "other",
@@ -264,22 +253,6 @@ describe("linking an external account to a password account", () => {
describe("unlinking", () => {
it("drops the identities so the provider no longer signs in as the user", async () => {
await post("/link-oidc-to-password", {
oidcUserId: "sso",
targetUsername: "alice",
});
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(200);
expect(h.users.get("local")).toMatchObject({
isOidc: false,
oidcIdentifier: null,
});
expect(h.identities).toEqual([]);
});
it("takes the new path", async () => {
await post("/link-external-to-password", {
externalUserId: "sso",
targetUsername: "alice",
@@ -291,8 +264,15 @@ describe("unlinking", () => {
expect(h.identities).toEqual([]);
});
it("refuses to leave an account with no way in", async () => {
it("no longer takes the 2.8 path", async () => {
const response = await post("/unlink-oidc-from-password", {
userId: "local",
});
expect(response.status).toBe(404);
});
it("refuses to leave an account with no way in", async () => {
const response = await post("/unlink-external-from-password", {
userId: "sso",
});
expect(response.status).toBe(400);
@@ -52,6 +52,14 @@ describe("pickResolvedPassword", () => {
});
});
function identities(rows: Array<{ id: number; subject: string }>) {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => rows,
}),
}));
}
describe("expandExternalUsername", () => {
beforeEach(() => {
vi.resetModules();
@@ -62,29 +70,19 @@ describe("expandExternalUsername", () => {
expect(await expandExternalUsername(undefined, "user-1")).toBeUndefined();
});
it("expands the placeholder with the user's OIDC identifier", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({ oidcIdentifier: "jdoe" }),
}),
}));
it("expands the placeholder with the subject of the user's first identity", async () => {
identities([
{ id: 4, subject: "later" },
{ id: 2, subject: "jdoe" },
]);
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
expect(await expand("$external.username", "user-1")).toBe("jdoe");
expect(
await expand("$external.username-$oidc.preferred_username", "user-1"),
).toBe("jdoe-jdoe");
expect(await expand("$external.username-x", "user-1")).toBe("jdoe-x");
});
it("leaves the placeholder as-is when the user has no OIDC identifier", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({ oidcIdentifier: null }),
}),
}));
it("no longer reads the 2.8 spelling", async () => {
identities([{ id: 1, subject: "jdoe" }]);
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
@@ -92,81 +90,25 @@ describe("expandExternalUsername", () => {
);
});
it("leaves the placeholder as-is when the user has no external sign-in", async () => {
identities([]);
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$external.username", "user-1")).toBe(
"$external.username",
);
});
it("returns the username unchanged when the DB lookup throws", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => {
createCurrentUserAuthRepository: () => {
throw new Error("DB unavailable");
},
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"$oidc.preferred_username",
);
});
it("strips the ldap:{providerId}: prefix for genuine LDAP users", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({
oidcIdentifier: "ldap:1:jdoe",
ssoProviderId: 1,
}),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:1", subject: "jdoe" },
],
}),
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe("jdoe");
});
it("does not strip a spoofed ldap: identifier from a non-LDAP provider", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({
oidcIdentifier: "ldap:1:admin",
ssoProviderId: 1,
}),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "1", subject: "ldap:1:admin" },
],
}),
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"ldap:1:admin",
);
});
it("does not strip when the embedded provider id is not the user's provider", async () => {
vi.doMock("../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async () => ({
oidcIdentifier: "ldap:1:admin",
ssoProviderId: 5,
}),
}),
createCurrentUserAuthRepository: () => ({
listIdentitiesForUser: async () => [
{ providerId: "ldap:5", subject: "admin" },
],
}),
}));
const { expandExternalUsername: expand } =
await import("../../hosts/credential-username.js");
expect(await expand("$oidc.preferred_username", "user-1")).toBe(
"ldap:1:admin",
expect(await expand("$external.username", "user-1")).toBe(
"$external.username",
);
});
});
@@ -1,12 +1,20 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
vi.mock("../../../database/repositories/factory.js", () => ({}));
const rows = vi.hoisted(() => ({
owned: [] as Array<Record<string, unknown>>,
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentHostDefaultsRepository: () => ({
listOwnedBy: async () => rows.owned,
}),
}));
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => [],
}));
const { validateLevelChange } =
const { folderCredentialIds, validateLevelChange } =
await import("../../../hosts/defaults/service.js");
const { buildCatalog } = await import("../../../hosts/defaults/catalog.js");
@@ -113,3 +121,39 @@ describe("validateLevelChange", () => {
expect(change.errors["core.jumpHosts"]).toBeDefined();
});
});
describe("folderCredentialIds", () => {
it("reads each folder's credential from its auth default", async () => {
rows.owned = [
{
level: "folder",
folderId: 4,
namespace: "core",
key: "auth",
value: JSON.stringify({ authType: "credential", credentialId: 7 }),
},
{
level: "folder",
folderId: 5,
namespace: "core",
key: "auth",
value: JSON.stringify({ authType: "none" }),
},
{
level: "user",
folderId: null,
namespace: "core",
key: "auth",
value: JSON.stringify({ authType: "credential", credentialId: 9 }),
},
{
level: "folder",
folderId: 6,
namespace: "fx",
key: "auth",
value: JSON.stringify({ authType: "credential", credentialId: 2 }),
},
];
expect([...(await folderCredentialIds("u1"))]).toEqual([[4, 7]]);
});
});
+5 -69
View File
@@ -7,7 +7,6 @@ const state = vi.hoisted(() => ({
overrideCredentialId: null as number | null,
credentials: new Map<string, Record<string, unknown>>(),
auditCalls: [] as Record<string, unknown>[],
folderCredentialId: null as number | null,
sharedSecret: null as Record<string, unknown> | null,
activeHostAccessId: null as number | null,
snapshotForUserCalls: [] as Record<string, unknown>[],
@@ -20,7 +19,6 @@ vi.mock("../../database/repositories/factory.js", () => ({
findHostById: async () => (state.host ? { ...state.host } : null),
findCredentialByIdForUser: async (credentialId: number, userId: string) =>
state.credentials.get(`${credentialId}:${userId}`) ?? null,
findFolderCredentialId: async () => state.folderCredentialId,
}),
createCurrentSharedHostAuthOverrideRepository: () => ({
findCredentialId: async () => state.overrideCredentialId,
@@ -114,7 +112,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
sudoPassword: "owner-sudo",
jumpHosts: null,
tunnelConnections: null,
terminalConfig: null,
socks5ProxyChain: null,
quickActions: null,
overrideCredentialUsername: false,
@@ -129,7 +126,6 @@ beforeEach(() => {
state.overrideCredentialId = null;
state.credentials.clear();
state.auditCalls = [];
state.folderCredentialId = null;
state.sharedSecret = null;
state.activeHostAccessId = null;
state.snapshotForUserCalls = [];
@@ -171,35 +167,6 @@ describe("resolveHostById", () => {
expect(host.sudoPassword).toBe("owner-sudo");
});
it("falls back to the host's folder-assigned credential when none is set on the host", async () => {
state.host = baseHost({
authType: "credential",
credentialId: null,
folder: "switches",
username: "",
password: null,
});
state.folderCredentialId = 11;
state.credentials.set("11:owner", {
id: 11,
username: "folder-user",
authType: "password",
password: "folder-pass",
privateKey: null,
key: null,
keyPassword: null,
keyType: null,
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
unknown
>;
expect(host.password).toBe("folder-pass");
expect(host.username).toBe("folder-user");
expect(host.authType).toBe("password");
});
it("prefers the host's own credential over its folder's credential", async () => {
state.host = baseHost({
authType: "credential",
@@ -208,7 +175,6 @@ describe("resolveHostById", () => {
username: "",
password: null,
});
state.folderCredentialId = 11;
state.credentials.set("9:owner", {
id: 9,
username: "host-user",
@@ -281,7 +247,7 @@ describe("resolveHostById", () => {
state.host = baseHost({
authType: "agent",
password: null,
terminalConfig: JSON.stringify({
sshOptions: JSON.stringify({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
}),
});
@@ -294,7 +260,7 @@ describe("resolveHostById", () => {
authType: "agent",
password: null,
shareSshAuth: true,
terminalConfig: JSON.stringify({
sshOptions: JSON.stringify({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
}),
});
@@ -307,9 +273,6 @@ describe("resolveHostById", () => {
expect(host.sshOptions).toEqual({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
});
expect(host.terminalConfig).toEqual({
agentSocketPath: "/run/user/1000/ssh-agent.sock",
});
});
it("uses the recipient's credential instead of the owner's authentication", async () => {
@@ -436,10 +399,7 @@ describe("resolveHostById", () => {
password: "stale-owner-password",
key: "stale-owner-key",
credentialId: null,
terminalConfig: JSON.stringify({
theme: "termix",
sudoPassword: "owner-sudo",
}),
sudoPassword: "owner-sudo",
});
const host = (await resolveHostById(42, "recipient")) as Record<
string,
@@ -448,37 +408,13 @@ describe("resolveHostById", () => {
expect(host.password).toBeNull();
expect(host.key).toBeNull();
expect(host.credentialId).toBeNull();
// The owner's legacy sudo password never reaches a recipient.
expect(host.terminalConfig).toEqual({ theme: "termix" });
// The owner's sudo password never reaches a recipient.
expect(host.sudoPassword).toBeNull();
});
it("hands the owner a 2.8 sudo password and SSH options kept in terminal_config", async () => {
state.host = baseHost({
sudoPassword: null,
sshOptions: null,
terminalConfig: JSON.stringify({
sudoPassword: "legacy-sudo",
keepaliveInterval: 12,
theme: "nord",
}),
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
unknown
>;
expect(host.sudoPassword).toBe("legacy-sudo");
expect(host.sshOptions).toEqual({ keepaliveInterval: 12 });
expect(host.terminalConfig).toEqual({
keepaliveInterval: 12,
theme: "nord",
});
});
it("prefers the ssh_options column once it is filled", async () => {
it("reads the ssh_options column", async () => {
state.host = baseHost({
sshOptions: JSON.stringify({ keepaliveCountMax: 3 }),
terminalConfig: JSON.stringify({ keepaliveInterval: 12 }),
});
const host = (await resolveHostById(42, "owner")) as Record<
string,
@@ -199,7 +199,7 @@ beforeEach(() => {
});
describe("a plugin's external identity", () => {
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
it("carries the role map and logout claims into core", async () => {
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
@@ -211,7 +211,6 @@ describe("a plugin's external identity", () => {
expect(result.kind).toBe("session");
const [user] = [...h.state.users.values()];
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
expect(h.state.identities).toContainEqual(
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
);
@@ -219,9 +218,7 @@ describe("a plugin's external identity", () => {
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
user.id,
expect.objectContaining({
ssoProviderId: 3,
oidcSub: "sub-1",
oidcSid: "sid-1",
externalSession: { providerId: 3, sub: "sub-1", sid: "sid-1" },
}),
);
});
@@ -238,29 +235,6 @@ describe("a plugin's external identity", () => {
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
expect(mapped.rateLimitUsername).toBeUndefined();
});
it("finds a 2.8 user by the old identifier and links the identity", async () => {
h.state.users.set("u-1", {
id: "u-1",
username: "Alice",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "sub-1",
});
setup();
const identity = await getLoginMethod("corp-sso")!.callback!(
fakeRequest() as never,
);
await runLogin(fakeRequest() as never, identity, {
methodId: "corp-sso",
rememberMe: false,
});
expect(h.state.users.size).toBe(1);
expect(h.state.identities).toContainEqual(
expect.objectContaining({ userId: "u-1", providerId: "3" }),
);
});
});
describe("ctx.auth.completeRedirectLogin", () => {
@@ -306,7 +280,7 @@ describe("the other ctx.auth helpers", () => {
const { auth } = setup();
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
expect(h.manager.revokeSessionsByExternalSession).toHaveBeenCalledWith({
ssoProviderId: 3,
providerId: 3,
sub: null,
sid: "sid-1",
});
@@ -402,8 +376,13 @@ describe("form methods and the external second factor setting", () => {
username: "Bob",
passwordHash: "",
isAdmin: false,
isOidc: true,
oidcIdentifier: "ldap:4:bob",
});
h.state.identities.push({
id: 1,
userId: "u-bob",
providerId: "ldap:4",
subject: "bob",
email: null,
});
h.state.factors.push({
userId: "u-bob",
+1 -1
View File
@@ -474,7 +474,7 @@ describe("ctx.hosts", () => {
password: "secret",
key: "PRIVATE",
autostartPassword: "x",
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
sshOptions: '{"keepaliveInterval":5}',
enableDocker: true,
jumpHosts: "[]",
},
+10 -47
View File
@@ -107,7 +107,6 @@ function addUser(overrides: Record<string, unknown> = {}) {
username: "alice",
passwordHash: bcrypt.hashSync(PASSWORD, 4),
isAdmin: false,
isOidc: false,
...overrides,
};
h.state.users.set(user.id as string, user as never);
@@ -196,7 +195,6 @@ describe("external sign-in cannot claim another account", () => {
email: null,
name: "Alice",
legacyIdentifier: "shared-id",
ssoProviderId: 3,
};
beforeEach(() => {
@@ -209,7 +207,7 @@ describe("external sign-in cannot claim another account", () => {
external: true,
}),
);
h.state.settings.set("oidc_auto_provision", "true");
h.state.settings.set("external_auto_provision", "true");
});
async function signIn(identity: Record<string, unknown> = external) {
@@ -223,55 +221,21 @@ describe("external sign-in cannot claim another account", () => {
return h.state.identities.find((row) => row.subject === "sub-1")?.userId;
}
it("never links a local password account by its old identifier", async () => {
addUser({ id: "local", oidcIdentifier: "shared-id", isOidc: false });
await signIn();
expect(linkedTo()).not.toBe("local");
});
it("never links an account that already has an identity", async () => {
addUser({
id: "taken",
username: "bob",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
});
it("never links an account that has no identity for this provider", async () => {
addUser({ id: "local", username: "Alice" });
addUser({ id: "sso", username: "bob", passwordHash: "" });
h.state.identities.push({
id: 1,
userId: "taken",
userId: "sso",
providerId: "9",
subject: "bob",
subject: "sub-1",
email: null,
});
await signIn();
expect(linkedTo()).not.toBe("taken");
});
it("never links an account from a different provider", async () => {
addUser({
id: "other",
username: "bob",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
ssoProviderId: 7,
});
await signIn();
expect(linkedTo()).not.toBe("other");
});
it("still links a genuine 2.8 account on its first sign-in", async () => {
addUser({
id: "legacy",
username: "Alice",
passwordHash: "",
isOidc: true,
oidcIdentifier: "shared-id",
ssoProviderId: 3,
});
await signIn();
expect(linkedTo()).toBe("legacy");
expect(linkedTo()).not.toBe("local");
expect(
h.state.identities.find((row) => row.providerId === "3")?.userId,
).not.toBe("sso");
});
it("never gives a new account a local user's username", async () => {
@@ -290,7 +254,6 @@ describe("external sign-in cannot claim another account", () => {
id: "sso-user",
username: "carol",
passwordHash: "",
isOidc: true,
});
h.state.identities.push({
id: 1,
@@ -398,7 +398,7 @@ describe("host field options", () => {
host: { enableKey: "on", enableLabelKey: "k", fields, ...extra },
});
it("accepts enableDefault, defaultFrom, shareRead and ownerOnly", () => {
it("accepts enableDefault, shareRead and ownerOnly", () => {
expect(
validateManifest(
host(
@@ -407,7 +407,6 @@ describe("host field options", () => {
key: "mode",
type: "boolean",
labelKey: "k",
defaultFrom: "defaultOn",
shareRead: "edit",
ownerOnly: true,
},
@@ -418,7 +417,7 @@ describe("host field options", () => {
).toEqual([]);
});
it("still accepts an old defaultFrom, but only as a string", () => {
it("rejects the removed defaultFrom", () => {
expect(
validateManifest(
host([
@@ -429,11 +428,6 @@ describe("host field options", () => {
defaultFrom: "missing",
},
]),
),
).toEqual([]);
expect(
validateManifest(
host([{ key: "mode", type: "boolean", labelKey: "k", defaultFrom: 1 }]),
).join(),
).toMatch(/defaultFrom/);
});
@@ -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 "../../upgrade/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,10 +1,8 @@
/**
* 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.
* Every provider key has to reach the ai plugin, encrypted and gone from the
* table. Running it twice must not duplicate or clobber anything.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -127,10 +125,7 @@ vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import {
allowlistToText,
runAiSettingsMigration,
} from "../../upgrade/ai-settings-migration.js";
import { runAiSettingsMigration } from "../../upgrade/ai-settings-migration.js";
const value = (scope: string, scopeId: string | null, key: string) => {
const row = state.pluginRows.find(
@@ -167,30 +162,7 @@ beforeEach(() => {
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();
@@ -235,18 +207,18 @@ describe("runAiSettingsMigration", () => {
expect(JSON.stringify(state.pluginRows)).toBe(first);
});
it("does not overwrite a value the plugin already has", async () => {
it("does not overwrite a key the plugin already has", async () => {
state.pluginRows.push({
pluginId: "ai",
scope: "admin",
scopeId: null,
key: "globallyEnabled",
value: "false",
encrypted: false,
scope: "secret",
scopeId: "u1",
key: "provider:3",
value: JSON.stringify("sysenc:kept"),
encrypted: true,
});
await runAiSettingsMigration();
expect(value("admin", null, "globallyEnabled")).toBe(false);
expect(value("secret", "u1", "provider:3")).toBe("sysenc:kept");
});
it("does nothing until the plugin row exists", async () => {
@@ -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 "../../upgrade/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,130 +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("../../upgrade/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);
});
it("does not recreate a legacy-oidc link that was moved to its provider", async () => {
user("oidc-user", { oidcIdentifier: "sub-1" });
h.state.identities.push({
id: 1,
userId: "oidc-user",
providerId: "1",
subject: "sub-1",
email: null,
} as never);
const result = await runExternalIdentityMigration();
expect(result).toEqual({ identities: 0, skipped: 1 });
expect(h.state.identities).toHaveLength(1);
});
});
@@ -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("../../upgrade/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,311 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type PluginRow = {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
};
const db = vi.hoisted(() => ({
settings: {} as Record<string, string>,
pluginRows: [] as PluginRow[],
defaults: [] as Array<{
scope: Record<string, unknown>;
namespace: string;
key: string;
value: string;
}>,
hosts: [] as Array<Record<string, unknown>>,
folders: [] as Array<{ id: number; userId: string; credentialId: number }>,
folderCredential: new Map<string, number>(),
}));
const manifests = vi.hoisted(() => [
{
id: "ssh-terminal",
contributes: {
settings: {
host: {
fields: [
{ key: "fontSize", type: "number", default: 14 },
{ key: "theme", type: "string", default: "termix" },
{ key: "localEcho", type: "select", default: "auto" },
{ key: "linkClickBehavior", type: "select", default: "confirm" },
],
},
},
},
},
{
id: "remote-desktop",
contributes: {
settings: {
host: {
fields: [{ key: "colorDepth", type: "select", default: "inherit" }],
},
},
},
},
{ id: "host-metrics", contributes: { settings: { host: { fields: [] } } } },
]);
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
hostSettingsPlugins: () => manifests,
}));
vi.mock("../../database/db/schema.js", () => ({
sshFolders: { id: "id", userId: "userId", credentialId: "credentialId" },
}));
vi.mock("../../database/repositories/factory.js", () => {
const scopeKey = (scope: Record<string, unknown>) =>
scope.level === "admin"
? "admin"
: scope.level === "user"
? `u:${scope.userId}`
: `f:${scope.folderId}`;
return {
createCurrentSettingsRepository: () => ({
get: async (key: string) => db.settings[key] ?? null,
set: async (key: string, value: string) => {
db.settings[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
listByKey: async (pluginId: string, scope: string, key: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId && row.scope === scope && row.key === key,
),
getAll: async (pluginId: string, scope: string, scopeId: string) =>
db.pluginRows.filter(
(row) =>
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId,
),
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
db.pluginRows.push({ pluginId, scope, scopeId, key, value });
},
delete: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
) => {
db.pluginRows = db.pluginRows.filter(
(row) =>
!(
row.pluginId === pluginId &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key
),
);
},
}),
createCurrentHostDefaultsRepository: () => ({
listScope: async (scope: Record<string, unknown>) =>
db.defaults.filter((row) => scopeKey(row.scope) === scopeKey(scope)),
apply: async (
scope: Record<string, unknown>,
set: Array<{ namespace: string; key: string; value: string }>,
) => {
for (const entry of set) db.defaults.push({ scope, ...entry });
},
listHosts: async () => db.hosts,
updateHosts: async (
patches: Array<{ id: number; values: Record<string, unknown> }>,
) => {
for (const patch of patches) {
Object.assign(
db.hosts.find((host) => host.id === patch.id)!,
patch.values,
);
}
},
}),
createCurrentHostResolutionRepository: () => ({
findFolderCredentialId: async (_userId: string, folder: string) =>
db.folderCredential.get(folder) ?? null,
}),
createCurrentRepositoryContext: () => ({
drizzle: {
select: () => ({
from: () => ({ where: async () => db.folders }),
}),
},
}),
};
});
const { runHostDefaultsMigration } =
await import("../../upgrade/host-defaults-migration.js");
function level(namespace: string, key: string, scopeLevel: string) {
const row = db.defaults.find(
(entry) =>
entry.namespace === namespace &&
entry.key === key &&
entry.scope.level === scopeLevel,
);
return row ? JSON.parse(row.value) : undefined;
}
function hostValue(hostId: number, pluginId: string, key: string) {
const row = db.pluginRows.find(
(entry) =>
entry.pluginId === pluginId &&
entry.scope === "host" &&
entry.scopeId === String(hostId) &&
entry.key === key,
);
return row?.value ? JSON.parse(row.value) : undefined;
}
const plugin = (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: unknown,
): PluginRow => ({
pluginId,
scope,
scopeId,
key,
value: JSON.stringify(value),
});
beforeEach(() => {
db.settings = {};
db.pluginRows = [];
db.defaults = [];
db.hosts = [];
db.folders = [];
db.folderCredential = new Map();
});
describe("runHostDefaultsMigration", () => {
it("moves the old server defaults, dropping the credential and password", async () => {
db.settings.host_defaults = JSON.stringify({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
socks5Password: "secret",
credentialId: 3,
statusCheckEnabled: false,
});
db.pluginRows.push(
plugin("ssh-terminal", "admin", null, "newHostFontSize", 18),
plugin("host-metrics", "admin", null, "enabledForNewHosts", false),
);
db.folders = [{ id: 4, userId: "u1", credentialId: 9 }];
await runHostDefaultsMigration();
expect(level("core", "socks5", "admin")).toMatchObject({
useSocks5: true,
socks5Host: "proxy",
socks5Port: 1081,
});
expect(JSON.stringify(db.defaults)).not.toContain("secret");
expect(level("core", "statusCheckEnabled", "admin")).toBe(false);
expect(level("ssh-terminal", "fontSize", "admin")).toBe(18);
expect(level("host-metrics", "metricsEnabled", "admin")).toBe(false);
expect(level("core", "auth", "folder")).toMatchObject({
authType: "credential",
credentialId: 9,
});
});
it("moves each user's terminal and remote desktop defaults to their own level", async () => {
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("remote-desktop", "user", "u1", "colorDepth", "16"),
plugin("remote-desktop", "user", "u1", "disableAudio", "inherit"),
);
await runHostDefaultsMigration();
expect(level("ssh-terminal", "fontSize", "user")).toBe(20);
expect(level("ssh-terminal", "localEcho", "user")).toBe("on");
expect(level("remote-desktop", "colorDepth", "user")).toBe("16");
expect(level("remote-desktop", "disableAudio", "user")).toBeUndefined();
});
it("writes in the look a host really had while it followed its user", async () => {
db.hosts = [
{ id: 1, userId: "u1", defaultOverrides: null },
{ id: 2, userId: "u1", defaultOverrides: null },
{ id: 3, userId: "u1", defaultOverrides: '{"core":[]}' },
];
db.pluginRows.push(
plugin("ssh-terminal", "user", "u1", "terminalDefaults", {
fontSize: 20,
}),
plugin("ssh-terminal", "user", "u1", "localEcho", "on"),
plugin("ssh-terminal", "host", "1", "fontSize", 30),
plugin("ssh-terminal", "host", "1", "localEcho", "default"),
plugin("ssh-terminal", "host", "2", "inheritAppearance", false),
plugin("ssh-terminal", "host", "2", "fontSize", 30),
plugin("ssh-terminal", "host", "2", "localEcho", "off"),
plugin("remote-desktop", "user", "u1", "colorDepth", "24"),
);
await runHostDefaultsMigration();
// Followed the user: the user's look, and their echo mode.
expect(hostValue(1, "ssh-terminal", "fontSize")).toBe(20);
expect(hostValue(1, "ssh-terminal", "localEcho")).toBe("on");
expect(hostValue(1, "remote-desktop", "colorDepth")).toBe("24");
// Had its own look: kept.
expect(hostValue(2, "ssh-terminal", "fontSize")).toBe(30);
expect(hostValue(2, "ssh-terminal", "localEcho")).toBe("off");
// Saved since host defaults: left alone.
expect(hostValue(3, "ssh-terminal", "fontSize")).toBeUndefined();
});
it("gives a credential host its folder's credential", async () => {
db.hosts = [
{
id: 1,
userId: "u1",
authType: "credential",
credentialId: null,
folder: "Prod",
defaultOverrides: null,
},
];
db.folderCredential.set("Prod", 12);
await runHostDefaultsMigration();
expect(db.hosts[0].credentialId).toBe(12);
});
it("moves each level once", async () => {
db.settings.host_defaults = JSON.stringify({ statusCheckEnabled: false });
await runHostDefaultsMigration();
db.defaults = [];
await runHostDefaultsMigration();
expect(level("core", "statusCheckEnabled", "admin")).toBeUndefined();
});
});
@@ -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("../../upgrade/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,8 +1,7 @@
/**
* 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.
* plugin's folder and the old file must stay for a downgrade. Running it
* twice must not overwrite anything.
*/
import fs from "node:fs/promises";
@@ -56,33 +55,22 @@ afterEach(async () => {
});
describe("runOpksshConfigMigration", () => {
it("copies the config, keeps the old one and the old redirect URI, once", async () => {
it("copies the config and keeps the old one, 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 runOpksshConfigMigration(dataDir)).toEqual({ copied: 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");
expect(state.settings.has("legacyCallback")).toBe(false);
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 runOpksshConfigMigration(dataDir)).toEqual({ copied: 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(await runOpksshConfigMigration(dataDir)).toEqual({ copied: false });
expect(state.settings.size).toBe(0);
});
@@ -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("../../upgrade/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("../../upgrade/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,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("../../upgrade/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("../../upgrade/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,174 +0,0 @@
/**
* The snippets settings migration: host quick actions, the startup snippet
* and the two snippet user preferences must come through into the snippets
* plugin's settings, and running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const hostRows: Array<Record<string, unknown>> = [];
const preferenceRows: Array<Record<string, unknown>> = [];
let pluginInstalled = true;
const find = (scope: string, scopeId: string | null, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "snippets" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "snippets" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
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 pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async (query: unknown) =>
JSON.stringify(query).includes("user_preferences")
? preferenceRows
: hostRows,
}),
}));
const { runSnippetsSettingsMigration, snippetHostSettingsFromRow } =
await import("../../upgrade/snippets-settings-migration.js");
function value(scope: string, scopeId: string, key: string): unknown {
const row = find(scope, scopeId, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
pluginRows.length = 0;
hostRows.length = 0;
preferenceRows.length = 0;
pluginInstalled = true;
});
describe("snippetHostSettingsFromRow", () => {
it("reads quick actions and the startup snippet, dropping broken rows", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: JSON.stringify([
{ name: "Restart", snippetId: 3 },
{ name: "Broken", snippetId: "x" },
{ name: "Str", snippetId: "4" },
]),
terminal_config: JSON.stringify({ fontSize: 14, startupSnippetId: 9 }),
}),
).toEqual({
quickActions: [
{ name: "Restart", snippetId: 3 },
{ name: "Str", snippetId: 4 },
],
startupSnippetId: 9,
});
});
it("reads nothing from a row without either", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: "[]",
terminal_config: JSON.stringify({ fontSize: 14 }),
}),
).toEqual({});
});
});
describe("runSnippetsSettingsMigration", () => {
it("moves host and user values once", async () => {
hostRows.push(
{
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
},
{ id: 2, quick_actions: null, terminal_config: "{}" },
);
preferenceRows.push(
{ user_id: "u1", confirm_snippet_execution: 1, folders_collapsed: 0 },
{
user_id: "u2",
confirm_snippet_execution: null,
folders_collapsed: null,
},
);
const first = await runSnippetsSettingsMigration();
expect(first).toEqual({ hostsMoved: 1, hostsSkipped: 0, usersMoved: 1 });
expect(value("host", "1", "quickActions")).toEqual([
{ name: "Up", snippetId: 2 },
]);
expect(value("host", "1", "startupSnippetId")).toBe(5);
expect(value("user", "u1", "confirmExecution")).toBe(true);
expect(value("user", "u1", "foldersCollapsed")).toBe(false);
expect(find("user", "u2", "confirmExecution")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
const second = await runSnippetsSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1, usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("never overwrites what the user already changed in the plugin", async () => {
pluginRows.push({
pluginId: "snippets",
scope: "host",
scopeId: "1",
key: "startupSnippetId",
value: "7",
});
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
});
await runSnippetsSettingsMigration();
expect(value("host", "1", "startupSnippetId")).toBe(7);
expect(find("host", "1", "quickActions")).toBeUndefined();
});
it("does nothing while the plugin is not installed", async () => {
pluginInstalled = false;
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: null,
});
await runSnippetsSettingsMigration();
expect(pluginRows).toHaveLength(0);
});
});
@@ -1,248 +0,0 @@
/**
* The terminal's look and behavior moving out of ssh_data.terminal_config and
* user_preferences into the ssh-terminal plugin's settings. Lossless, and a
* second run changes nothing.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
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(() => ({
db: null as unknown,
rows: [] as Row[],
installed: true,
}));
const find = (scope: string, scopeId: string | null, key: string) =>
state.rows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.installed && id === "ssh-terminal" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
getAllForScopeIds: async (scope: string, scopeIds: string[]) =>
state.rows.filter(
(row) => row.scope === scope && scopeIds.includes(row.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.rows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
const {
runSshTerminalLookMigration,
hostSettingsFromTerminalConfig,
userSettingsFromPreferences,
} = await import("../../upgrade/ssh-terminal-look-migration.js");
let sqlite: Database.Database;
beforeEach(() => {
state.rows.length = 0;
state.installed = true;
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, terminal_config TEXT);
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
terminal_defaults TEXT,
custom_themes TEXT,
command_autocomplete INTEGER
);
`);
state.db = drizzle(sqlite);
});
function host(id: number, key: string): unknown {
const row = find("host", String(id), key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
function user(id: string, key: string): unknown {
const row = find("user", id, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
describe("hostSettingsFromTerminalConfig", () => {
it("keeps a host that followed the user's look following it", () => {
expect(
hostSettingsFromTerminalConfig(
JSON.stringify({
autoTmux: true,
localEcho: "on",
keepaliveInterval: 30,
startupSnippetId: 3,
}),
),
).toEqual({ autoTmux: true, localEcho: "on" });
});
it("opts a host with its own look out of the user's defaults", () => {
expect(
hostSettingsFromTerminalConfig({
theme: "Termix Dark",
fontSize: "16",
cursorStyle: "wobble",
bellStyle: "visual",
}),
).toEqual({
inheritAppearance: false,
theme: "termix",
fontSize: 16,
bellStyle: "visual",
});
});
it("reads nothing out of garbage", () => {
expect(hostSettingsFromTerminalConfig("not json")).toEqual({});
expect(hostSettingsFromTerminalConfig(null)).toEqual({});
});
});
describe("userSettingsFromPreferences", () => {
it("takes the defaults, saved themes and autocomplete", () => {
expect(
userSettingsFromPreferences({
terminal_defaults: JSON.stringify({ fontSize: 18, theme: "nord" }),
custom_themes: JSON.stringify([
{ id: "a", name: "Mine", colors: { background: "#000" } },
]),
command_autocomplete: 1,
}),
).toEqual({
terminalDefaults: { fontSize: 18, theme: "nord" },
customThemes: [{ id: "a", name: "Mine", colors: { background: "#000" } }],
commandAutocomplete: true,
});
expect(userSettingsFromPreferences({ command_autocomplete: null })).toEqual(
{},
);
});
});
describe("runSshTerminalLookMigration", () => {
function seed() {
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(
1,
JSON.stringify({
theme: "dracula",
fontSize: 18,
autoTmux: true,
sudoPasswordAutoFill: true,
keepaliveInterval: 30,
}),
);
sqlite
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
.run(2, JSON.stringify({ passwordPromptAutoFill: false }));
sqlite
.prepare("INSERT INTO ssh_data VALUES (?, ?)")
.run(3, JSON.stringify({ keepaliveInterval: 10 }));
sqlite.prepare("INSERT INTO ssh_data VALUES (?, ?)").run(4, null);
sqlite
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
.run(
"u1",
JSON.stringify({ cursorBlink: false }),
JSON.stringify([
{ id: "t", name: "T", colors: { background: "#111" } },
]),
1,
);
sqlite
.prepare("INSERT INTO user_preferences VALUES (?, ?, ?, ?)")
.run("u2", null, null, null);
}
it("copies each host's terminal keys and each user's terminal preferences", async () => {
seed();
const result = await runSshTerminalLookMigration();
expect(result).toEqual({ hostsMoved: 2, hostsSkipped: 0, usersMoved: 1 });
expect(host(1, "inheritAppearance")).toBe(false);
expect(host(1, "theme")).toBe("dracula");
expect(host(1, "fontSize")).toBe(18);
expect(host(1, "autoTmux")).toBe(true);
expect(host(1, "sudoPasswordAutoFill")).toBe(true);
expect(host(1, "keepaliveInterval")).toBeUndefined();
expect(host(2, "passwordPromptAutoFill")).toBe(false);
expect(host(2, "inheritAppearance")).toBeUndefined();
expect(host(3, "keepaliveInterval")).toBeUndefined();
expect(user("u1", "terminalDefaults")).toEqual({ cursorBlink: false });
expect(user("u1", "customThemes")).toHaveLength(1);
expect(user("u1", "commandAutocomplete")).toBe(true);
expect(state.rows.some((row) => row.scopeId === "u2")).toBe(false);
});
it("changes nothing on a second run, even after values were edited", async () => {
seed();
await runSshTerminalLookMigration();
find("host", "1", "fontSize")!.value = JSON.stringify(12);
find("user", "u1", "commandAutocomplete")!.value = JSON.stringify(false);
const before = JSON.stringify(state.rows);
const second = await runSshTerminalLookMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 2, usersMoved: 0 });
expect(JSON.stringify(state.rows)).toBe(before);
expect(host(1, "fontSize")).toBe(12);
});
it("waits for the plugin row", async () => {
seed();
state.installed = false;
expect(await runSshTerminalLookMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
});
expect(state.rows).toEqual([]);
});
it("copies nothing from a database without the columns", async () => {
sqlite = new Database(":memory:");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
state.db = drizzle(sqlite);
expect(await runSshTerminalLookMigration()).toEqual({
hostsMoved: 0,
hostsSkipped: 0,
usersMoved: 0,
});
});
});
@@ -1,104 +0,0 @@
/**
* The terminal macros migration: a user's saved macros must come through
* into the ssh-terminal plugin's macros setting, and running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const preferenceRows: Array<Record<string, unknown>> = [];
const find = (scopeId: string, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === "user" &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (id === "ssh-terminal" ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, scopeId: string, key: string) =>
find(scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string | null,
) => {
const existing = find(scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({ all: async () => preferenceRows }),
}));
const { runSshTerminalMacrosMigration, macrosFromPreferences } =
await import("../../upgrade/ssh-terminal-macros-migration.js");
const macro = { id: "m1", name: "Deploy", steps: [{ id: "s", type: "send" }] };
beforeEach(() => {
pluginRows.length = 0;
preferenceRows.length = 0;
});
describe("macrosFromPreferences", () => {
it("keeps valid macros and drops the rest", () => {
expect(
macrosFromPreferences(JSON.stringify([macro, { id: 1 }, "x"])),
).toEqual([macro]);
expect(macrosFromPreferences("[]")).toBeNull();
expect(macrosFromPreferences("{bad")).toBeNull();
});
});
describe("runSshTerminalMacrosMigration", () => {
it("moves each user's macros once", async () => {
preferenceRows.push(
{ user_id: "u1", terminal_macros: JSON.stringify([macro]) },
{ user_id: "u2", terminal_macros: "[]" },
);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 1 });
expect(JSON.parse(find("u1", "macros")!.value!)).toEqual([macro]);
expect(find("u2", "macros")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("leaves a user who already has macros in the plugin alone", async () => {
pluginRows.push({
pluginId: "ssh-terminal",
scope: "user",
scopeId: "u1",
key: "macros",
value: "[]",
});
preferenceRows.push({
user_id: "u1",
terminal_macros: JSON.stringify([macro]),
});
await runSshTerminalMacrosMigration();
expect(find("u1", "macros")!.value).toBe("[]");
});
});
@@ -1,241 +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("../../upgrade/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("moves the terminal half of the admin host defaults to the new-host settings", async () => {
coreSettings.set(
"host_defaults",
JSON.stringify({
useSocks5: true,
fontSize: 18,
fontFamily: "Fira Code",
theme: "Termix Dark",
cursorStyle: "wobble",
cursorBlink: false,
autoTmux: true,
}),
);
await runSshTerminalSettingsMigration();
expect(admin("newHostFontSize")).toBe(18);
expect(admin("newHostFontFamily")).toBe("Fira Code");
expect(admin("newHostTheme")).toBe("termix");
expect(admin("newHostCursorStyle")).toBeUndefined();
expect(admin("newHostCursorBlink")).toBe(false);
expect(admin("newHostAutoTmux")).toBe(true);
});
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,79 +0,0 @@
/**
* The silent sign-in default moving from core settings into the sso plugin's
* admin settings.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
core: new Map<string, string>(),
plugin: new Map<string, string | null>(),
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "sso" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.core.get(key) ?? null,
delete: async (key: string) => {
state.core.delete(key);
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => (state.plugin.has(key) ? { value: state.plugin.get(key) } : null),
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
) => {
state.plugin.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runSsoSettingsMigration } from "../../upgrade/sso-settings-migration.js";
beforeEach(() => {
state.core = new Map();
state.plugin = new Map();
state.pluginInstalled = true;
});
describe("runSsoSettingsMigration", () => {
it("moves the core value into the sso admin setting", async () => {
state.core.set("oidc_silent_login_default", "true");
expect(await runSsoSettingsMigration()).toEqual({ moved: true });
expect(state.plugin.get("silentLoginDefault")).toBe("true");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("keeps a value already in plugin settings", async () => {
state.core.set("oidc_silent_login_default", "true");
state.plugin.set("silentLoginDefault", "false");
await runSsoSettingsMigration();
expect(state.plugin.get("silentLoginDefault")).toBe("false");
expect(state.core.has("oidc_silent_login_default")).toBe(false);
});
it("does nothing twice or without the plugin", async () => {
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
state.core.set("oidc_silent_login_default", "true");
state.pluginInstalled = false;
expect(await runSsoSettingsMigration()).toEqual({ moved: false });
expect(state.core.get("oidc_silent_login_default")).toBe("true");
});
});
@@ -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 "../../upgrade/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("../../upgrade/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,100 +0,0 @@
/**
* The telemetry settings migration: an instance that turned telemetry off in
* 2.8 stays off, keeps its instance id, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface SettingsRow {
pluginId: string;
key: string;
value: string | null;
}
const coreSettings = new Map<string, string>();
const settingsRows: SettingsRow[] = [];
let pluginInstalled = true;
const find = (pluginId: string, key: string) =>
settingsRows.find((row) => row.pluginId === pluginId && row.key === key);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (pluginInstalled ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (pluginId: string, _scope: string, _id: null, key: string) =>
find(pluginId, key) ?? null,
set: async (
pluginId: string,
_scope: string,
_id: null,
key: string,
value: string | null,
) => {
const existing = find(pluginId, key);
if (existing) existing.value = value;
else settingsRows.push({ pluginId, key, value });
},
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => coreSettings.get(key) ?? null,
}),
}));
const { runTelemetrySettingsMigration } =
await import("../../upgrade/telemetry-settings-migration.js");
const stored = (key: string) => {
const row = find("telemetry", key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
describe("runTelemetrySettingsMigration", () => {
beforeEach(() => {
coreSettings.clear();
settingsRows.length = 0;
pluginInstalled = true;
});
it("moves a disabled switch and the instance id", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "abc-123");
const result = await runTelemetrySettingsMigration();
expect(result).toEqual({ movedEnabled: true, movedInstanceId: true });
expect(stored("enabled")).toBe(false);
expect(stored("instanceId")).toBe("abc-123");
});
it("writes nothing for an enabled instance without an id", async () => {
coreSettings.set("analytics_enabled", "true");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
it("is idempotent and never overwrites plugin values", async () => {
coreSettings.set("analytics_enabled", "false");
coreSettings.set("analytics_instance_id", "old");
settingsRows.push({
pluginId: "telemetry",
key: "instanceId",
value: JSON.stringify("new"),
});
await runTelemetrySettingsMigration();
const second = await runTelemetrySettingsMigration();
expect(second).toEqual({ movedEnabled: false, movedInstanceId: false });
expect(stored("instanceId")).toBe("new");
expect(settingsRows).toHaveLength(2);
});
it("does nothing without the plugin row", async () => {
pluginInstalled = false;
coreSettings.set("analytics_enabled", "false");
await runTelemetrySettingsMigration();
expect(settingsRows).toHaveLength(0);
});
});
@@ -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("../../upgrade/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,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("../../upgrade/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 "../../upgrade/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 "../../upgrade/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 "../../upgrade/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("../../upgrade/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();
});
});
@@ -75,17 +75,15 @@ function insertSession({ id, userId, sub, sid, providerId }: SessionInput) {
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
oidc_sub, oidc_sid, sso_provider_id,
external_session_ref,
created_at, expires_at, last_active_at
) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?, ?, ?)`,
) VALUES (?, ?, ?, 'browser', 'test', ?, ?, ?, ?)`,
)
.run(
id,
userId,
`token-${id}`,
sub,
sid,
providerId,
JSON.stringify({ providerId, sub, sid }),
"2026-07-10T00:00:00.000Z",
"2026-07-11T00:00:00.000Z",
"2026-07-10T00:00:00.000Z",
@@ -108,9 +106,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
jwt_token TEXT NOT NULL,
device_type TEXT NOT NULL,
device_info TEXT NOT NULL,
oidc_sub TEXT,
oidc_sid TEXT,
sso_provider_id INTEGER,
external_session_ref TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
last_active_at TEXT NOT NULL
@@ -148,7 +144,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sub: "subject-1",
sid: "session-1",
}),
@@ -182,7 +178,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sub: "subject-1",
}),
).resolves.toBe(2);
@@ -190,10 +186,34 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
expect(sessionIds()).toEqual(["other-subject"]);
});
it("leaves sessions with no or a broken reference alone", async () => {
mocks
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
external_session_ref, created_at, expires_at, last_active_at
) VALUES (?, 'user-1', 't', 'browser', 'test', ?, 'a', 'b', 'c')`,
)
.run("broken", "{not json");
mocks
.sqlite!.prepare(
`INSERT INTO sessions (
id, user_id, jwt_token, device_type, device_info,
external_session_ref, created_at, expires_at, last_active_at
) VALUES (?, 'user-1', 't', 'browser', 'test', NULL, 'a', 'b', 'c')`,
)
.run("password");
await expect(
authManager.revokeSessionsByExternalSession({ sub: "subject-1" }),
).resolves.toBe(0);
expect(sessionIds()).toEqual(["broken", "password"]);
});
it("does not persist when no session matches", async () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sid: "missing",
}),
).resolves.toBe(0);
@@ -213,7 +233,7 @@ describe("AuthManager.revokeSessionsByExternalSession", () => {
await expect(
authManager.revokeSessionsByExternalSession({
ssoProviderId: 7,
providerId: 7,
sid: "session-1",
}),
).rejects.toThrow("disk full");
@@ -1,129 +0,0 @@
/**
* The SSH connection options moving out of ssh_data.terminal_config into
* the core ssh_options column, against a real SQLite database through the
* same raw SQL helpers the boot path uses.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ db: null as unknown }));
vi.mock("../../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const { runSshOptionsMigration } =
await import("../../../utils/crypto-migration/ssh-options-migration.js");
let sqlite: Database.Database;
beforeEach(() => {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY,
terminal_config TEXT,
ssh_options TEXT
);
`);
state.db = drizzle(sqlite);
});
function insert(id: number, terminalConfig: unknown, sshOptions?: string) {
sqlite
.prepare(
"INSERT INTO ssh_data (id, terminal_config, ssh_options) VALUES (?, ?, ?)",
)
.run(
id,
terminalConfig === null ? null : JSON.stringify(terminalConfig),
sshOptions ?? null,
);
}
function options(id: number): unknown {
const row = sqlite
.prepare("SELECT ssh_options FROM ssh_data WHERE id = ?")
.get(id) as { ssh_options: string | null };
return row.ssh_options === null ? null : JSON.parse(row.ssh_options);
}
describe("runSshOptionsMigration", () => {
it("copies only the connection options, leaving the look and old tunnel keys", async () => {
insert(1, {
theme: "dracula",
fontSize: 16,
keepaliveInterval: 30,
keepaliveCountMax: 3,
allowLegacyAlgorithms: false,
agentSocketPath: "/run/agent.sock",
agentIdentity: "work",
agentForwarding: true,
environmentVariables: [{ key: "LANG", value: "C" }],
startupSnippetId: 4,
cfAccessClientId: "old",
cfTunnelHostname: "old.example.com",
});
const result = await runSshOptionsMigration();
expect(result.hostsMoved).toBe(1);
expect(options(1)).toEqual({
keepaliveInterval: 30,
keepaliveCountMax: 3,
allowLegacyAlgorithms: false,
agentSocketPath: "/run/agent.sock",
agentIdentity: "work",
agentForwarding: true,
environmentVariables: [{ key: "LANG", value: "C" }],
});
const terminalConfig = sqlite
.prepare("SELECT terminal_config FROM ssh_data WHERE id = 1")
.get() as { terminal_config: string };
expect(JSON.parse(terminalConfig.terminal_config).keepaliveInterval).toBe(
30,
);
});
it("marks a host with no options as done and skips hosts with no config", async () => {
insert(1, { theme: "dracula" });
insert(2, null);
const result = await runSshOptionsMigration();
expect(result.hostsMoved).toBe(0);
expect(options(1)).toEqual({});
expect(options(2)).toBeNull();
});
it("changes nothing on a second run, even after the options were edited", async () => {
insert(1, { keepaliveInterval: 30 });
insert(
2,
{ keepaliveInterval: 45 },
JSON.stringify({ keepaliveCountMax: 9 }),
);
await runSshOptionsMigration();
sqlite
.prepare("UPDATE ssh_data SET ssh_options = ? WHERE id = 1")
.run(JSON.stringify({ keepaliveInterval: 90 }));
const second = await runSshOptionsMigration();
expect(second.hostsMoved).toBe(0);
expect(options(1)).toEqual({ keepaliveInterval: 90 });
expect(options(2)).toEqual({ keepaliveCountMax: 9 });
});
it("does not throw on a database without the column", async () => {
sqlite = new Database(":memory:");
sqlite.exec("CREATE TABLE ssh_data (id INTEGER PRIMARY KEY)");
state.db = drizzle(sqlite);
await expect(runSshOptionsMigration()).resolves.toEqual({
hostsMoved: 0,
});
});
});
@@ -1,143 +0,0 @@
/**
* The status half of ssh_data.stats_config moving into the core
* status_check_enabled and status_check_interval columns. Run against a real
* SQLite database through the same raw SQL helpers the boot path uses.
*/
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
settings: new Map<string, string>(),
db: null as unknown,
}));
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.settings.get(key) ?? null,
set: async (key: string, value: string) => {
state.settings.set(key, value);
},
}),
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
const {
runHostStatusConfigMigration,
statusConfigFromStatsConfig,
HOST_STATUS_CONFIG_MIGRATED,
} =
await import("../../utils/crypto-migration/host-status-config-migration.js");
let sqlite: Database.Database;
function createHosts(withStatsConfig = true) {
sqlite = new Database(":memory:");
sqlite.exec(`
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY,
${withStatsConfig ? "stats_config TEXT," : ""}
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER
);
`);
state.db = drizzle(sqlite);
}
const hosts = () =>
sqlite
.prepare(
"SELECT id, status_check_enabled AS enabled, status_check_interval AS interval FROM ssh_data ORDER BY id",
)
.all();
beforeEach(() => {
state.settings.clear();
createHosts();
});
describe("statusConfigFromStatsConfig", () => {
it.each([
[null, true, null],
["{}", true, null],
['{"statusCheckEnabled":false}', false, null],
['{"disableTcpPing":true}', false, null],
['{"useGlobalStatusInterval":false,"statusCheckInterval":45}', true, 45],
['{"useGlobalStatusInterval":true,"statusCheckInterval":45}', true, null],
['{"useGlobalStatusInterval":false,"statusCheckInterval":2}', true, null],
[
JSON.stringify(JSON.stringify({ statusCheckEnabled: false })),
false,
null,
],
["not json", true, null],
])("%s", (raw, enabled, interval) => {
expect(statusConfigFromStatsConfig(raw)).toEqual({
statusCheckEnabled: enabled,
statusCheckInterval: interval,
});
});
});
describe("runHostStatusConfigMigration", () => {
it("copies each host's status options into the new columns", async () => {
const insert = sqlite.prepare(
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
);
insert.run(1, JSON.stringify({ statusCheckEnabled: false }));
insert.run(
2,
JSON.stringify({
useGlobalStatusInterval: false,
statusCheckInterval: 45,
}),
);
insert.run(
3,
JSON.stringify({ disableTcpPing: true, metricsEnabled: false }),
);
insert.run(4, JSON.stringify({ metricsInterval: 10 }));
insert.run(5, null);
const result = await runHostStatusConfigMigration();
expect(result).toEqual({ skipped: false, hostsMoved: 3 });
expect(hosts()).toEqual([
{ id: 1, enabled: 0, interval: null },
{ id: 2, enabled: 1, interval: 45 },
{ id: 3, enabled: 0, interval: null },
{ id: 4, enabled: 1, interval: null },
{ id: 5, enabled: 1, interval: null },
]);
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
});
it("changes nothing the second time", async () => {
sqlite
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
.run(1, JSON.stringify({ statusCheckEnabled: false }));
await runHostStatusConfigMigration();
// The user turns checks back on after the upgrade.
sqlite.exec("UPDATE ssh_data SET status_check_enabled = 1 WHERE id = 1");
expect(await runHostStatusConfigMigration()).toEqual({
skipped: true,
hostsMoved: 0,
});
expect(hosts()).toEqual([{ id: 1, enabled: 1, interval: null }]);
});
it("finishes on a fresh install that never had stats_config", async () => {
createHosts(false);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
expect(await runHostStatusConfigMigration()).toEqual({
skipped: false,
hostsMoved: 0,
});
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
});
});