NexusTerm: an SSH session manager for Linux
Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
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<typeof getConnectionTree>): 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user