Files
nexusterm/tests/credentials.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

150 lines
5.2 KiB
TypeScript

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()
})
})