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