import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import Database from 'better-sqlite3' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import init001 from '../src/main/db/migrations/001_init.sql?raw' import { migrate } from '../src/main/db/database' import { getConnectionTree, getHost } from '../src/main/db/repositories/connections' /** * Migration 002 rebuilds the tree into an interleaved structure, remapping ids * as it goes. It is the most intricate SQL in the project and the failure mode * is silent data loss, so it is tested against a database built in the *old* * shape and filled with old-format rows — not against a fresh one, where every * code path would be trivially empty. */ let db: Database.Database let dir: string beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'nexusterm-migration-')) db = new Database(join(dir, 'v1.db')) db.pragma('foreign_keys = ON') // Build the pre-migration schema exactly as it shipped, then declare it // version 1 so `migrate` sees migration 2 as pending. db.exec(init001) db.pragma('user_version = 1') }) afterEach(() => { db.close() rmSync(dir, { recursive: true, force: true }) }) function seedFolder(parentId: number | null, name: string, sortOrder: number): number { const now = Date.now() return Number( db .prepare( 'INSERT INTO folders (parent_id, name, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', ) .run(parentId, name, sortOrder, now, now).lastInsertRowid, ) } function seedHost( folderId: number | null, label: string, sortOrder: number, overrides: { hostname?: string; port?: number } = {}, ): number { const now = Date.now() return Number( db .prepare( `INSERT INTO hosts (folder_id, label, hostname, port, username, auth_type, key_path, color, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, 'root', 'password', NULL, NULL, ?, ?, ?)`, ) .run(folderId, label, overrides.hostname ?? '10.0.1.5', overrides.port ?? 22, sortOrder, now, now) .lastInsertRowid, ) } /** A v1 database with two root folders, a nested host set, and a root host. */ function seedV1(): { prod: number; staging: number; web: number; database: number; rooted: number } { const prod = seedFolder(null, 'prod', 1024) const staging = seedFolder(null, 'staging', 2048) const web = seedHost(prod, 'web-01', 1024, { hostname: '10.0.1.5' }) const database = seedHost(prod, 'db-01', 2048, { hostname: '10.0.2.10', port: 5432 }) const rooted = seedHost(null, 'rooted', 1024, { hostname: '203.0.113.7' }) return { prod, staging, web, database, rooted } } function names(nodes: ReturnType): string[] { return nodes.map((n) => (n.kind === 'folder' ? n.name : n.label)) } describe('migration 002: interleaving folders and hosts', () => { it('advances the schema version', () => { seedV1() migrate(db) expect(db.pragma('user_version', { simple: true })).toBe(2) }) it('leaves no dangling references', () => { seedV1() migrate(db) // The runner already asserts this and rolls back on failure; checking again // here documents that the guarantee is deliberate. expect(db.pragma('foreign_key_check')).toEqual([]) }) it('preserves every folder and host', () => { seedV1() migrate(db) const folders = db.prepare('SELECT COUNT(*) AS n FROM folders').get() as { n: number } const hosts = db.prepare('SELECT COUNT(*) AS n FROM hosts').get() as { n: number } const nodes = db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number } expect(folders.n).toBe(2) expect(hosts.n).toBe(3) expect(nodes.n).toBe(5) }) it('preserves the nesting', () => { const ids = seedV1() migrate(db) const tree = getConnectionTree(db) expect(names(tree)).toEqual(['prod', 'staging', 'rooted']) const prod = tree.find((n) => n.kind === 'folder' && n.name === 'prod') expect(prod?.kind).toBe('folder') if (prod?.kind === 'folder') { expect(names(prod.children)).toEqual(['web-01', 'db-01']) expect(prod.children.map((c) => c.id)).not.toContain(ids.web) } }) it('reproduces the order the old tree displayed, folders first', () => { // The two old sequences collided head-on — folder 1024 against host 1024. // Renumbering each layer resolves the ties into the order the UI already // showed, so nothing visibly jumps after upgrading. seedV1() migrate(db) expect(names(getConnectionTree(db))).toEqual(['prod', 'staging', 'rooted']) }) it('preserves the relative order of hosts within a layer', () => { // A real inventory contains hosts whose sort_order is a midpoint, left // there by an earlier drag — the seeded tree this migration first ran // against had exactly that shape. The renumbering must carry the order // across, not merely the membership. const prod = seedFolder(null, 'prod', 1024) seedHost(prod, 'first', 1024) seedHost(prod, 'dropped-in', 1536) seedHost(prod, 'last', 2048) migrate(db) const prodNode = getConnectionTree(db).find((n) => n.kind === 'folder') expect(prodNode?.kind).toBe('folder') if (prodNode?.kind === 'folder') { expect(names(prodNode.children)).toEqual(['first', 'dropped-in', 'last']) } }) it('preserves host attributes through the id remap', () => { const ids = seedV1() migrate(db) const all = db.prepare('SELECT id, label, hostname, port FROM hosts').all() as { id: number label: string hostname: string port: number }[] const db01 = all.find((h) => h.label === 'db-01') expect(db01).toMatchObject({ hostname: '10.0.2.10', port: 5432 }) // Ids are reassigned by the rebuild, so the old id must not be assumed. expect(db01?.id).not.toBe(ids.database) }) it('remaps credentials onto the rebuilt host rows', () => { // The tables are empty in practice at this point, but a migration that // silently orphans saved credentials is exactly the bug that surfaces // months later — so it carries the data across. const ids = seedV1() db.prepare( `INSERT INTO credentials (host_id, kind, ciphertext, nonce, salt, created_at) VALUES (?, 'password', X'01', X'02', X'03', ?)`, ).run(ids.web, Date.now()) migrate(db) const row = db .prepare('SELECT c.host_id, h.label FROM credentials c JOIN hosts h ON h.id = c.host_id') .get() as { host_id: number; label: string } | undefined expect(row?.label).toBe('web-01') expect(row?.host_id).not.toBe(ids.web) }) it('remaps port forwards onto the rebuilt host rows', () => { const ids = seedV1() db.prepare( `INSERT INTO port_forwards (host_id, type, bind_host, bind_port, dest_host, dest_port, auto_start, created_at) VALUES (?, 'local', '127.0.0.1', 8080, 'localhost', 80, 0, ?)`, ).run(ids.database, Date.now()) migrate(db) const row = db .prepare('SELECT f.host_id, h.label FROM port_forwards f JOIN hosts h ON h.id = f.host_id') .get() as { host_id: number; label: string } | undefined expect(row?.label).toBe('db-01') }) it('removes the temporary legacy_id column', () => { seedV1() migrate(db) const columns = db.pragma('table_info(nodes)') as { name: string }[] expect(columns.map((c) => c.name)).not.toContain('legacy_id') }) it('leaves foreign keys enforced again afterwards', () => { seedV1() migrate(db) expect(db.pragma('foreign_keys', { simple: true })).toBe(1) // And the enforcement is real, not just the flag. const bad = db.prepare('INSERT INTO hosts (id, label, hostname, port, username, auth_type) VALUES (?, ?, ?, ?, ?, ?)') expect(() => bad.run(999_999, 'ghost', 'h', 22, 'u', 'password')).toThrow() }) }) describe('migration 002 on an empty database', () => { it('succeeds without any rows to carry across', () => { migrate(db) expect(db.pragma('user_version', { simple: true })).toBe(2) expect(getConnectionTree(db)).toEqual([]) }) }) describe('migration 002 is idempotent', () => { it('does nothing on a second run', () => { seedV1() migrate(db) const before = getHost(db, (db.prepare("SELECT id FROM hosts WHERE label = 'web-01'").get() as { id: number }).id) const applied = migrate(db) expect(applied).toBe(0) const after = getHost(db, (db.prepare("SELECT id FROM hosts WHERE label = 'web-01'").get() as { id: number }).id) expect(after).toEqual(before) }) })