diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 8210a72c7..e72930e99 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -1926,6 +1926,23 @@ async function moveRemoteLegacySudoPasswords(remote: { } } +async function dropMysqlForeignKeys( + remote: { execute: (query: SQL) => Promise }, + table: string, + column: string, +): Promise { + const keys = resultRows<{ name: string }>( + await remote.execute( + sql`SELECT CONSTRAINT_NAME AS name FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ${table} AND COLUMN_NAME = ${column} AND REFERENCED_TABLE_NAME IS NOT NULL`, + ), + ); + for (const key of keys) { + await remote.execute( + sql`ALTER TABLE ${sql.identifier(table)} DROP FOREIGN KEY ${sql.identifier(key.name)}`, + ); + } +} + /** * The columns 2.9.0 left in place without a drizzle migration, so drizzle no * longer knows them. Dropped by hand, only where they still exist. @@ -1961,6 +1978,10 @@ async function dropRemoteRetired( for (const column of columns) { if (!existing.has(`${table}.${column}`)) continue; try { + // Postgres drops a column's foreign keys with it; MySQL refuses. + if (dialect === "mysql") { + await dropMysqlForeignKeys(remote, table, column); + } await remote.execute( sql`ALTER TABLE ${sql.identifier(table)} DROP COLUMN ${sql.identifier(column)}`, ); diff --git a/src/backend/database/db/retired-columns.ts b/src/backend/database/db/retired-columns.ts index 2f431e3ac..b2784205d 100644 --- a/src/backend/database/db/retired-columns.ts +++ b/src/backend/database/db/retired-columns.ts @@ -5,8 +5,11 @@ * * Kept on purpose: columns a move only reads once the owner's data key opens * at a password login (users.totp_*, the rdp, vnc and telnet logins and - * domain), so a user who never signed in to 2.9 still gets them, and - * ssh_data.vault_profile_id, whose foreign key SQLite cannot drop in place. + * domain), so a user who never signed in to 2.9 still gets them. + * + * ssh_data.vault_profile_id goes because its foreign key follows the vault + * plugin's rename of vault_profiles: once the plugin is uninstalled, SQLite + * refuses every write to ssh_data and the other engines refuse the uninstall. */ /** Columns drizzle never knew were removed, dropped by hand on every engine. */ @@ -52,6 +55,7 @@ export const REMOTE_RETIRED_COLUMNS: Record = { "telnet_port", "tunnel_connections", "use_warpgate", + "vault_profile_id", "vnc_port", "web_ui_config", "wol_broadcast_address", diff --git a/src/backend/tests/database/db/vault-profile-foreign-key.test.ts b/src/backend/tests/database/db/vault-profile-foreign-key.test.ts new file mode 100644 index 000000000..7828b0a97 --- /dev/null +++ b/src/backend/tests/database/db/vault-profile-foreign-key.test.ts @@ -0,0 +1,89 @@ +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"; + +// ssh_data.vault_profile_id points at vault_profiles. The vault plugin renames +// that table to p_vault_profiles, which SQLite follows into ssh_data's foreign +// key, so uninstalling the plugin used to leave every host write failing with +// "no such table: main.p_vault_profiles". +describe("ssh_data and the vault plugin's tables", () => { + let dataDir: string; + + beforeEach(() => { + dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-vault-fk-")); + vi.resetModules(); + vi.stubEnv("DATA_DIR", dataDir); + vi.stubEnv("DB_FILE_ENCRYPTION", "false"); + vi.stubEnv("ALLOW_EMPTY_DATA_DIR", "true"); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + function seed(afterAdoption: string): void { + const seed = new Database(":memory:"); + seed.exec( + fs.readFileSync( + new URL("../../fixtures/sqlite-2.8-schema.sql", import.meta.url), + "utf8", + ), + ); + seed.exec(` + CREATE TABLE plugin_migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_id TEXT NOT NULL, + migration_id TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (plugin_id, migration_id), + FOREIGN KEY (plugin_id) REFERENCES plugins (id) ON DELETE CASCADE + ); + INSERT INTO users (id, username, password_hash) + VALUES ('owner', 'alice', 'hash'); + INSERT INTO ssh_data (id, user_id, ip, port, username, auth_type) + VALUES (1, 'owner', '192.0.2.1', 22, 'root', 'password'); + ALTER TABLE vault_profiles RENAME TO p_vault_profiles; + ALTER TABLE vault_tokens RENAME TO p_vault_tokens; + ${afterAdoption} + `); + fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize()); + seed.close(); + } + + async function boot() { + const db = await import("../../../database/db/index.js"); + await db.initializeDatabase(); + return db.getSqlite(); + } + + function writeHosts(sqlite: Database.Database): void { + sqlite + .prepare( + "INSERT INTO ssh_data (user_id, ip, port, username, auth_type) VALUES ('owner', '192.0.2.2', 22, 'root', 'password')", + ) + .run(); + sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run(); + } + + it("repairs a database the plugin was already uninstalled from", async () => { + seed("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;"); + const sqlite = await boot(); + + expect(() => writeHosts(sqlite)).not.toThrow(); + }); + + it("leaves nothing in ssh_data pointing at the plugin", async () => { + seed(""); + const sqlite = await boot(); + + expect( + sqlite.prepare("PRAGMA foreign_key_list(ssh_data)").all(), + ).not.toContainEqual(expect.objectContaining({ table: "p_vault_profiles" })); + sqlite.exec("DROP TABLE p_vault_tokens; DROP TABLE p_vault_profiles;"); + expect(() => writeHosts(sqlite)).not.toThrow(); + }); +});