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:
ylhan
2026-09-17 03:12:24 +08:00
commit 430d78b9f0
88 changed files with 19793 additions and 0 deletions
+311
View File
@@ -0,0 +1,311 @@
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()
})
})
+149
View File
@@ -0,0 +1,149 @@
import { readFileSync } from 'node:fs'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { Db } from '../src/main/db/database'
import {
deleteCredential,
getCredential,
hasCredential,
setCredential,
} from '../src/main/db/repositories/credentials'
import { createHost } from '../src/main/db/repositories/connections'
import { Vault } from '../src/main/security/vault'
import { createTempDb, fakeKeyring, insecureKeyring, type TempDb } from './helpers'
let temp: TempDb
let vault: Vault
let hostId: number
beforeEach(() => {
temp = createTempDb()
vault = new Vault(temp.db, fakeKeyring())
vault.initialize()
hostId = createHost(temp.db, {
parentId: null,
label: 'web-01',
hostname: '10.0.1.5',
port: 22,
username: 'root',
authType: 'password',
keyPath: null,
color: null,
})
})
afterEach(() => {
temp.cleanup()
})
describe('credentials repository', () => {
it('round-trips a password', () => {
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('hunter2')
})
it('returns null when nothing is stored', () => {
expect(getCredential(temp.db, vault, hostId, 'password')).toBeNull()
expect(hasCredential(temp.db, hostId, 'password')).toBe(false)
})
it('replaces rather than duplicates on a second write', () => {
setCredential(temp.db, vault, hostId, 'password', 'first')
setCredential(temp.db, vault, hostId, 'password', 'second')
const count = temp.db
.prepare('SELECT COUNT(*) AS n FROM credentials WHERE host_id = ?')
.get(hostId) as { n: number }
expect(count.n).toBe(1)
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('second')
})
it('keeps password and passphrase independent', () => {
setCredential(temp.db, vault, hostId, 'password', 'login-pw')
setCredential(temp.db, vault, hostId, 'passphrase', 'key-phrase')
expect(getCredential(temp.db, vault, hostId, 'password')).toBe('login-pw')
expect(getCredential(temp.db, vault, hostId, 'passphrase')).toBe('key-phrase')
})
it('deletes a credential and leaves no row', () => {
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
deleteCredential(temp.db, hostId, 'password')
expect(hasCredential(temp.db, hostId, 'password')).toBe(false)
expect(getCredential(temp.db, vault, hostId, 'password')).toBeNull()
})
it('removes credentials when the host is deleted', () => {
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
temp.db.prepare('DELETE FROM hosts WHERE id = ?').run(hostId)
const count = temp.db.prepare('SELECT COUNT(*) AS n FROM credentials').get() as { n: number }
expect(count.n).toBe(0)
})
it('throws instead of storing plaintext when the vault is locked', () => {
vault.lock()
// A caller that believes it saved a password must not be told otherwise.
expect(() => setCredential(temp.db, vault, hostId, 'password', 'hunter2')).toThrow()
})
it('stores no plaintext anywhere in the database file', () => {
// The assertion that matters. Checking the column would only prove the
// column is bytes; reading the file proves the secret did not land in a WAL
// frame, an index, or anywhere else SQLite decided to put it.
const secret = 'correct-horse-battery-staple'
setCredential(temp.db, vault, hostId, 'password', secret)
// Fold the WAL back into the main file so a single read is conclusive.
temp.db.pragma('wal_checkpoint(TRUNCATE)')
const bytes = readFileSync(temp.path)
expect(bytes.includes(Buffer.from(secret, 'utf8'))).toBe(false)
// Guard against the assertion passing for the wrong reason — e.g. the write
// silently not happening at all.
expect(bytes.includes(Buffer.from('credentials', 'utf8'))).toBe(true)
})
it('stores no plaintext for non-ASCII secrets either', () => {
const secret = '密码-🔐-pass'
setCredential(temp.db, vault, hostId, 'password', secret)
temp.db.pragma('wal_checkpoint(TRUNCATE)')
const bytes = readFileSync(temp.path)
expect(bytes.includes(Buffer.from(secret, 'utf8'))).toBe(false)
})
it('refuses to read a credential written under a different master key', () => {
setCredential(temp.db, vault, hostId, 'password', 'hunter2')
// Simulates the keyring having changed: a new vault generates a new key,
// and the old ciphertext must fail authentication rather than decrypt to
// garbage that gets sent to a server as a password.
const otherVault = new Vault(temp.db, insecureKeyring())
expect(otherVault.isUnlocked).toBe(false)
expect(() => otherVault.initialize()).not.toThrow()
})
})
describe('credentials with an insecure keyring', () => {
it('cannot be written at all', () => {
const lockedDb: Db = createTempDb().db
const insecureVault = new Vault(lockedDb, insecureKeyring())
insecureVault.initialize()
const id = createHost(lockedDb, {
parentId: null,
label: 'web-02',
hostname: '10.0.1.6',
port: 22,
username: 'root',
authType: 'password',
keyPath: null,
color: null,
})
expect(() => setCredential(lockedDb, insecureVault, id, 'password', 'hunter2')).toThrow()
})
})
+157
View File
@@ -0,0 +1,157 @@
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()
})
})
+193
View File
@@ -0,0 +1,193 @@
import { existsSync } from 'node:fs'
import { userInfo } from 'node:os'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import type { Client } from 'ssh2'
import { dial, type DialResult } from '../src/main/session/dial'
import { findKnownHost, recordKnownHost } from '../src/main/db/repositories/knownHosts'
import type { HostRecord } from '../src/main/db/repositories/connections'
import { createTempDb, type TempDb } from './helpers'
/**
* Integration tests against a real OpenSSH server, not a mock and not ssh2's
* own server implementation. The trust-on-first-use state machine is the most
* security-sensitive logic in the app, and testing it against itself would
* prove only that it is self-consistent.
*
* Start the server first:
*
* mkdir -p /tmp/testsshd && ssh-keygen -t ed25519 -f /tmp/testsshd/host_key -N '' -q
* ssh-keygen -t ed25519 -f /tmp/testsshd/user_key -N '' -q
* cp /tmp/testsshd/user_key.pub /tmp/testsshd/authorized_keys
* printf 'Port 2222\nListenAddress 127.0.0.1\nHostKey /tmp/testsshd/host_key\n' \
* 'AuthorizedKeysFile /tmp/testsshd/authorized_keys\nUsePAM no\nStrictModes no\n' \
* > /tmp/testsshd/sshd_config
* sshd -f /tmp/testsshd/sshd_config -D -e &
*
* The suite skips when that server is absent rather than failing, so the unit
* tests stay runnable anywhere.
*/
const TEST_PORT = 2222
const TEST_USER_KEY = '/tmp/testsshd/user_key'
const EXPECTED_FINGERPRINT = 'SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo'
const prerequisitesPresent = existsSync(TEST_USER_KEY)
let temp: TempDb
const openClients: Client[] = []
beforeEach(() => {
temp = createTempDb()
})
afterEach(() => {
for (const client of openClients.splice(0)) {
try {
client.end()
} catch {
// Already gone.
}
}
temp.cleanup()
})
function testHost(overrides: Partial<HostRecord> = {}): HostRecord {
return {
id: 1,
parentId: null,
label: 'test-server',
hostname: '127.0.0.1',
port: TEST_PORT,
username: userInfo().username,
authType: 'key',
keyPath: TEST_USER_KEY,
color: null,
sortOrder: 1024,
...overrides,
}
}
async function attempt(overrides: Partial<HostRecord> = {}, trustFingerprint: string | null = null, password: string | null = null): Promise<DialResult> {
const result = await dial({
db: temp.db,
host: testHost(overrides),
password,
passphrase: null,
trustFingerprint,
readyTimeoutMs: 8_000,
})
if (result.ok) openClients.push(result.client)
return result
}
describe.runIf(prerequisitesPresent)('dial — trust on first use', () => {
it('refuses an unrecognised host key rather than connecting', async () => {
const result = await attempt()
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.failure.reason).toBe('unknown-host-key')
if (result.failure.reason !== 'unknown-host-key') return
// The fingerprint shown must be the real one, or the user cannot check it.
expect(result.failure.fingerprint).toBe(EXPECTED_FINGERPRINT)
expect(result.failure.keyType).toBe('ssh-ed25519')
})
it('records nothing when the key was not trusted', async () => {
// A refused connection must leave no trace, or the next attempt would be
// silently accepted without the user ever seeing the fingerprint.
await attempt()
expect(findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')).toBeNull()
})
it('connects when the presented fingerprint is trusted', async () => {
const result = await attempt({}, EXPECTED_FINGERPRINT)
expect(result.ok).toBe(true)
if (result.ok) expect(result.fingerprint).toBe(EXPECTED_FINGERPRINT)
})
it('remembers the key once trusted', async () => {
await attempt({}, EXPECTED_FINGERPRINT)
const known = findKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519')
expect(known?.fingerprint).toBe(EXPECTED_FINGERPRINT)
})
it('connects without a prompt once the key is recorded', async () => {
await attempt({}, EXPECTED_FINGERPRINT)
const second = await attempt()
expect(second.ok).toBe(true)
})
it('refuses a trust token that does not match the key presented', async () => {
// The user accepted one fingerprint and a different key turned up — the
// exact shape of an interception. It must not be waved through.
const result = await attempt({}, 'SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.failure.reason).toBe('host-key-mismatch')
})
it('refuses a changed key even though an earlier one was trusted', async () => {
recordKnownHost(temp.db, '127.0.0.1', TEST_PORT, 'ssh-ed25519', 'SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
const result = await attempt()
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.failure.reason).toBe('host-key-mismatch')
if (result.failure.reason !== 'host-key-mismatch') return
expect(result.failure.expected).toBe('SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB')
expect(result.failure.actual).toBe(EXPECTED_FINGERPRINT)
})
it('reports a readable failure for an unreadable key file', async () => {
const result = await attempt({ keyPath: '/nonexistent/id_ed25519' })
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.failure.reason).toBe('error')
if (result.failure.reason === 'error') {
expect(result.failure.message).toMatch(/private key/)
}
})
it('verifies the host key before attempting authentication', async () => {
// Ordering matters and is not obvious: an untrusted key stops the attempt
// before a password is ever offered to the server. Getting this backwards
// would leak credentials to whatever is on the other end.
const result = await attempt({ authType: 'password' }, null, 'definitely-not-the-password')
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.failure.reason).toBe('unknown-host-key')
})
it('reports auth failure distinctly from a transport error', async () => {
// The distinction matters: auth failure means "offer a password prompt",
// a transport error does not. The key has to be trusted first, or this
// never reaches authentication.
const result = await attempt(
{ authType: 'password' },
EXPECTED_FINGERPRINT,
'definitely-not-the-password',
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.failure.reason).toBe('auth-failed')
})
})
describe.runIf(!prerequisitesPresent)('dial — integration suite skipped', () => {
it('reports why', () => {
// Deliberately visible: a silently absent integration suite reads as
// passing coverage when it is nothing of the sort.
expect(prerequisitesPresent).toBe(false)
})
})
+89
View File
@@ -0,0 +1,89 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { openDatabase, closeDatabase, type Db } from '../src/main/db/database'
import type { Keyring } from '../src/main/security/keyring'
import type { SafeStorageStatus } from '../src/shared/ipc-schemas'
export interface TempDb {
db: Db
path: string
cleanup: () => void
}
/**
* A real file-backed database in a temp directory, not `:memory:`.
*
* Tests that assert a secret never reaches disk need an actual file to inspect,
* and WAL mode does not apply to in-memory databases — so the in-memory
* shortcut would quietly skip the behaviour most worth checking.
*/
export function createTempDb(): TempDb {
const dir = mkdtempSync(join(tmpdir(), 'nexusterm-test-'))
const path = join(dir, 'test.db')
const db = openDatabase(path)
return {
db,
path,
cleanup: () => {
closeDatabase(db)
rmSync(dir, { recursive: true, force: true })
},
}
}
/**
* A stand-in keyring.
*
* `wrap` is a reversible transform rather than real encryption, and that is
* deliberate: it is not trying to be secure, it is trying to be *observable*.
* The point is that the bytes handed to SQLite are recognisably different from
* the plaintext, so tests can prove the vault actually called through to the
* keyring instead of passing a secret straight to storage.
*/
export function fakeKeyring(
status: SafeStorageStatus = {
available: true,
backend: 'test-keyring',
secure: true,
reason: 'ok',
},
): Keyring {
const PREFIX = 'WRAPPED:'
return {
status: () => status,
wrap: (plaintext) => Buffer.from(PREFIX + Buffer.from(plaintext, 'utf8').toString('hex')),
unwrap: (sealed) => {
const text = sealed.toString('utf8')
if (!text.startsWith(PREFIX)) {
throw new Error('blob was not wrapped by this keyring')
}
return Buffer.from(text.slice(PREFIX.length), 'hex').toString('utf8')
},
}
}
/**
* A keyring that reports the Linux `basic_text` condition: the API claims
* encryption is available, but the backend uses a hardcoded key. Both `wrap`
* and `unwrap` throw, so any code path that fails to check `secure` first will
* fail loudly rather than storing something weak.
*/
export function insecureKeyring(): Keyring {
const refuse = (): never => {
throw new Error('insecure keyring must not be used')
}
return {
status: () => ({
available: true,
backend: 'basic_text',
secure: false,
reason: 'basic-text',
}),
wrap: refuse,
unwrap: refuse,
}
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { fingerprintOf, keyTypeOf } from '../src/main/session/hostKey'
/**
* A real ed25519 host key, with the fingerprint `ssh-keygen -lf` prints for it:
*
* $ ssh-keygen -lf host_key.pub
* 256 SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo (ED25519)
*
* The expected value is pinned rather than computed so the test proves the
* *format*, not merely that the function is self-consistent. A fingerprint that
* does not match what ssh-keygen shows is one the user cannot check against
* anything, which defeats the point of showing it at all.
*/
const ED25519_PUBLIC_KEY =
'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKzewpDb5LqzDdJeN9GQVNBzIVE3ZPZ9cGWFpurXr1o8 test@host'
const ED25519_FINGERPRINT = 'SHA256:ASmbFHszdZLp5YkSiJnqmGfAhpZKOZwQJnk3rLUo4Xo'
function blobOf(publicKeyLine: string): Buffer {
const base64 = publicKeyLine.trim().split(/\s+/)[1]
if (!base64) throw new Error('not a public key line')
return Buffer.from(base64, 'base64')
}
describe('keyTypeOf', () => {
it('reads the algorithm name out of the wire format', () => {
expect(keyTypeOf(blobOf(ED25519_PUBLIC_KEY))).toBe('ssh-ed25519')
})
it('returns unknown rather than throwing on a malformed blob', () => {
// A short or bogus blob must not take down a connection attempt — it just
// cannot be identified, and the fingerprint check will still be shown.
expect(keyTypeOf(Buffer.alloc(0))).toBe('unknown')
expect(keyTypeOf(Buffer.from([0, 0]))).toBe('unknown')
// Declares a 4GB name.
expect(keyTypeOf(Buffer.from([0xff, 0xff, 0xff, 0xff, 1, 2, 3]))).toBe('unknown')
})
})
describe('fingerprintOf', () => {
it('matches the OpenSSH SHA256 fingerprint exactly', () => {
expect(fingerprintOf(blobOf(ED25519_PUBLIC_KEY))).toBe(ED25519_FINGERPRINT)
})
it('strips base64 padding, as OpenSSH does', () => {
expect(fingerprintOf(blobOf(ED25519_PUBLIC_KEY))).not.toContain('=')
})
it('is stable for the same key', () => {
const blob = blobOf(ED25519_PUBLIC_KEY)
expect(fingerprintOf(blob)).toBe(fingerprintOf(blob))
})
it('differs for a different key', () => {
const a = blobOf(ED25519_PUBLIC_KEY)
const b = Buffer.from(a)
b[b.length - 1] ^= 0x01
expect(fingerprintOf(b)).not.toBe(fingerprintOf(a))
})
it('is a SHA256 digest, not a truncated or padded one', () => {
// 32 bytes base64-encodes to 44 characters, 43 once padding is stripped.
const digest = fingerprintOf(blobOf(ED25519_PUBLIC_KEY)).replace('SHA256:', '')
expect(digest).toHaveLength(43)
})
})
+249
View File
@@ -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)
})
})
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest'
import {
MIN_GAP,
SORT_GAP,
needsRebalance,
rebalancedOrders,
sortOrderBetween,
} from '../src/main/db/repositories/ordering'
describe('ordering', () => {
it('starts a fresh layer at the base gap', () => {
expect(sortOrderBetween(null, null)).toBe(SORT_GAP)
})
it('appends past the last item', () => {
expect(sortOrderBetween(1024, null)).toBe(1024 + SORT_GAP)
})
it('prepends before the first item', () => {
expect(sortOrderBetween(null, 1024)).toBe(1024 - SORT_GAP / 2)
})
it('lands strictly between two neighbours', () => {
const mid = sortOrderBetween(1024, 2048)
expect(mid).toBeGreaterThan(1024)
expect(mid).toBeLessThan(2048)
})
it('does not flag a healthy gap as needing rebalancing', () => {
expect(needsRebalance(1024, 2048)).toBe(false)
// A missing neighbour means "open end", not "collapsed".
expect(needsRebalance(null, 1024)).toBe(false)
expect(needsRebalance(1024, null)).toBe(false)
})
it('flags a gap too small to split again', () => {
expect(needsRebalance(1, 1 + MIN_GAP / 2)).toBe(true)
expect(needsRebalance(1, 1 + MIN_GAP * 2)).toBe(false)
})
it('exhausts precision after a bounded number of splits, then says so', () => {
// The documented cost of fractional ordering. Repeatedly splitting the same
// gap halves the interval each time, and a double cannot halve forever.
// This test pins down that the limit is reached and, crucially, that it is
// *detected* — an undetected collapse would silently make item order
// arbitrary rather than raising an error.
let current = 1024
const next = 2048
let splits = 0
while (!needsRebalance(current, next) && splits < 500) {
current = sortOrderBetween(current, next)
splits++
}
expect(splits).toBeLessThan(500)
expect(needsRebalance(current, next)).toBe(true)
// Comfortably beyond any human drag-and-drop session, which is why the
// tradeoff is worth taking.
expect(splits).toBeGreaterThan(20)
})
it('produces evenly spaced orders when rebalancing', () => {
const orders = rebalancedOrders(4)
expect(orders).toEqual([1024, 2048, 3072, 4096])
const gaps = orders.slice(1).map((v, i) => v - orders[i])
expect(new Set(gaps).size).toBe(1)
})
})
+75
View File
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { RECONNECT, reconnectDelay } from '../src/shared/session-protocol'
/** The nominal delay before jitter, for a given 1-based attempt. */
function nominal(attempt: number): number {
return Math.min(
RECONNECT.maxDelayMs,
RECONNECT.initialDelayMs * 2 ** Math.max(0, attempt - 1),
)
}
describe('reconnectDelay', () => {
it('stays within the jitter band on every attempt', () => {
for (let attempt = 1; attempt <= RECONNECT.maxAttempts + 5; attempt++) {
const delay = reconnectDelay(attempt)
const base = nominal(attempt)
expect(delay).toBeGreaterThanOrEqual(Math.round(base * (1 - RECONNECT.jitter)) - 1)
expect(delay).toBeLessThanOrEqual(Math.round(base * (1 + RECONNECT.jitter)) + 1)
}
})
it('starts short enough that a blip is barely noticed', () => {
// A first retry the user has to wait through feels like a broken app.
expect(reconnectDelay(1)).toBeLessThanOrEqual(1_500)
})
it('climbs, and does not exceed the ceiling', () => {
expect(nominal(2)).toBeGreaterThan(nominal(1))
for (let attempt = 1; attempt <= 30; attempt++) {
expect(reconnectDelay(attempt)).toBeLessThanOrEqual(
Math.round(RECONNECT.maxDelayMs * (1 + RECONNECT.jitter)) + 1,
)
}
})
it('is never zero or negative', () => {
// A zero delay would turn the retry loop into a busy loop against a server
// that is already having a bad day.
for (let attempt = 1; attempt <= 30; attempt++) {
expect(reconnectDelay(attempt)).toBeGreaterThan(0)
}
})
it('varies between calls, so sessions that drop together do not retry in lockstep', () => {
// This is the whole reason for the jitter: a network failure takes every
// session down at once, and identical delays would have them all hit the
// server on the same tick.
const samples = new Set(Array.from({ length: 40 }, () => reconnectDelay(4)))
expect(samples.size).toBeGreaterThan(1)
})
it('handles a non-positive attempt without producing nonsense', () => {
expect(reconnectDelay(0)).toBeGreaterThan(0)
expect(reconnectDelay(-3)).toBeGreaterThan(0)
})
})
describe('RECONNECT limits', () => {
it('gives up eventually rather than retrying forever', () => {
expect(RECONNECT.maxAttempts).toBeGreaterThan(0)
expect(RECONNECT.maxAttempts).toBeLessThanOrEqual(50)
})
it('spans roughly five minutes of trying', () => {
// Long enough to ride out a reboot or a flaky link, short enough that a
// session pointed at a decommissioned host stops rather than living
// forever in a retry loop.
let total = 0
for (let attempt = 1; attempt <= RECONNECT.maxAttempts; attempt++) {
total += nominal(attempt)
}
expect(total).toBeGreaterThan(60_000)
expect(total).toBeLessThan(15 * 60_000)
})
})
+82
View File
@@ -0,0 +1,82 @@
import { randomBytes } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { deriveKey, seal, unseal, SECRET_CONTEXT } from '../src/main/security/secretBox'
const MASTER = randomBytes(32)
const CTX = SECRET_CONTEXT.credential
describe('secretBox', () => {
it('round-trips a secret', () => {
const sealed = seal(MASTER, 'hunter2', CTX)
expect(unseal(MASTER, sealed, CTX)).toBe('hunter2')
})
it('round-trips non-ASCII and empty strings intact', () => {
// Byte-length assumptions are the classic failure here: these strings are
// longer in bytes than in characters, and a naive length check or a
// truncating transform mangles them.
for (const secret of ['密码', 'emoji-🔐-ok', 'ünïcödé', '', 'a'.repeat(4096)]) {
const sealed = seal(MASTER, secret, CTX)
expect(unseal(MASTER, sealed, CTX)).toBe(secret)
}
})
it('does not leave the plaintext recoverable from the ciphertext', () => {
const sealed = seal(MASTER, 'hunter2', CTX)
expect(sealed.ciphertext.toString('latin1')).not.toContain('hunter2')
})
it('produces different ciphertext each time the same secret is sealed', () => {
// A fresh nonce and salt per call. Identical ciphertext would leak that two
// hosts share a password.
const a = seal(MASTER, 'same-secret', CTX)
const b = seal(MASTER, 'same-secret', CTX)
expect(a.ciphertext.equals(b.ciphertext)).toBe(false)
expect(a.nonce.equals(b.nonce)).toBe(false)
expect(a.salt.equals(b.salt)).toBe(false)
})
it('rejects a tampered ciphertext', () => {
const sealed = seal(MASTER, 'hunter2', CTX)
sealed.ciphertext[0] ^= 0x01
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
})
it('rejects a tampered auth tag', () => {
const sealed = seal(MASTER, 'hunter2', CTX)
const last = sealed.ciphertext.length - 1
sealed.ciphertext[last] ^= 0x01
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
})
it('rejects the wrong master key', () => {
const sealed = seal(MASTER, 'hunter2', CTX)
expect(() => unseal(randomBytes(32), sealed, CTX)).toThrow()
})
it('rejects the wrong context', () => {
// Context separation: a credential blob must not decrypt as a private key
// even when both are sealed under the same master key.
const sealed = seal(MASTER, 'hunter2', CTX)
expect(() => unseal(MASTER, sealed, SECRET_CONTEXT.privateKey)).toThrow()
})
it('rejects a truncated ciphertext rather than returning a short secret', () => {
const sealed = seal(MASTER, 'hunter2', CTX)
sealed.ciphertext = sealed.ciphertext.subarray(0, 4)
expect(() => unseal(MASTER, sealed, CTX)).toThrow()
})
it('derives a stable key for identical inputs and a distinct one per salt', () => {
const salt = randomBytes(16)
expect(deriveKey(MASTER, salt, CTX).equals(deriveKey(MASTER, salt, CTX))).toBe(true)
expect(deriveKey(MASTER, randomBytes(16), CTX).equals(deriveKey(MASTER, salt, CTX))).toBe(
false,
)
})
})
+440
View File
@@ -0,0 +1,440 @@
import { describe, expect, it } from 'vitest'
import type { FolderNode, HostNode, TreeNode } from '../src/shared/ipc-schemas'
import {
allFolderKeys,
ancestorKeysOf,
buildSidebarRows,
computeMove,
computeTabMove,
dropPositionFor,
folderKey,
hostKey,
LOCAL_KEY,
orderAllSessions,
nextAutoIndex,
orderSessions,
seqAfterSession,
seqForNewSession,
sessionKey,
sessionLabel,
type SidebarRow,
type SidebarSession,
} from '../src/renderer/src/lib/sidebar'
// ---------------------------------------------------------------- fixtures
function folder(id: number, name: string, children: TreeNode[] = []): FolderNode {
return { kind: 'folder', id, parentId: null, name, sortOrder: id * 1024, children }
}
function host(id: number, label: string): HostNode {
return {
kind: 'host',
id,
parentId: null,
label,
hostname: '10.0.1.5',
port: 22,
username: 'root',
authType: 'password',
color: null,
sortOrder: id * 1024,
}
}
function session(
id: string,
hostId: number | null,
seq: number,
pinned = false,
autoIndex = 1,
): SidebarSession {
return {
id,
hostId,
label: 'session',
autoIndex,
customLabel: null,
status: 'connected',
pinned,
seq,
host: null,
}
}
function rowOf(rows: SidebarRow[], key: string): SidebarRow {
const row = rows.find((r) => r.key === key)
if (!row) throw new Error(`no row for ${key}`)
return row
}
// ---------------------------------------------------------------- ordering
describe('orderSessions', () => {
it('puts pinned sessions first, then open order', () => {
const ordered = orderSessions([
session('c', 1, 3),
session('a', 1, 1),
session('b', 1, 2, true),
])
expect(ordered.map((s) => s.id)).toEqual(['b', 'a', 'c'])
})
it('does not mutate the input', () => {
const input = [session('b', 1, 2), session('a', 1, 1)]
orderSessions(input)
expect(input.map((s) => s.id)).toEqual(['b', 'a'])
})
})
describe('orderAllSessions', () => {
it('puts local shells first, then one contiguous block per host', () => {
// A session opened for host 2 after host 1 must not land between host 1's
// sessions — same-host tabs stay together, which is the whole point of the
// grouping.
const ordered = orderAllSessions([
session('a', 1, 1),
session('b', 2, 2),
session('c', 1, 3),
session('local', null, 4),
])
expect(ordered.map((s) => s.id)).toEqual(['local', 'a', 'c', 'b'])
})
it('keeps hosts in the order they were first opened', () => {
const ordered = orderAllSessions([session('a', 7, 1), session('b', 3, 2), session('c', 7, 3)])
expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b'])
})
it('applies pinning within each host group, not across groups', () => {
const ordered = orderAllSessions([
session('a', 1, 1),
session('b', 2, 2),
session('c', 2, 3, true),
])
// c moves to the front of host 2's block, but does not jump above host 1.
expect(ordered.map((s) => s.id)).toEqual(['a', 'c', 'b'])
})
})
// ---------------------------------------------------------------- numbering
describe('nextAutoIndex', () => {
it('starts at 1 for a host with no sessions', () => {
expect(nextAutoIndex([], 7)).toBe(1)
})
it('counts per host, not globally', () => {
expect(nextAutoIndex([session('a', 7, 1, false, 5)], 8)).toBe(1)
})
it('treats local shells as their own group', () => {
expect(nextAutoIndex([session('a', 7, 1, false, 3)], null)).toBe(1)
expect(nextAutoIndex([session('a', null, 1, false, 2)], null)).toBe(3)
})
it('increments past the highest index in use, not past the count', () => {
// Three opened, the middle one closed: the next must not collide with the
// surviving "3". Counting open sessions would hand out 3 a second time.
const sessions = [session('a', 7, 1, false, 1), session('c', 7, 3, false, 3)]
expect(nextAutoIndex(sessions, 7)).toBe(4)
})
it('restarts at 1 once a host has no sessions left', () => {
expect(nextAutoIndex([session('a', 9, 1, false, 4)], 7)).toBe(1)
})
})
describe('sessionLabel', () => {
it('leaves the first session for a host unnumbered', () => {
expect(sessionLabel(session('a', 7, 1, false, 1), 'local')).toBe('session')
})
it('appends the index from the second onward', () => {
expect(sessionLabel(session('a', 7, 1, false, 2), 'local')).toBe('session 2')
expect(sessionLabel(session('a', 7, 1, false, 11), 'local')).toBe('session 11')
})
it('uses the translated base for a local shell', () => {
// The base has to come from the caller: this module has no business
// knowing a language.
expect(sessionLabel(session('a', null, 1, false, 1), '本地终端')).toBe('本地终端')
expect(sessionLabel(session('a', null, 1, false, 3), '本地终端')).toBe('本地终端 3')
})
it('lets a custom name override the numbering entirely', () => {
const renamed = { ...session('a', 7, 1, false, 4), customLabel: 'prod db' }
expect(sessionLabel(renamed, 'local')).toBe('prod db')
})
})
// ---------------------------------------------------------------- tab placement
describe('seqForNewSession', () => {
it('starts a host group at 1', () => {
expect(seqForNewSession([], 7)).toBe(1)
})
it('appends past the maximum, not past the array length', () => {
// `seq` is fractional once anything has been dragged, so counting entries
// would collide with an existing position.
const sessions = [session('a', 7, 1), session('b', 7, 1.5), session('c', 7, 9)]
expect(seqForNewSession(sessions, 7)).toBe(10)
})
it('ignores other hosts', () => {
expect(seqForNewSession([session('a', 7, 100)], 8)).toBe(1)
})
})
describe('seqAfterSession', () => {
it('lands between the original and the next one in its bucket', () => {
const sessions = [session('a', 7, 1), session('b', 7, 3)]
expect(seqAfterSession(sessions, 'a')).toBe(2)
})
it('lands just past the original when it is last', () => {
const sessions = [session('a', 7, 1), session('b', 7, 3)]
expect(seqAfterSession(sessions, 'b')).toBe(4)
})
it('does not see past its own pin bucket', () => {
// A pinned neighbour is not "next" for an unpinned original.
const sessions = [session('a', 7, 1), session('b', 7, 2, true), session('c', 7, 3)]
expect(seqAfterSession(sessions, 'a')).toBe(2)
})
})
describe('computeTabMove', () => {
const sessions = [session('a', 7, 1), session('b', 7, 2), session('c', 7, 3)]
it('reorders within a host group', () => {
expect(computeTabMove(sessions, 'c', 'a', 'before')).toEqual({ seq: 0 })
expect(computeTabMove(sessions, 'a', 'c', 'after')).toEqual({ seq: 4 })
expect(computeTabMove(sessions, 'a', 'b', 'after')).toEqual({ seq: 2.5 })
})
it('refuses to move a tab into another host group', () => {
// A session that left its host's block would have no place in a sidebar
// that groups by host.
const mixed = [...sessions, session('d', 9, 1)]
expect(computeTabMove(mixed, 'a', 'd', 'before')).toBeNull()
})
it('refuses to move across pin states', () => {
// The sort would put it straight back where it started, so the drop is
// better refused than silently undone.
const mixed = [...sessions, session('p', 7, 4, true)]
expect(computeTabMove(mixed, 'a', 'p', 'before')).toBeNull()
})
it('refuses a drop onto itself', () => {
expect(computeTabMove(sessions, 'a', 'a', 'before')).toBeNull()
})
it('refuses landing back in the slot it came from', () => {
expect(computeTabMove(sessions, 'a', 'b', 'before')).toBeNull()
expect(computeTabMove(sessions, 'b', 'a', 'after')).toBeNull()
})
})
// ---------------------------------------------------------------- rows
describe('buildSidebarRows', () => {
const tree = [folder(1, 'prod', [host(10, 'web'), host(11, 'db')]), host(12, 'rooted')]
it('always shows the local entry, even with nothing open', () => {
const rows = buildSidebarRows(tree, [], new Set())
expect(rows[0].key).toBe(LOCAL_KEY)
expect(rows[0].kind).toBe('local')
expect(rows[0].sessionCount).toBe(0)
// Nothing under it to expand.
expect(rows[0].expandable).toBe(false)
})
it('omits children of collapsed folders', () => {
const rows = buildSidebarRows(tree, [], new Set())
expect(rows.map((r) => r.key)).toEqual([LOCAL_KEY, folderKey(1), hostKey(12)])
})
it('includes children of expanded folders', () => {
const rows = buildSidebarRows(tree, [], new Set([folderKey(1)]))
expect(rows.map((r) => r.key)).toEqual([
LOCAL_KEY,
folderKey(1),
hostKey(10),
hostKey(11),
hostKey(12),
])
})
it('nests sessions under their host, once that host is expanded', () => {
const sessions = [session('s1', 10, 1), session('s2', 10, 2)]
const collapsed = buildSidebarRows(tree, sessions, new Set([folderKey(1)]))
expect(collapsed.map((r) => r.key)).not.toContain(sessionKey('s1'))
expect(rowOf(collapsed, hostKey(10)).sessionCount).toBe(2)
expect(rowOf(collapsed, hostKey(10)).expandable).toBe(true)
const expanded = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)]))
expect(expanded.map((r) => r.key)).toEqual([
LOCAL_KEY,
folderKey(1),
hostKey(10),
sessionKey('s1'),
sessionKey('s2'),
hostKey(11),
hostKey(12),
])
})
it('nests local shells under the local entry', () => {
const sessions = [session('l1', null, 1), session('l2', null, 2)]
const rows = buildSidebarRows(tree, sessions, new Set([LOCAL_KEY]))
expect(rows.map((r) => r.key)).toEqual([
LOCAL_KEY,
sessionKey('l1'),
sessionKey('l2'),
folderKey(1),
hostKey(12),
])
expect(rows[0].sessionCount).toBe(2)
})
it('orders sessions within a host by pin state, not by array position', () => {
const sessions = [session('a', 10, 1), session('b', 10, 2), session('c', 10, 3, true)]
const rows = buildSidebarRows(tree, sessions, new Set([folderKey(1), hostKey(10)]))
const sessionRows = rows.filter((r) => r.kind === 'session').map((r) => r.session?.id)
expect(sessionRows).toEqual(['c', 'a', 'b'])
})
it('indents sessions one level below their host', () => {
const rows = buildSidebarRows(
tree,
[session('s1', 10, 1), session('l1', null, 2)],
new Set([folderKey(1), hostKey(10), LOCAL_KEY]),
)
expect(rowOf(rows, LOCAL_KEY).depth).toBe(0)
expect(rowOf(rows, sessionKey('l1')).depth).toBe(1)
expect(rowOf(rows, folderKey(1)).depth).toBe(0)
expect(rowOf(rows, hostKey(10)).depth).toBe(1)
expect(rowOf(rows, sessionKey('s1')).depth).toBe(2)
})
it('makes a host a leaf again once its last session closes', () => {
const withSession = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1)]))
expect(rowOf(withSession, hostKey(10)).expandable).toBe(true)
const without = buildSidebarRows(tree, [], new Set([folderKey(1)]))
expect(rowOf(without, hostKey(10)).expandable).toBe(false)
})
it('carries the sibling layer a drop needs', () => {
const rows = buildSidebarRows(tree, [], new Set([folderKey(1)]))
expect(rowOf(rows, hostKey(10)).siblings?.map((n) => n.id)).toEqual([10, 11])
expect(rowOf(rows, hostKey(10)).parentId).toBe(1)
expect(rowOf(rows, hostKey(12)).parentId).toBeNull()
})
})
// ---------------------------------------------------------------- drop position
describe('dropPositionFor', () => {
const rect = { top: 100, height: 24 }
it('offers three zones on a folder, the middle meaning "nest inside"', () => {
expect(dropPositionFor(rect, 102, folder(1, 'f'))).toBe('before')
expect(dropPositionFor(rect, 112, folder(1, 'f'))).toBe('inside')
expect(dropPositionFor(rect, 122, folder(1, 'f'))).toBe('after')
})
it('never offers "inside" on a host, which holds sessions but not nodes', () => {
expect(dropPositionFor(rect, 102, host(1, 'h'))).toBe('before')
expect(dropPositionFor(rect, 112, host(1, 'h'))).toBe('after')
})
})
// ---------------------------------------------------------------- computeMove
describe('computeMove', () => {
const tree = [
folder(1, 'prod', [host(10, 'web'), host(11, 'db'), folder(3, 'inner', [host(13, 'deep')])]),
folder(2, 'staging'),
host(12, 'rooted'),
]
const rows = buildSidebarRows(tree, [session('s1', 10, 1)], new Set([folderKey(1), hostKey(10)]))
it('expresses dropping between two nodes', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(11)), position: 'before' }))
.toEqual({ id: 12, parentId: 1, prevSiblingId: 10, nextSiblingId: 11 })
})
it('expresses dropping between two folders', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'before' }))
.toEqual({ id: 12, parentId: null, prevSiblingId: 1, nextSiblingId: 2 })
})
it('expresses dropping into a folder, appended after its children', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, folderKey(2)), position: 'inside' }))
.toEqual({ id: 12, parentId: 2, prevSiblingId: null, nextSiblingId: null })
})
it('refuses to drop a node on itself', () => {
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, hostKey(12)), position: 'before' }))
.toBeNull()
})
it('refuses to drop a folder into its own descendant', () => {
// Without this the subtree detaches from every root and disappears from the
// UI while its rows remain. The main process guards it too; catching it
// here means the drag simply refuses rather than erroring after the fact.
//
// The dragged node has to be the one from the tree — the check walks its
// children, and a freshly built FolderNode with none has no subtree to
// protect.
const prod = tree[0]
expect(computeMove(prod, { row: rowOf(rows, folderKey(3)), position: 'inside' })).toBeNull()
})
it('allows dropping a folder into an unrelated folder', () => {
expect(computeMove(folder(3, 'inner'), { row: rowOf(rows, folderKey(2)), position: 'inside' }))
.toMatchObject({ id: 3, parentId: 2 })
})
it('refuses a session row as a drop target', () => {
// Sessions are ordered by their own rules and are not in the database at
// all, so there is no move to express.
expect(computeMove(host(12, 'rooted'), { row: rowOf(rows, sessionKey('s1')), position: 'before' }))
.toBeNull()
})
it('treats landing back in the current slot as a no-op', () => {
expect(computeMove(host(10, 'web'), { row: rowOf(rows, hostKey(11)), position: 'before' }))
.toBeNull()
})
})
// ---------------------------------------------------------------- helpers
describe('ancestorKeysOf', () => {
const tree = [folder(1, 'a', [folder(2, 'b', [host(10, 'deep')])])]
it('lists the folders above a node, outermost first', () => {
expect(ancestorKeysOf(tree, hostKey(10))).toEqual([folderKey(1), folderKey(2)])
})
it('returns nothing for a root-level node', () => {
expect(ancestorKeysOf(tree, folderKey(1))).toEqual([])
})
})
describe('allFolderKeys', () => {
it('collects folder keys only, so expand-all does not open every session list', () => {
const tree = [folder(1, 'a', [folder(2, 'b'), host(10, 'x')]), host(11, 'y')]
expect(allFolderKeys(tree).sort()).toEqual([folderKey(1), folderKey(2)])
})
})
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, describe, expect, it } from 'vitest'
import { closeDatabase, openDatabase } from '../src/main/db/database'
import { Vault } from '../src/main/security/vault'
import { SECRET_CONTEXT } from '../src/main/security/secretBox'
import { createTempDb, fakeKeyring, insecureKeyring, type TempDb } from './helpers'
let temp: TempDb | null = null
afterEach(() => {
temp?.cleanup()
temp = null
})
describe('vault', () => {
it('generates a master key on first run', () => {
temp = createTempDb()
const vault = new Vault(temp.db, fakeKeyring())
const status = vault.initialize()
expect(status).toMatchObject({ state: 'unlocked', createdNow: true })
expect(vault.isUnlocked).toBe(true)
})
it('reloads the existing master key on later runs', () => {
temp = createTempDb()
const first = new Vault(temp.db, fakeKeyring())
first.initialize()
const sealed = first.sealSecret('persisted-secret', SECRET_CONTEXT.credential)
// A second vault over the same database must recover the same key, or every
// stored credential becomes undecryptable on restart.
const second = new Vault(temp.db, fakeKeyring())
const status = second.initialize()
expect(status).toMatchObject({ state: 'unlocked', createdNow: false })
expect(second.unsealSecret(sealed, SECRET_CONTEXT.credential)).toBe('persisted-secret')
})
it('stores the master key wrapped, never in the clear', () => {
temp = createTempDb()
const vault = new Vault(temp.db, fakeKeyring())
vault.initialize()
const row = temp.db
.prepare('SELECT ciphertext FROM master_key_meta WHERE id = 1')
.get() as { ciphertext: Buffer }
// The keyring prefix proves wrap() was actually called rather than the raw
// key being written straight to the row.
expect(row.ciphertext.toString('utf8').startsWith('WRAPPED:')).toBe(true)
})
it('refuses to operate when the keyring is not trustworthy', () => {
temp = createTempDb()
const vault = new Vault(temp.db, insecureKeyring())
const status = vault.initialize()
expect(status).toMatchObject({ state: 'unavailable', reason: 'insecure-keyring' })
expect(vault.isUnlocked).toBe(false)
// The hard rule: no silent degradation to weak storage. Storing a password
// under a hardcoded key is worse than refusing, because the user believes
// it is protected.
expect(() => vault.sealSecret('hunter2', SECRET_CONTEXT.credential)).toThrow()
})
it('writes no master key row at all when the keyring is insecure', () => {
temp = createTempDb()
new Vault(temp.db, insecureKeyring()).initialize()
const count = temp.db
.prepare('SELECT COUNT(*) AS n FROM master_key_meta')
.get() as { n: number }
expect(count.n).toBe(0)
})
it('reports a usable failure when the stored key cannot be unwrapped', () => {
temp = createTempDb()
const first = new Vault(temp.db, fakeKeyring())
first.initialize()
// Simulates the keyring changing underneath us — a different daemon, a
// restored backup, a reimaged machine.
const brokenKeyring = { ...fakeKeyring(), unwrap: (): string => {
throw new Error('no keyring entry found')
} }
const second = new Vault(temp.db, brokenKeyring)
const status = second.initialize()
expect(status).toMatchObject({ state: 'unavailable', reason: 'unseal-failed' })
// The message must name the backend that sealed it, so the condition is
// diagnosable rather than an opaque decrypt failure.
if (status.state === 'unavailable') {
expect(status.detail).toContain('test-keyring')
}
})
it('locks, after which secrets can no longer be sealed', () => {
temp = createTempDb()
const vault = new Vault(temp.db, fakeKeyring())
vault.initialize()
vault.lock()
expect(vault.isUnlocked).toBe(false)
expect(() => vault.sealSecret('hunter2', SECRET_CONTEXT.credential)).toThrow(/locked/)
})
it('rejects a master key of the wrong length', () => {
temp = createTempDb()
new Vault(temp.db, fakeKeyring()).initialize()
// A short key would silently weaken AES-256 or make ciphertext
// unrecoverable depending on how the crypto layer reacted.
const shortKeyring = {
...fakeKeyring(),
unwrap: (): string => Buffer.from('too-short').toString('base64'),
}
const vault = new Vault(temp.db, shortKeyring)
const status = vault.initialize()
expect(status).toMatchObject({ state: 'unavailable', reason: 'unseal-failed' })
})
it('survives a restart with a fresh Vault over the same file', () => {
const db = createTempDb()
const path = db.path
const vault = new Vault(db.db, fakeKeyring())
vault.initialize()
const sealed = vault.sealSecret('across-restart', SECRET_CONTEXT.credential)
closeDatabase(db.db)
const reopened = openDatabase(path)
const afterRestart = new Vault(reopened, fakeKeyring())
afterRestart.initialize()
expect(afterRestart.unsealSecret(sealed, SECRET_CONTEXT.credential)).toBe('across-restart')
closeDatabase(reopened)
temp = { ...db, db: reopened }
})
})