mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: drop the 2.8 columns, moves and aliases kept for a downgrade
This commit is contained in:
1 parent
d3d8269cb7
commit
61f46d2517
196 files changed
+29909
-10511
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -474,7 +474,7 @@ describe("ctx.hosts", () => {
|
||||
password: "secret",
|
||||
key: "PRIVATE",
|
||||
autostartPassword: "x",
|
||||
terminalConfig: '{"keepaliveInterval":5,"fontSize":14}',
|
||||
sshOptions: '{"keepaliveInterval":5}',
|
||||
enableDocker: true,
|
||||
jumpHosts: "[]",
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user