Files
nexusterm/tests/connections.test.ts
ylhan 430d78b9f0 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.
2026-09-17 03:12:24 +08:00

312 lines
9.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
createFolder,
createHost,
deleteNode,
getConnectionTree,
getHost,
isDescendantOf,
listHosts,
moveNode,
renameFolder,
updateHost,
} from '../src/main/db/repositories/connections'
import type { HostInput } from '../src/shared/ipc-schemas'
import { createTempDb, type TempDb } from './helpers'
let temp: TempDb
beforeEach(() => {
temp = createTempDb()
})
afterEach(() => {
temp.cleanup()
})
function hostInput(overrides: Partial<HostInput> = {}): HostInput {
return {
parentId: null,
label: 'web-01',
hostname: '10.0.1.5',
port: 22,
username: 'root',
authType: 'password',
keyPath: null,
color: null,
...overrides,
}
}
function addHost(parentId: number | null, label: string): number {
return createHost(temp.db, hostInput({ parentId, label }))
}
/** Labels of a layer, in tree order, for compact assertions. */
function layerNames(nodes: ReturnType<typeof getConnectionTree>): string[] {
return nodes.map((n) => (n.kind === 'folder' ? n.name : n.label))
}
describe('creating nodes', () => {
it('creates top-level folders and nests them', () => {
const prod = createFolder(temp.db, null, 'prod')
const web = createFolder(temp.db, prod, 'web')
const tree = getConnectionTree(temp.db)
expect(layerNames(tree)).toEqual(['prod'])
const root = tree[0]
expect(root.kind).toBe('folder')
if (root.kind === 'folder') {
expect(root.id).toBe(prod)
expect(layerNames(root.children)).toEqual(['web'])
expect(root.children[0].id).toBe(web)
}
})
it('gives folders and hosts ids from one shared sequence', () => {
// The point of the interleaved tree: a folder and a host can never collide
// on an id, so a node id is a complete identity.
const folder = createFolder(temp.db, null, 'f')
const host = addHost(null, 'h')
expect(folder).not.toBe(host)
expect(new Set([folder, host]).size).toBe(2)
})
it('stores host attributes', () => {
const id = createHost(
temp.db,
hostInput({ label: 'db', hostname: '10.0.2.10', port: 5432, username: 'postgres' }),
)
const host = getHost(temp.db, id)
expect(host).toMatchObject({
label: 'db',
hostname: '10.0.2.10',
port: 5432,
username: 'postgres',
parentId: null,
})
})
it('appends new nodes to the end of their layer', () => {
const first = addHost(null, 'first')
const second = addHost(null, 'second')
expect(layerNames(getConnectionTree(temp.db))).toEqual(['first', 'second'])
expect(getHost(temp.db, second)?.sortOrder).toBeGreaterThan(
getHost(temp.db, first)?.sortOrder ?? 0,
)
})
})
describe('interleaving folders and hosts', () => {
it('renders them in one order rather than grouping by kind', () => {
// The whole point of the schema change. Before it, every folder came before
// every host, because the two sort_order sequences were not comparable.
const folder = createFolder(temp.db, null, 'folder')
const host = addHost(null, 'host')
// Drop the host ahead of the folder.
moveNode(temp.db, host, null, null, folder)
expect(layerNames(getConnectionTree(temp.db))).toEqual(['host', 'folder'])
})
it('allows a host between two folders', () => {
// Not expressible at all before the interleaving — the two sequences had no
// common scale.
const a = createFolder(temp.db, null, 'a')
const b = createFolder(temp.db, null, 'b')
const host = addHost(null, 'middle')
moveNode(temp.db, host, null, a, b)
expect(layerNames(getConnectionTree(temp.db))).toEqual(['a', 'middle', 'b'])
})
it('allows a folder between two hosts', () => {
const h1 = addHost(null, 'h1')
const h2 = addHost(null, 'h2')
const folder = createFolder(temp.db, null, 'middle')
moveNode(temp.db, folder, null, h1, h2)
expect(layerNames(getConnectionTree(temp.db))).toEqual(['h1', 'middle', 'h2'])
})
it('keeps a layer strictly ordered through many midpoint inserts', () => {
const anchorLeft = createFolder(temp.db, null, 'left')
const anchorRight = createFolder(temp.db, null, 'right')
// Each insert goes into the gap immediately right of the previous one, so
// the interval halves every time — the precision-exhaustion scenario the
// REAL ordering trades for never renumbering siblings.
let previous = anchorLeft
for (let i = 0; i < 30; i++) {
const id = addHost(null, `mid-${i}`)
moveNode(temp.db, id, null, previous, anchorRight)
previous = id
}
const orders = getConnectionTree(temp.db).map((n) => n.sortOrder)
expect(orders).toHaveLength(32)
expect(new Set(orders).size).toBe(orders.length)
})
})
describe('moving nodes', () => {
it('reparents a host into a folder', () => {
const folder = createFolder(temp.db, null, 'group')
const host = addHost(null, 'roamer')
moveNode(temp.db, host, folder)
const tree = getConnectionTree(temp.db)
expect(tree).toHaveLength(1)
if (tree[0].kind === 'folder') {
expect(layerNames(tree[0].children)).toEqual(['roamer'])
}
})
it('moves a node back out to the root layer', () => {
const folder = createFolder(temp.db, null, 'group')
const host = createHost(temp.db, hostInput({ parentId: folder, label: 'inside' }))
moveNode(temp.db, host, null)
expect(layerNames(getConnectionTree(temp.db))).toEqual(['group', 'inside'])
})
it('refuses to make a node its own parent', () => {
const folder = createFolder(temp.db, null, 'f')
expect(() => moveNode(temp.db, folder, folder)).toThrow(/own parent/)
})
it('refuses to move a folder into its own descendant', () => {
// Without this the subtree detaches from every root and disappears from the
// UI while its rows remain.
const a = createFolder(temp.db, null, 'a')
const b = createFolder(temp.db, a, 'b')
const c = createFolder(temp.db, b, 'c')
expect(() => moveNode(temp.db, a, b)).toThrow(/descendant/)
expect(() => moveNode(temp.db, a, c)).toThrow(/descendant/)
})
it('identifies descendants correctly', () => {
const a = createFolder(temp.db, null, 'a')
const b = createFolder(temp.db, a, 'b')
const unrelated = createFolder(temp.db, null, 'unrelated')
expect(isDescendantOf(temp.db, b, a)).toBe(true)
expect(isDescendantOf(temp.db, a, b)).toBe(false)
expect(isDescendantOf(temp.db, unrelated, a)).toBe(false)
})
})
describe('updating and deleting', () => {
it('renames a folder', () => {
const id = createFolder(temp.db, null, 'old')
renameFolder(temp.db, id, 'new')
expect(layerNames(getConnectionTree(temp.db))).toEqual(['new'])
})
it('updates a host without moving it', () => {
const folder = createFolder(temp.db, null, 'group')
const host = createHost(temp.db, hostInput({ parentId: folder, label: 'before' }))
const sortBefore = getHost(temp.db, host)?.sortOrder
updateHost(temp.db, host, hostInput({ parentId: folder, label: 'after', port: 2222 }))
const record = getHost(temp.db, host)
expect(record?.label).toBe('after')
expect(record?.port).toBe(2222)
// An edit must not disturb the node's position in the tree.
expect(record?.sortOrder).toBe(sortBefore)
})
it('deletes a folder along with everything beneath it', () => {
const a = createFolder(temp.db, null, 'a')
const b = createFolder(temp.db, a, 'b')
createHost(temp.db, hostInput({ parentId: b, label: 'deep' }))
addHost(null, 'unrelated')
deleteNode(temp.db, a)
expect(layerNames(getConnectionTree(temp.db))).toEqual(['unrelated'])
expect(listHosts(temp.db)).toHaveLength(1)
})
it('deletes a host on its own', () => {
const folder = createFolder(temp.db, null, 'group')
const host = createHost(temp.db, hostInput({ parentId: folder, label: 'gone' }))
deleteNode(temp.db, host)
const tree = getConnectionTree(temp.db)
expect(tree).toHaveLength(1)
if (tree[0].kind === 'folder') {
expect(tree[0].children).toHaveLength(0)
}
})
})
/**
* SQLite reports an UPDATE or DELETE that matched no rows as a successful
* operation. Left unchecked, that turns every one of these into a silent
* no-op — and the UI, which trusts the promise, reports success for a change
* that never happened.
*/
describe('guards against missing rows', () => {
const MISSING = 999_999
it('renameFolder throws for an unknown id', () => {
expect(() => renameFolder(temp.db, MISSING, 'x')).toThrow(/not found/)
})
it('moveNode throws for an unknown id', () => {
expect(() => moveNode(temp.db, MISSING, null)).toThrow(/not found/)
})
it('deleteNode throws for an unknown id', () => {
expect(() => deleteNode(temp.db, MISSING)).toThrow(/not found/)
})
it('updateHost throws for an unknown id', () => {
expect(() => updateHost(temp.db, MISSING, hostInput())).toThrow(/not found/)
})
it('updateHost throws when handed a folder id', () => {
// With one id space, a folder id is a syntactically valid node id — the
// separate `hosts` table is what makes it fail rather than silently
// editing nothing.
const folder = createFolder(temp.db, null, 'not-a-host')
expect(() => updateHost(temp.db, folder, hostInput())).toThrow(/not found/)
})
})
describe('reading', () => {
it('returns an empty tree for an empty inventory', () => {
expect(getConnectionTree(temp.db)).toEqual([])
})
it('returns null for a folder id passed to getHost', () => {
const folder = createFolder(temp.db, null, 'f')
expect(getHost(temp.db, folder)).toBeNull()
})
it('lists every host with its parent', () => {
const folder = createFolder(temp.db, null, 'group')
createHost(temp.db, hostInput({ parentId: folder, label: 'nested' }))
addHost(null, 'rooted')
const hosts = listHosts(temp.db)
expect(hosts).toHaveLength(2)
expect(hosts.find((h) => h.label === 'nested')?.parentId).toBe(folder)
expect(hosts.find((h) => h.label === 'rooted')?.parentId).toBeNull()
})
})