import { afterEach, describe, expect, it } from 'vitest' import { openDatabase, closeDatabase } from '../src/main/db/database' import { MIGRATIONS } from '../src/main/db/migrations' import { createFolder, createHost } from '../src/main/db/repositories/connections' import { createTempDb, type TempDb } from './helpers' let temp: TempDb | null = null afterEach(() => { temp?.cleanup() temp = null }) const HIGHEST_VERSION = Math.max(...MIGRATIONS.map((m) => m.version)) describe('database', () => { it('applies every migration on first open', () => { temp = createTempDb() expect(temp.db.pragma('user_version', { simple: true })).toBe(HIGHEST_VERSION) }) it('creates the expected tables', () => { temp = createTempDb() const rows = temp.db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") .all() as { name: string }[] const tables = rows.map((r) => r.name) for (const table of [ 'credentials', 'folders', 'history', 'hosts', 'known_hosts', 'master_key_meta', 'nodes', 'port_forwards', 'private_keys', 'settings', ]) { expect(tables).toContain(table) } }) it('is idempotent across reopens', () => { const dir = createTempDb() const { path } = dir closeDatabase(dir.db) // Reopening must not attempt to re-run migrations; a second CREATE TABLE // would throw and the user would lose access to their data on every launch. const reopened = openDatabase(path) expect(reopened.pragma('user_version', { simple: true })).toBe(HIGHEST_VERSION) closeDatabase(reopened) temp = { ...dir, db: reopened } }) it('enables foreign keys, without which cascades silently do nothing', () => { temp = createTempDb() expect(temp.db.pragma('foreign_keys', { simple: true })).toBe(1) }) it('cascades folder deletion to nested folders and their hosts', () => { temp = createTempDb() const parent = createFolder(temp.db, null, 'parent') const child = createFolder(temp.db, parent, 'child') createHost(temp.db, { parentId: child, label: 'web', hostname: 'example.com', port: 22, username: 'root', authType: 'password', keyPath: null, color: null, }) temp.db.prepare('DELETE FROM nodes WHERE id = ?').run(parent) const nodes = temp.db.prepare('SELECT COUNT(*) AS n FROM nodes').get() as { n: number } const hosts = temp.db.prepare('SELECT COUNT(*) AS n FROM hosts').get() as { n: number } const folders = temp.db.prepare('SELECT COUNT(*) AS n FROM folders').get() as { n: number } expect(nodes.n).toBe(0) expect(folders.n).toBe(0) expect(hosts.n).toBe(0) }) it('rejects a credential attached to a folder id', () => { // The point of splitting attributes out of `nodes`: a `kind` discriminator // alone could not stop this, but a foreign key to `hosts(id)` can. temp = createTempDb() const folder = createFolder(temp.db, null, 'not-a-host') expect(() => temp?.db .prepare( `INSERT INTO credentials (host_id, kind, ciphertext, nonce, salt, created_at) VALUES (?, 'password', X'01', X'02', X'03', ?)`, ) .run(folder, Date.now()), ).toThrow() }) it('enforces the single-row constraint on master_key_meta', () => { temp = createTempDb() const insert = (): void => { temp?.db .prepare( 'INSERT INTO master_key_meta (id, ciphertext, backend, created_at) VALUES (?, ?, ?, ?)', ) .run(1, Buffer.from('x'), 'test', Date.now()) } insert() // A second row would mean two master keys, and therefore secrets that // silently become undecryptable depending on which row is read first. expect(insert).toThrow() }) it('rejects malformed port forwards via the CHECK constraint', () => { temp = createTempDb() const now = Date.now() const host = createHost(temp.db, { parentId: null, label: 'web', hostname: 'example.com', port: 22, username: 'root', authType: 'password', keyPath: null, color: null, }) // A dynamic (SOCKS) forward has no fixed destination. expect(() => temp?.db .prepare( `INSERT INTO port_forwards (host_id, type, bind_port, dest_host, dest_port, created_at) VALUES (?, 'dynamic', 1080, 'example.com', 80, ?)`, ) .run(host, now), ).toThrow() // A local forward must have one. expect(() => temp?.db .prepare( `INSERT INTO port_forwards (host_id, type, bind_port, dest_host, dest_port, created_at) VALUES (?, 'local', 8080, NULL, NULL, ?)`, ) .run(host, now), ).toThrow() }) })