From b3e27951e42c920e966e941cb86b1a98b5c16440 Mon Sep 17 00:00:00 2001 From: Meanski Date: Sat, 11 Jul 2026 13:18:34 +1200 Subject: [PATCH 1/3] Add unit tests for various components and functionalities - Implement tests for LocalTerminalView to cover terminal spawning, data routing, and resizing. - Create tests for TopBar to validate UI interactions and state changes. - Add tests for K8sIcon to ensure correct rendering and props handling. - Develop tests for ServerContextMenu to verify menu interactions and session management. - Enhance UnlockScreen tests to improve PIN entry handling and error display. - Introduce tests for SFTP connection logic to validate authentication methods and error handling. --- src/main/__tests__/menu.test.ts | 116 +++ src/main/__tests__/updater.test.ts | 189 ++++ .../ipc/__tests__/k8s.handlers.more.test.ts | 805 ++++++++++++++++++ .../ipc/__tests__/keychain.handlers.test.ts | 466 ++++++++++ .../__tests__/localTerminal.handlers.test.ts | 268 ++++++ .../ipc/__tests__/localfs.handlers.test.ts | 195 +++++ src/main/ipc/__tests__/redis.handlers.test.ts | 503 +++++++++++ .../ipc/__tests__/runner.handlers.test.ts | 350 ++++++++ .../ipc/__tests__/sessions.handlers.test.ts | 372 ++++++++ .../ipc/__tests__/settings.handlers.test.ts | 94 ++ .../ipc/__tests__/sshClients.handlers.test.ts | 358 ++++++++ src/preload/__tests__/index.test.ts | 367 ++++++++ .../__tests__/CommandPalette.test.tsx | 246 ++++++ .../components/Database/DatabaseExplorer.tsx | 24 +- .../Docker/__tests__/DockerLogsModal.test.tsx | 209 +++++ .../K8s/__tests__/PodExecModal.test.tsx | 245 ++++++ .../K8s/__tests__/PodLogsModal.test.tsx | 294 +++++++ .../__tests__/NotificationHost.test.tsx | 90 ++ .../src/components/Settings/Settings.tsx | 2 +- .../Settings/__tests__/Settings.test.tsx | 9 +- .../Sidebar/__tests__/Sidebar.more.test.tsx | 459 ++++++++++ .../__tests__/LocalTerminalView.test.tsx | 277 ++++++ .../TopBar/__tests__/TopBar.test.tsx | 100 +++ .../src/components/__tests__/K8sIcon.test.tsx | 41 + .../__tests__/ServerContextMenu.test.tsx | 204 +++++ .../__tests__/UnlockScreen.test.tsx | 22 +- .../src/lib/__tests__/sftpConnect.test.ts | 86 ++ 27 files changed, 6372 insertions(+), 19 deletions(-) create mode 100644 src/main/__tests__/menu.test.ts create mode 100644 src/main/__tests__/updater.test.ts create mode 100644 src/main/ipc/__tests__/k8s.handlers.more.test.ts create mode 100644 src/main/ipc/__tests__/keychain.handlers.test.ts create mode 100644 src/main/ipc/__tests__/localTerminal.handlers.test.ts create mode 100644 src/main/ipc/__tests__/localfs.handlers.test.ts create mode 100644 src/main/ipc/__tests__/redis.handlers.test.ts create mode 100644 src/main/ipc/__tests__/runner.handlers.test.ts create mode 100644 src/main/ipc/__tests__/sessions.handlers.test.ts create mode 100644 src/main/ipc/__tests__/settings.handlers.test.ts create mode 100644 src/main/ipc/__tests__/sshClients.handlers.test.ts create mode 100644 src/preload/__tests__/index.test.ts create mode 100644 src/renderer/src/components/CommandPalette/__tests__/CommandPalette.test.tsx create mode 100644 src/renderer/src/components/Docker/__tests__/DockerLogsModal.test.tsx create mode 100644 src/renderer/src/components/K8s/__tests__/PodExecModal.test.tsx create mode 100644 src/renderer/src/components/K8s/__tests__/PodLogsModal.test.tsx create mode 100644 src/renderer/src/components/Notifications/__tests__/NotificationHost.test.tsx create mode 100644 src/renderer/src/components/Sidebar/__tests__/Sidebar.more.test.tsx create mode 100644 src/renderer/src/components/Terminal/__tests__/LocalTerminalView.test.tsx create mode 100644 src/renderer/src/components/TopBar/__tests__/TopBar.test.tsx create mode 100644 src/renderer/src/components/__tests__/K8sIcon.test.tsx create mode 100644 src/renderer/src/components/__tests__/ServerContextMenu.test.tsx create mode 100644 src/renderer/src/lib/__tests__/sftpConnect.test.ts diff --git a/src/main/__tests__/menu.test.ts b/src/main/__tests__/menu.test.ts new file mode 100644 index 0000000..dd9a304 --- /dev/null +++ b/src/main/__tests__/menu.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' + +vi.mock('electron', () => ({ + app: { name: 'noxed' }, + Menu: { + buildFromTemplate: vi.fn((template: unknown) => ({ template })), + setApplicationMenu: vi.fn(), + }, + BrowserWindow: { + getFocusedWindow: vi.fn(() => null), + getAllWindows: vi.fn(() => []), + }, +})) + +import { Menu, BrowserWindow, type MenuItemConstructorOptions } from 'electron' +import { buildAppMenu } from '../menu' + +const realPlatform = process.platform + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { value }) +} + +function buildTemplate(): MenuItemConstructorOptions[] { + buildAppMenu() + const template = vi.mocked(Menu.buildFromTemplate).mock.calls.at(-1)?.[0] + if (!template) throw new Error('Menu.buildFromTemplate was not called') + return template as MenuItemConstructorOptions[] +} + +function submenuOf(template: MenuItemConstructorOptions[], label: string): MenuItemConstructorOptions[] { + const menu = template.find((item) => item.label === label) + if (!menu) throw new Error(`no ${label} menu`) + return menu.submenu as MenuItemConstructorOptions[] +} + +function makeWindow() { + return { webContents: { send: vi.fn() } } +} + +afterEach(() => { + setPlatform(realPlatform) + vi.mocked(BrowserWindow.getFocusedWindow).mockReset().mockReturnValue(null) + vi.mocked(BrowserWindow.getAllWindows).mockReset().mockReturnValue([]) +}) + +describe('buildAppMenu', () => { + it('installs the built menu as the application menu', () => { + buildAppMenu() + const built = vi.mocked(Menu.buildFromTemplate).mock.results.at(-1)?.value + expect(Menu.setApplicationMenu).toHaveBeenCalledWith(built) + }) + + it('includes the macOS app menu and mac-only roles on darwin', () => { + setPlatform('darwin') + const template = buildTemplate() + expect(template.map((item) => item.label)).toEqual(['noxed', 'File', 'Edit', 'View', 'Window']) + + const appMenu = template[0].submenu as MenuItemConstructorOptions[] + expect(appMenu.map((item) => item.role ?? item.type)).toContain('quit') + + // Quit lives in the app menu, not in File + expect(submenuOf(template, 'File').some((item) => item.role === 'quit')).toBe(false) + expect(submenuOf(template, 'Edit').some((item) => item.role === 'pasteAndMatchStyle')).toBe(true) + expect(submenuOf(template, 'Window').some((item) => item.role === 'front')).toBe(true) + }) + + it('omits the app menu and moves Quit into File on Windows/Linux', () => { + setPlatform('win32') + const template = buildTemplate() + expect(template.map((item) => item.label)).toEqual(['File', 'Edit', 'View', 'Window']) + expect(submenuOf(template, 'File').at(-1)).toEqual({ role: 'quit' }) + expect(submenuOf(template, 'Edit').some((item) => item.role === 'pasteAndMatchStyle')).toBe(false) + expect(submenuOf(template, 'Window').some((item) => item.role === 'close')).toBe(true) + }) + + it('binds the documented accelerators to the File commands', () => { + const file = submenuOf(buildTemplate(), 'File') + const accelerators = Object.fromEntries( + file.filter((item) => item.label).map((item) => [item.label, item.accelerator]) + ) + expect(accelerators).toEqual({ + 'New Connection…': 'CmdOrCtrl+N', + 'Open Connection…': 'CmdOrCtrl+T', + 'New Local Terminal': 'CmdOrCtrl+`', + 'Close Tab': 'CmdOrCtrl+W', + }) + }) +}) + +describe('menu command routing', () => { + function clickItem(label: string): void { + const item = submenuOf(buildTemplate(), 'File').find((entry) => entry.label === label) + if (!item?.click) throw new Error(`no clickable ${label} item`) + ;(item.click as () => void)() + } + + it('sends the command to the focused window', () => { + const focused = makeWindow() + vi.mocked(BrowserWindow.getFocusedWindow).mockReturnValue(focused as never) + clickItem('New Connection…') + expect(focused.webContents.send).toHaveBeenCalledWith('menu:new-connection') + }) + + it('falls back to the first window when none is focused', () => { + const fallback = makeWindow() + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([fallback] as never) + clickItem('Open Connection…') + expect(fallback.webContents.send).toHaveBeenCalledWith('menu:open-connection') + }) + + it('does nothing when no window exists', () => { + expect(() => clickItem('New Local Terminal')).not.toThrow() + expect(() => clickItem('Close Tab')).not.toThrow() + }) +}) diff --git a/src/main/__tests__/updater.test.ts b/src/main/__tests__/updater.test.ts new file mode 100644 index 0000000..64dbee0 --- /dev/null +++ b/src/main/__tests__/updater.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const state = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + }, + updaterEvents: new Map unknown>(), + app: { isPackaged: false, getVersion: () => '9.9.9' }, + windows: [] as Array<{ webContents: { isDestroyed: () => boolean; send: (...args: unknown[]) => void } }>, +})) + +vi.mock('electron', () => ({ + app: state.app, + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + state.ipc.handlers.set(channel, fn) + }), + on: vi.fn(), + }, + BrowserWindow: { + getAllWindows: () => state.windows, + }, +})) + +vi.mock('electron-updater', () => { + const autoUpdater = { + autoDownload: true, + autoInstallOnAppQuit: false, + on: vi.fn((event: string, fn: (...args: unknown[]) => unknown) => { + state.updaterEvents.set(event, fn) + return autoUpdater + }), + checkForUpdates: vi.fn(() => Promise.resolve({})), + downloadUpdate: vi.fn(() => Promise.resolve([])), + quitAndInstall: vi.fn(), + } + return { autoUpdater } +}) + +import { ipcMain } from 'electron' +import { autoUpdater } from 'electron-updater' +import { registerUpdaterHandlers, checkForUpdatesOnStartup } from '../updater' + +registerUpdaterHandlers() + +function makeWindow(destroyed = false) { + const win = { webContents: { isDestroyed: () => destroyed, send: vi.fn() } } + state.windows.push(win) + return win +} + +function invoke(channel: string): unknown { + const handler = state.ipc.handlers.get(channel) + if (!handler) throw new Error(`${channel} handler not registered`) + return handler({ sender: { id: 1, send: vi.fn(), isDestroyed: () => false } }) +} + +beforeEach(() => { + state.windows.length = 0 + state.app.isPackaged = false + vi.mocked(autoUpdater.checkForUpdates).mockClear().mockResolvedValue({} as never) + vi.mocked(autoUpdater.downloadUpdate).mockClear().mockResolvedValue([] as never) + vi.mocked(autoUpdater.quitAndInstall).mockClear() + vi.mocked(ipcMain.handle).mockClear() +}) + +describe('registerUpdaterHandlers', () => { + it('configures manual download with install-on-quit staging', () => { + expect(autoUpdater.autoDownload).toBe(false) + expect(autoUpdater.autoInstallOnAppQuit).toBe(true) + }) + + it('only wires handlers once', () => { + registerUpdaterHandlers() + expect(ipcMain.handle).not.toHaveBeenCalled() + }) +}) + +describe('status broadcasting', () => { + it('broadcasts each autoUpdater event to all live windows', () => { + const win = makeWindow() + const dead = makeWindow(true) + + state.updaterEvents.get('checking-for-update')?.() + state.updaterEvents.get('update-available')?.({ version: '2.0.0' }) + state.updaterEvents.get('update-not-available')?.({ version: '1.0.0' }) + state.updaterEvents.get('download-progress')?.({ percent: 41.7 }) + state.updaterEvents.get('update-downloaded')?.({ version: '2.0.0' }) + state.updaterEvents.get('error')?.(new Error('boom')) + + expect(win.webContents.send.mock.calls).toEqual([ + ['updater:status', { state: 'checking' }], + ['updater:status', { state: 'available', version: '2.0.0' }], + ['updater:status', { state: 'not-available', version: '1.0.0' }], + ['updater:status', { state: 'downloading', percent: 42 }], + ['updater:status', { state: 'downloaded', version: '2.0.0' }], + ['updater:status', { state: 'error', message: 'boom' }], + ]) + expect(dead.webContents.send).not.toHaveBeenCalled() + }) + + it('stringifies errors that have no message', () => { + const win = makeWindow() + state.updaterEvents.get('error')?.(undefined) + expect(win.webContents.send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'undefined', + }) + }) +}) + +describe('updater:version', () => { + it('returns the app version', () => { + expect(invoke('updater:version')).toBe('9.9.9') + }) +}) + +describe('updater:check', () => { + it('refuses to check in unpackaged (dev) builds and broadcasts the error', async () => { + const win = makeWindow() + const status = { state: 'error', message: 'Updates are only available in packaged builds' } + await expect(invoke('updater:check')).resolves.toEqual(status) + expect(win.webContents.send).toHaveBeenCalledWith('updater:status', status) + expect(autoUpdater.checkForUpdates).not.toHaveBeenCalled() + }) + + it('checks for updates in packaged builds', async () => { + state.app.isPackaged = true + await expect(invoke('updater:check')).resolves.toEqual({ ok: true }) + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + it('broadcasts and returns an error status when the check fails', async () => { + state.app.isPackaged = true + const win = makeWindow() + vi.mocked(autoUpdater.checkForUpdates).mockRejectedValueOnce(new Error('net down')) + await expect(invoke('updater:check')).resolves.toEqual({ state: 'error', message: 'net down' }) + expect(win.webContents.send).toHaveBeenCalledWith('updater:status', { state: 'error', message: 'net down' }) + }) + + it('falls back to a generic message for message-less failures', async () => { + state.app.isPackaged = true + vi.mocked(autoUpdater.checkForUpdates).mockRejectedValueOnce({}) + await expect(invoke('updater:check')).resolves.toEqual({ state: 'error', message: 'Update check failed' }) + }) +}) + +describe('updater:download', () => { + it('downloads the update', async () => { + await expect(invoke('updater:download')).resolves.toEqual({ ok: true }) + expect(autoUpdater.downloadUpdate).toHaveBeenCalledTimes(1) + }) + + it('broadcasts and returns an error status when the download fails', async () => { + const win = makeWindow() + vi.mocked(autoUpdater.downloadUpdate).mockRejectedValueOnce(new Error('disk full')) + await expect(invoke('updater:download')).resolves.toEqual({ state: 'error', message: 'disk full' }) + expect(win.webContents.send).toHaveBeenCalledWith('updater:status', { state: 'error', message: 'disk full' }) + }) + + it('falls back to a generic message for message-less failures', async () => { + vi.mocked(autoUpdater.downloadUpdate).mockRejectedValueOnce({}) + await expect(invoke('updater:download')).resolves.toEqual({ state: 'error', message: 'Download failed' }) + }) +}) + +describe('updater:quitAndInstall', () => { + it('defers the install so the IPC reply can flush first', async () => { + invoke('updater:quitAndInstall') + expect(autoUpdater.quitAndInstall).not.toHaveBeenCalled() + await new Promise((r) => setImmediate(r)) + expect(autoUpdater.quitAndInstall).toHaveBeenCalledTimes(1) + }) +}) + +describe('checkForUpdatesOnStartup', () => { + it('does nothing in unpackaged builds', () => { + checkForUpdatesOnStartup() + expect(autoUpdater.checkForUpdates).not.toHaveBeenCalled() + }) + + it('checks in packaged builds and swallows failures', async () => { + state.app.isPackaged = true + vi.mocked(autoUpdater.checkForUpdates).mockRejectedValueOnce(new Error('offline')) + checkForUpdatesOnStartup() + expect(autoUpdater.checkForUpdates).toHaveBeenCalledTimes(1) + await new Promise((r) => setImmediate(r)) + }) +}) diff --git a/src/main/ipc/__tests__/k8s.handlers.more.test.ts b/src/main/ipc/__tests__/k8s.handlers.more.test.ts new file mode 100644 index 0000000..2b9a22d --- /dev/null +++ b/src/main/ipc/__tests__/k8s.handlers.more.test.ts @@ -0,0 +1,805 @@ +import { describe, it, expect, vi, beforeEach, afterEach, afterAll, type Mock } from 'vitest' +import type { PassThrough } from 'node:stream' +import { mkdirSync, writeFileSync, existsSync } from 'node:fs' +import { join } from 'node:path' + +const h = vi.hoisted(() => { + const tmp = (process.env.TMPDIR ?? process.env.TMP ?? '/tmp').replace(/\/+$/, '') + const FAKE_HOME = `${tmp}/noxed-k8s-more-test-home` + return { + FAKE_HOME, + loads: [] as string[], + loadFromFileError: null as Error | null, + contexts: [ + { name: 'ctx', cluster: 'c' }, + { name: 'orphan', cluster: 'missing-cluster' }, + ], + clusters: [{ name: 'c', server: 'https://cluster.example.test' }], + api: { + listNamespace: vi.fn(), + listNamespacedPod: vi.fn(), + deleteNamespacedPod: vi.fn(), + listNamespacedDeployment: vi.fn(), + patchNamespacedDeployment: vi.fn(), + listNamespacedStatefulSet: vi.fn(), + listNamespacedDaemonSet: vi.fn(), + listNamespacedReplicaSet: vi.fn(), + listNamespacedJob: vi.fn(), + listNamespacedCronJob: vi.fn(), + listNamespacedService: vi.fn(), + listNamespacedIngress: vi.fn(), + listNamespacedConfigMap: vi.fn(), + listNamespacedSecret: vi.fn(), + readNamespacedSecret: vi.fn(), + listNode: vi.fn(), + listNamespacedEvent: vi.fn(), + readNamespacedPod: vi.fn(), + readNamespacedDeployment: vi.fn(), + readNamespacedStatefulSet: vi.fn(), + readNamespacedDaemonSet: vi.fn(), + readNamespacedReplicaSet: vi.fn(), + readNamespacedService: vi.fn(), + readNamespacedIngress: vi.fn(), + readNamespacedConfigMap: vi.fn(), + readNamespacedJob: vi.fn(), + readNamespacedCronJob: vi.fn(), + readNode: vi.fn(), + readNamespacedEndpoints: vi.fn(), + }, + portForward: vi.fn(async () => undefined), + execFn: vi.fn(), + logFn: vi.fn(), + } +}) + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => h.FAKE_HOME } +}) + +vi.mock('@kubernetes/client-node', () => { + class KubeConfig { + loadFromDefault() { h.loads.push('') } + loadFromFile(p: string) { + h.loads.push(p) + if (h.loadFromFileError) throw h.loadFromFileError + } + getContexts() { return h.contexts } + getClusters() { return h.clusters } + setCurrentContext = vi.fn() + getCurrentContext() { return 'ctx' } + makeApiClient() { return h.api } + } + class PortForward { portForward = h.portForward } + class Exec { exec = h.execFn } + class Log { log = h.logFn } + class CoreV1Api {} + class AppsV1Api {} + class NetworkingV1Api {} + class BatchV1Api {} + return { KubeConfig, PortForward, Exec, Log, CoreV1Api, AppsV1Api, NetworkingV1Api, BatchV1Api } +}) + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => join(h.FAKE_HOME, 'userdata')) }, + ipcMain: { handle: vi.fn(), on: vi.fn() }, + dialog: { showOpenDialog: vi.fn() }, + BrowserWindow: { fromWebContents: vi.fn() }, +})) + +vi.mock('../keychain', () => ({ + isUnlocked: vi.fn(() => true), +})) + +import { ipcMain, dialog, BrowserWindow } from 'electron' +import { isUnlocked } from '../keychain' +import { registerK8sHandlers, disposeK8sSessionsForSender } from '../k8s' +import { OwnershipError, ConnectionError, NotFoundError } from '../errors' + +// Fixture layout inside the fake home directory (homedir() is mocked above, so +// the security module's allowed-dir list points here too). +const KUBE_DIR = join(h.FAKE_HOME, '.kube') +const DOWNLOADS = join(h.FAKE_HOME, 'Downloads') +const MANAGED_DIR = join(h.FAKE_HOME, 'userdata', 'kubeconfigs') +const ALLOWED_CFG = join(KUBE_DIR, 'config') +const DOWNLOADED_CFG = join(DOWNLOADS, 'cluster-export.yaml') +const BIG_CFG = join(DOWNLOADS, 'huge.yaml') + +mkdirSync(KUBE_DIR, { recursive: true }) +mkdirSync(DOWNLOADS, { recursive: true }) +writeFileSync(ALLOWED_CFG, 'apiVersion: v1\nkind: Config\n') +writeFileSync(DOWNLOADED_CFG, 'apiVersion: v1\nkind: Config\n') +writeFileSync(BIG_CFG, Buffer.alloc(1024 * 1024 + 1)) + +registerK8sHandlers() + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Handler = (...args: any[]) => any + +function handler(channel: string): Handler { + const call = (ipcMain.handle as Mock).mock.calls.find((c) => c[0] === channel) + if (!call) throw new Error(`No invoke handler registered for ${channel}`) + return call[1] as Handler +} + +interface FakeEvent { + sender: { id: number; isDestroyed: () => boolean; send: Mock } +} + +const usedSenderIds: number[] = [] +let senderSeq = 5000 +function makeEvent(destroyed = false): FakeEvent { + const id = senderSeq++ + usedSenderIds.push(id) + return { sender: { id, isDestroyed: () => destroyed, send: vi.fn() } } +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + h.loadFromFileError = null +}) + +afterAll(() => { + for (const id of usedSenderIds) disposeK8sSessionsForSender(id) +}) + +describe('k8s:contextsDetailed', () => { + it('maps a context with an unknown cluster to an empty server', () => { + expect(handler('k8s:contextsDetailed')({})).toEqual([ + { name: 'ctx', server: 'https://cluster.example.test' }, + { name: 'orphan', server: '' }, + ]) + }) +}) + +describe('k8s:importKubeconfig', () => { + const importCfg = (path: unknown) => handler('k8s:importKubeconfig')({}, path) + + it('rejects malformed raw paths', () => { + expect(() => importCfg(42)).toThrow('Invalid kubeconfig path') + expect(() => importCfg(' ')).toThrow('Invalid kubeconfig path') + expect(() => importCfg('/home/x/\0config')).toThrow('Invalid kubeconfig path') + expect(() => importCfg(`/${'a'.repeat(5000)}`)).toThrow('Invalid kubeconfig path') + }) + + it('rejects paths outside the home directory', () => { + expect(() => importCfg('/etc/hosts')).toThrow('Kubeconfig must be inside your home directory') + }) + + it('rejects missing files, directories, and oversized files', () => { + expect(() => importCfg(join(h.FAKE_HOME, 'does-not-exist.yaml'))).toThrow('File does not exist or is not accessible') + expect(() => importCfg(h.FAKE_HOME)).toThrow('Path is not a regular file') + expect(() => importCfg(BIG_CFG)).toThrow('Kubeconfig is too large (max 1MB)') + }) + + it('rejects files that do not parse as a kubeconfig', () => { + h.loadFromFileError = new Error('bad yaml') + expect(() => importCfg(DOWNLOADED_CFG)).toThrow('Not a valid kubeconfig: bad yaml') + }) + + it('rejects kubeconfigs without contexts', () => { + const saved = h.contexts + h.contexts = [] + try { + expect(() => importCfg(DOWNLOADED_CFG)).toThrow('Kubeconfig contains no contexts') + } finally { + h.contexts = saved + } + }) + + it('references files already inside an allowed directory in place', () => { + expect(importCfg(ALLOWED_CFG)).toEqual({ + path: ALLOWED_CFG, + contexts: [ + { name: 'ctx', server: 'https://cluster.example.test' }, + { name: 'orphan', server: '' }, + ], + }) + }) + + it('copies other home files into the managed folder, expanding ~', () => { + const result = importCfg('~/Downloads/cluster-export.yaml') as { path: string } + expect(result.path.startsWith(join(MANAGED_DIR, 'cluster-export.yaml-'))).toBe(true) + expect(existsSync(result.path)).toBe(true) + }) +}) + +describe('k8s:showFilePicker', () => { + it('throws when the event has no window', async () => { + ;(BrowserWindow.fromWebContents as Mock).mockReturnValueOnce(null) + await expect(handler('k8s:showFilePicker')(makeEvent())).rejects.toThrow('No active window') + }) + + it('returns null when the dialog is canceled and the path when confirmed', async () => { + ;(BrowserWindow.fromWebContents as Mock).mockReturnValue({}) + ;(dialog.showOpenDialog as Mock).mockResolvedValueOnce({ canceled: true, filePaths: [] }) + await expect(handler('k8s:showFilePicker')(makeEvent())).resolves.toBeNull() + ;(dialog.showOpenDialog as Mock).mockResolvedValueOnce({ canceled: false, filePaths: ['/picked'] }) + await expect(handler('k8s:showFilePicker')(makeEvent())).resolves.toBe('/picked') + }) +}) + +describe('kubeconfigPath validation and caching', () => { + it('accepts an allowed explicit kubeconfig and caches the parsed config', async () => { + // h.loads accumulates across tests (importKubeconfig also loads this path), + // so assert against a baseline rather than an absolute count. + const loadsBefore = h.loads.filter((p) => p === ALLOWED_CFG).length + h.api.listNamespace.mockResolvedValue({ body: { items: [{ metadata: { name: 'default' } }, {}] } }) + await expect(handler('k8s:namespaces')({}, 'ctx', ALLOWED_CFG)).resolves.toEqual(['default', '']) + await expect(handler('k8s:namespaces')({}, 'ctx', ALLOWED_CFG)).resolves.toEqual(['default', '']) + expect(h.loads.filter((p) => p === ALLOWED_CFG)).toHaveLength(loadsBefore + 1) + }) + + it('treats empty and null kubeconfig paths as the default config', async () => { + h.api.listNamespace.mockResolvedValue({ body: { items: [] } }) + await expect(handler('k8s:namespaces')({}, 'ctx', '')).resolves.toEqual([]) + await expect(handler('k8s:namespaces')({}, 'ctx', null)).resolves.toEqual([]) + }) + + it('rejects non-string and disallowed kubeconfig paths', async () => { + await expect(handler('k8s:namespaces')({}, 'ctx', 42)).rejects.toThrow('Invalid kubeconfig path') + await expect(handler('k8s:namespaces')({}, 'ctx', '/etc/passwd')).rejects.toThrow('Access denied') + }) +}) + +describe('list handlers map API items with defaults', () => { + it('k8s:pods', async () => { + h.api.listNamespacedPod.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { name: 'api-1', namespace: 'default', creationTimestamp: 't1' }, + status: { + phase: 'Running', + containerStatuses: [ + { ready: true, restartCount: 2 }, + { ready: false, restartCount: 1 }, + ], + }, + spec: { containers: [{ name: 'app' }, { name: 'sidecar' }], nodeName: 'node-1' }, + }, + {}, + ], + }, + }) + await expect(handler('k8s:pods')({}, 'ctx', 'default')).resolves.toEqual([ + { + name: 'api-1', namespace: 'default', status: 'Running', ready: '1/2', + restarts: 3, age: 't1', node: 'node-1', containers: ['app', 'sidecar'], + }, + { + name: '', namespace: '', status: 'Unknown', ready: '0/0', + restarts: 0, age: undefined, node: '', containers: [], + }, + ]) + }) + + it('k8s:pods rejects an invalid namespace', async () => { + await expect(handler('k8s:pods')({}, 'ctx', 'bad ns')).rejects.toThrow('Invalid namespace') + }) + + it('k8s:deployments', async () => { + h.api.listNamespacedDeployment.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { name: 'web', namespace: 'default', creationTimestamp: 't1' }, + spec: { replicas: 3 }, + status: { readyReplicas: 2, availableReplicas: 2 }, + }, + {}, + ], + }, + }) + await expect(handler('k8s:deployments')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 'web', namespace: 'default', ready: '2/3', available: 2, replicas: 3, age: 't1' }, + { name: '', namespace: '', ready: '0/0', available: 0, replicas: 0, age: undefined }, + ]) + }) + + it('k8s:statefulsets', async () => { + h.api.listNamespacedStatefulSet.mockResolvedValueOnce({ + body: { + items: [ + { metadata: { name: 'db' }, spec: { replicas: 2 }, status: { readyReplicas: 1 } }, + {}, + ], + }, + }) + await expect(handler('k8s:statefulsets')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 'db', namespace: '', ready: '1/2', replicas: 2, age: undefined }, + { name: '', namespace: '', ready: '0/0', replicas: 0, age: undefined }, + ]) + }) + + it('k8s:daemonsets', async () => { + h.api.listNamespacedDaemonSet.mockResolvedValueOnce({ + body: { + items: [ + { metadata: { name: 'logs' }, status: { desiredNumberScheduled: 3, numberReady: 2, numberAvailable: 1 } }, + {}, + ], + }, + }) + await expect(handler('k8s:daemonsets')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 'logs', namespace: '', desired: 3, ready: 2, available: 1, age: undefined }, + { name: '', namespace: '', desired: 0, ready: 0, available: 0, age: undefined }, + ]) + }) + + it('k8s:replicasets filters out fully scaled-down replica sets', async () => { + h.api.listNamespacedReplicaSet.mockResolvedValueOnce({ + body: { + items: [ + { metadata: { name: 'live' }, spec: { replicas: 2 }, status: { readyReplicas: 2 } }, + { metadata: { name: 'draining' }, spec: { replicas: 0 }, status: { replicas: 1 } }, + { metadata: { name: 'dead' }, spec: { replicas: 0 }, status: { replicas: 0 } }, + {}, + ], + }, + }) + const rows = (await handler('k8s:replicasets')({}, 'ctx', 'default')) as Array<{ name: string }> + expect(rows.map((r) => r.name)).toEqual(['live', 'draining']) + expect(rows[1]).toEqual({ name: 'draining', namespace: '', desired: 0, ready: 0, age: undefined }) + }) + + it('k8s:jobs', async () => { + h.api.listNamespacedJob.mockResolvedValueOnce({ + body: { + items: [ + { metadata: { name: 'sync' }, spec: { completions: 5 }, status: { succeeded: 4, failed: 1, active: 2 } }, + {}, + ], + }, + }) + await expect(handler('k8s:jobs')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 'sync', namespace: '', completions: '4/5', failed: 1, active: 2, age: undefined }, + { name: '', namespace: '', completions: '0/1', failed: 0, active: 0, age: undefined }, + ]) + }) + + it('k8s:cronjobs', async () => { + h.api.listNamespacedCronJob.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { name: 'nightly' }, + spec: { schedule: '0 0 * * *', suspend: true }, + status: { lastScheduleTime: 't1', active: [{}, {}] }, + }, + {}, + ], + }, + }) + await expect(handler('k8s:cronjobs')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 'nightly', namespace: '', schedule: '0 0 * * *', lastSchedule: 't1', active: 2, suspended: true, age: undefined }, + { name: '', namespace: '', schedule: '', lastSchedule: null, active: 0, suspended: false, age: undefined }, + ]) + }) + + it('k8s:services covers load balancer ip, hostname, and port formats', async () => { + h.api.listNamespacedService.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { name: 'lb-ip' }, + spec: { + type: 'LoadBalancer', clusterIP: '10.0.0.1', + ports: [{ port: 80, nodePort: 30080, protocol: 'TCP' }, { port: 443, protocol: 'TCP' }], + }, + status: { loadBalancer: { ingress: [{ ip: '1.2.3.4' }] } }, + }, + { + metadata: { name: 'lb-host' }, + spec: { type: 'LoadBalancer' }, + status: { loadBalancer: { ingress: [{ hostname: 'lb.example.test' }] } }, + }, + {}, + ], + }, + }) + const rows = (await handler('k8s:services')({}, 'ctx', 'default')) as Array> + expect(rows[0]).toMatchObject({ name: 'lb-ip', externalIP: '1.2.3.4', ports: '80:30080/TCP, 443/TCP' }) + expect(rows[1]).toMatchObject({ name: 'lb-host', externalIP: 'lb.example.test', ports: '' }) + expect(rows[2]).toEqual({ name: '', namespace: '', type: '', clusterIP: '', externalIP: '', ports: '', age: undefined }) + }) + + it('k8s:ingresses covers hosts, class fallbacks, and addresses', async () => { + h.api.listNamespacedIngress.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { name: 'named-class' }, + spec: { rules: [{ host: 'a.example.test' }, {}], ingressClassName: 'nginx' }, + status: { loadBalancer: { ingress: [{ ip: '9.9.9.9' }] } }, + }, + { + metadata: { name: 'annotated', annotations: undefined }, + spec: {}, + status: { loadBalancer: { ingress: [{ hostname: 'edge.example.test' }] } }, + }, + { + metadata: { name: 'legacy', annotations: { 'kubernetes.io/ingress.class': 'traefik' } }, + }, + ], + }, + }) + const rows = (await handler('k8s:ingresses')({}, 'ctx', 'default')) as Array> + expect(rows[0]).toMatchObject({ hosts: 'a.example.test, *', address: '9.9.9.9', ingressClass: 'nginx' }) + expect(rows[1]).toMatchObject({ hosts: '', address: 'edge.example.test', ingressClass: '' }) + expect(rows[2]).toMatchObject({ hosts: '', address: '', ingressClass: 'traefik', ports: '80, 443' }) + }) + + it('k8s:configmaps and k8s:secrets count keys', async () => { + h.api.listNamespacedConfigMap.mockResolvedValueOnce({ + body: { items: [{ metadata: { name: 'cm' }, data: { a: '1', b: '2' } }, {}] }, + }) + await expect(handler('k8s:configmaps')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 'cm', namespace: '', keys: 2, age: undefined }, + { name: '', namespace: '', keys: 0, age: undefined }, + ]) + + h.api.listNamespacedSecret.mockResolvedValueOnce({ + body: { items: [{ metadata: { name: 's1' }, type: 'Opaque', data: { token: 'eA==' } }, {}] }, + }) + await expect(handler('k8s:secrets')({}, 'ctx', 'default')).resolves.toEqual([ + { name: 's1', namespace: '', type: 'Opaque', keys: 1, age: undefined }, + { name: '', namespace: '', type: '', keys: 0, age: undefined }, + ]) + }) + + it('k8s:nodes derives readiness and roles', async () => { + h.api.listNode.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { + name: 'cp-1', + labels: { 'node-role.kubernetes.io/control-plane': '', 'kubernetes.io/os': 'linux' }, + }, + status: { + conditions: [{ type: 'Ready', status: 'True' }], + capacity: { cpu: '8', memory: '32Gi' }, + nodeInfo: { osImage: 'Ubuntu', kubeletVersion: 'v1.30.0' }, + }, + }, + {}, + ], + }, + }) + await expect(handler('k8s:nodes')({}, 'ctx')).resolves.toEqual([ + { + name: 'cp-1', status: 'Ready', roles: 'control-plane', cpu: '8', memory: '32Gi', + osImage: 'Ubuntu', kubeletVersion: 'v1.30.0', age: undefined, + }, + { + name: '', status: 'NotReady', roles: 'worker', cpu: '', memory: '', + osImage: '', kubeletVersion: '', age: undefined, + }, + ]) + }) +}) + +describe('pod and deployment mutations', () => { + it('k8s:deletePod deletes the validated pod', async () => { + h.api.deleteNamespacedPod.mockResolvedValueOnce({}) + await handler('k8s:deletePod')({}, 'ctx', 'default', 'api-1') + expect(h.api.deleteNamespacedPod).toHaveBeenCalledWith('api-1', 'default') + }) + + it('k8s:scaleDeployment validates the replica count', async () => { + for (const bad of [-1, 1.5, 10_001, '3']) { + await expect(handler('k8s:scaleDeployment')({}, 'ctx', 'default', 'web', bad)).rejects.toThrow('Invalid replica count') + } + expect(h.api.patchNamespacedDeployment).not.toHaveBeenCalled() + }) + + it('k8s:scaleDeployment sends a merge patch with the new replica count', async () => { + h.api.patchNamespacedDeployment.mockResolvedValueOnce({}) + await handler('k8s:scaleDeployment')({}, 'ctx', 'default', 'web', 3) + expect(h.api.patchNamespacedDeployment).toHaveBeenCalledWith( + 'web', 'default', { spec: { replicas: 3 } }, + undefined, undefined, undefined, undefined, undefined, + { headers: { 'Content-Type': 'application/merge-patch+json' } }, + ) + }) + + it('k8s:restartDeployment patches the restartedAt annotation', async () => { + h.api.patchNamespacedDeployment.mockResolvedValueOnce({}) + await handler('k8s:restartDeployment')({}, 'ctx', 'default', 'web') + const patch = h.api.patchNamespacedDeployment.mock.calls.at(-1)?.[2] as { + spec: { template: { metadata: { annotations: Record } } } + } + expect(patch.spec.template.metadata.annotations['kubectl.kubernetes.io/restartedAt']).toMatch(/^\d{4}-\d{2}-\d{2}T/) + }) +}) + +describe('k8s:secretDetail', () => { + it('requires the app to be unlocked', async () => { + ;(isUnlocked as Mock).mockReturnValueOnce(false) + await expect(handler('k8s:secretDetail')({}, 'ctx', 'default', 's1')).rejects.toThrow('App must be unlocked to view secret values') + }) + + it('decodes base64 secret data', async () => { + h.api.readNamespacedSecret.mockResolvedValueOnce({ + body: { data: { user: Buffer.from('admin').toString('base64'), pass: Buffer.from('hunter2').toString('base64') } }, + }) + await expect(handler('k8s:secretDetail')({}, 'ctx', 'default', 's1')).resolves.toEqual({ user: 'admin', pass: 'hunter2' }) + }) + + it('returns an empty object for secrets without data', async () => { + h.api.readNamespacedSecret.mockResolvedValueOnce({ body: {} }) + await expect(handler('k8s:secretDetail')({}, 'ctx', 'default', 's1')).resolves.toEqual({}) + }) +}) + +describe('k8s:resourceDetail', () => { + const detail = (kind: unknown, name = 'thing', namespace: unknown = 'default') => + handler('k8s:resourceDetail')({}, 'ctx', namespace, kind, name) + + it('rejects unknown or non-string kinds', async () => { + await expect(detail('namespace')).rejects.toThrow('Unknown resource kind: namespace') + await expect(detail(42)).rejects.toThrow('Unknown resource kind: 42') + }) + + it.each([ + ['pod', 'readNamespacedPod'], + ['deployment', 'readNamespacedDeployment'], + ['statefulset', 'readNamespacedStatefulSet'], + ['daemonset', 'readNamespacedDaemonSet'], + ['replicaset', 'readNamespacedReplicaSet'], + ['service', 'readNamespacedService'], + ['ingress', 'readNamespacedIngress'], + ['configmap', 'readNamespacedConfigMap'], + ['job', 'readNamespacedJob'], + ['cronjob', 'readNamespacedCronJob'], + ] as const)('returns pretty JSON for %s', async (kind, apiMethod) => { + h.api[apiMethod].mockResolvedValueOnce({ body: { kind, metadata: { name: 'thing' } } }) + const json = (await detail(kind)) as string + expect(JSON.parse(json)).toEqual({ kind, metadata: { name: 'thing' } }) + expect(h.api[apiMethod]).toHaveBeenCalledWith('thing', 'default') + }) + + it('reads nodes cluster-wide without validating the namespace', async () => { + h.api.readNode.mockResolvedValueOnce({ body: { kind: 'Node' } }) + await expect(detail('node', 'cp-1', undefined)).resolves.toBe(JSON.stringify({ kind: 'Node' }, null, 2)) + expect(h.api.readNode).toHaveBeenCalledWith('cp-1') + }) + + it('redacts secret values but keeps the keys', async () => { + h.api.readNamespacedSecret.mockResolvedValueOnce({ body: { data: { user: 'YWRtaW4=', pass: 'aHVudGVyMg==' } } }) + expect(JSON.parse((await detail('secret')) as string)).toEqual({ data: { user: '', pass: '' } }) + + h.api.readNamespacedSecret.mockResolvedValueOnce({ body: { type: 'Opaque' } }) + expect(JSON.parse((await detail('secret')) as string)).toEqual({ type: 'Opaque' }) + }) +}) + +describe('k8s:logsGet', () => { + const logsGet = (tailLines: unknown = 100) => + handler('k8s:logsGet')({}, 'ctx', 'default', 'pod-1', 'main', tailLines) + + it('collects the stream into a single string and clamps tail lines', async () => { + h.logFn.mockImplementationOnce(async ( + _ns: string, _pod: string, _c: string, stream: PassThrough, cb: (err: unknown) => void, + ) => { + stream.write('line one\n') + stream.write('line two\n') + cb(null) + return {} + }) + await expect(logsGet(100)).resolves.toBe('line one\nline two\n') + expect(h.logFn.mock.calls.at(-1)?.[5]).toEqual({ follow: false, tailLines: 100 }) + + h.logFn.mockImplementationOnce(async ( + _ns: string, _pod: string, _c: string, stream: PassThrough, cb: (err: unknown) => void, + ) => { cb(null); return {} }) + await expect(logsGet('not-a-number')).resolves.toBe('') + expect(h.logFn.mock.calls.at(-1)?.[5]).toEqual({ follow: false, tailLines: 500 }) + }) + + it('rejects with ConnectionError when the stream reports an error', async () => { + h.logFn.mockImplementationOnce(async ( + _ns: string, _pod: string, _c: string, _s: PassThrough, cb: (err: unknown) => void, + ) => { cb(new Error('container not found')); return {} }) + const rejection = logsGet().catch((err: unknown) => err) + await expect(rejection).resolves.toBeInstanceOf(ConnectionError) + await expect(rejection).resolves.toMatchObject({ message: expect.stringContaining('container not found') }) + }) + + it('rejects with ConnectionError when the log request itself fails', async () => { + h.logFn.mockRejectedValueOnce(new Error('api unreachable')) + await expect(logsGet()).rejects.toThrow(ConnectionError) + }) + + it('rejects an unknown context', async () => { + await expect(handler('k8s:logsGet')({}, 'nope', 'default', 'pod-1', 'main', 100)).rejects.toThrow('Unknown kube context: nope') + }) +}) + +describe('k8s:logsStream and k8s:logsStop', () => { + interface CapturedLog { + stream: PassThrough + cb: (err: unknown) => void + req: { abort: Mock } + } + + async function startStream(event: FakeEvent = makeEvent(), req: unknown = { abort: vi.fn() }) { + const captured = {} as CapturedLog + captured.req = req as CapturedLog['req'] + h.logFn.mockImplementationOnce(async ( + _ns: string, _pod: string, _c: string, stream: PassThrough, cb: (err: unknown) => void, + ) => { + captured.stream = stream + captured.cb = cb + return req + }) + const sessionId = (await handler('k8s:logsStream')(event, 'ctx', 'default', 'pod-1', 'main', 50)) as string + return { sessionId, captured, event } + } + + it('streams chunks to the renderer and reports the end of the stream', async () => { + const { sessionId, captured, event } = await startStream() + expect(sessionId).toMatch(/^k8s_/) + expect(h.logFn.mock.calls.at(-1)?.[5]).toEqual({ follow: true, tailLines: 50 }) + + captured.stream.write('log chunk') + await flush() + expect(event.sender.send).toHaveBeenCalledWith('k8s:logChunk', sessionId, 'log chunk') + + captured.cb(null) + expect(event.sender.send).toHaveBeenCalledWith('k8s:logEnd', sessionId, null) + // The session is gone: stop becomes a no-op and abort is never called. + expect(handler('k8s:logsStop')(event, sessionId)).toBeUndefined() + expect(captured.req.abort).not.toHaveBeenCalled() + }) + + it('sends the error message when the stream ends with a failure', async () => { + const { sessionId, captured, event } = await startStream() + captured.cb(new Error('pod deleted')) + expect(event.sender.send).toHaveBeenCalledWith('k8s:logEnd', sessionId, 'pod deleted') + }) + + it('drops chunks and end notifications for destroyed senders', async () => { + const event = makeEvent(true) + const { sessionId, captured } = await startStream(event) + captured.stream.write('chunk') + await flush() + captured.cb(null) + expect(event.sender.send).not.toHaveBeenCalled() + handler('k8s:logsStop')(event, sessionId) + }) + + it('logsStop enforces ownership, tolerates bogus ids, and aborts the request', async () => { + const { sessionId, captured, event } = await startStream() + expect(handler('k8s:logsStop')(event, 42)).toBeUndefined() + expect(handler('k8s:logsStop')(event, 'k8s_missing')).toBeUndefined() + expect(() => handler('k8s:logsStop')(makeEvent(), sessionId)).toThrow(OwnershipError) + handler('k8s:logsStop')(event, sessionId) + expect(captured.req.abort).toHaveBeenCalledTimes(1) + }) + + it('disposeK8sSessionsForSender swallows abort failures and handles missing requests', async () => { + const throwing = await startStream(makeEvent(), { abort: () => { throw new Error('already aborted') } }) + const reqless = await startStream(makeEvent(), undefined) + expect(() => disposeK8sSessionsForSender(throwing.event.sender.id)).not.toThrow() + expect(() => disposeK8sSessionsForSender(reqless.event.sender.id)).not.toThrow() + expect(handler('k8s:logsStop')(throwing.event, throwing.sessionId)).toBeUndefined() + expect(handler('k8s:logsStop')(reqless.event, reqless.sessionId)).toBeUndefined() + }) +}) + +describe('k8s exec error paths', () => { + it('execStart rejects an unknown context', async () => { + await expect(handler('k8s:execStart')(makeEvent(), 'nope', 'default', 'pod-1', 'main')).rejects.toThrow('Unknown kube context: nope') + }) + + it('execStop swallows websocket close failures', async () => { + h.execFn.mockImplementationOnce(async () => ({ close: () => { throw new Error('socket already closed') } })) + const event = makeEvent() + const sessionId = (await handler('k8s:execStart')(event, 'ctx', 'default', 'pod-1', 'main')) as string + expect(() => handler('k8s:execStop')(event, sessionId)).not.toThrow() + expect(handler('k8s:execStop')(event, sessionId)).toBeUndefined() + }) +}) + +describe('k8s:portForwardStart with an explicit local port', () => { + it('binds the requested port when it is free', async () => { + const event = makeEvent() + const probe = (await handler('k8s:portForwardStart')(event, 'ctx', 'default', 'pod-1', 8080, 0)) as { id: string; localPort: number } + handler('k8s:portForwardStop')(event, probe.id) + + const explicit = (await handler('k8s:portForwardStart')(event, 'ctx', 'default', 'pod-1', 8080, probe.localPort)) as { id: string; localPort: number } + expect(explicit.localPort).toBe(probe.localPort) + handler('k8s:portForwardStop')(event, explicit.id) + }) +}) + +describe('k8s:servicePortForwardStart', () => { + const start = (event: FakeEvent, servicePort = 8080) => + handler('k8s:servicePortForwardStart')(event, 'ctx', 'default', 'svc-a', servicePort, 0) as Promise<{ id: string; localPort: number }> + + it('throws NotFoundError when the service exposes no ports', async () => { + h.api.readNamespacedService.mockResolvedValueOnce({ body: { spec: { ports: [] } } }) + const rejection = start(makeEvent()).catch((err: unknown) => err) + await expect(rejection).resolves.toBeInstanceOf(NotFoundError) + await expect(rejection).resolves.toMatchObject({ message: expect.stringContaining('Port 8080 on service svc-a not found') }) + expect(h.api.readNamespacedEndpoints).not.toHaveBeenCalled() + }) + + it('forwards to the pod backing a single-port endpoint subset', async () => { + h.api.readNamespacedService.mockResolvedValueOnce({ + body: { spec: { ports: [{ name: 'http', port: 8080 }] } }, + }) + h.api.readNamespacedEndpoints.mockResolvedValueOnce({ + body: { + subsets: [{ + ports: [{ port: 9090 }], + addresses: [{ targetRef: { kind: 'Pod', name: 'backend-1' } }], + }], + }, + }) + const event = makeEvent() + const { id, localPort } = await start(event) + expect(localPort).toBeGreaterThan(0) + expect(handler('k8s:portForwardList')(event)).toEqual([ + { id, context: 'ctx', namespace: 'default', podName: 'backend-1', targetPort: 9090, localPort, service: 'svc-a' }, + ]) + handler('k8s:portForwardStop')(event, id) + }) + + it('matches multi-port subsets by name and falls back to the first service port', async () => { + // Requested port 9999 does not exist, so the first service port spec wins. + h.api.readNamespacedService.mockResolvedValueOnce({ + body: { spec: { ports: [{ name: 'http', port: 8080 }, { name: 'metrics', port: 9100 }] } }, + }) + h.api.readNamespacedEndpoints.mockResolvedValueOnce({ + body: { + subsets: [{ + ports: [{ name: 'metrics', port: 9101 }, { name: 'http', port: 8081 }], + addresses: [{ targetRef: { kind: 'Pod', name: 'backend-2' } }], + }], + }, + }) + const event = makeEvent() + const { id } = await start(event, 9999) + const listed = handler('k8s:portForwardList')(event) as Array> + expect(listed[0]).toMatchObject({ podName: 'backend-2', targetPort: 8081, service: 'svc-a' }) + handler('k8s:portForwardStop')(event, id) + }) + + it('throws NotFoundError when no subset yields a ready pod', async () => { + h.api.readNamespacedService.mockResolvedValue({ + body: { spec: { ports: [{ name: 'http', port: 8080 }] } }, + }) + h.api.readNamespacedEndpoints + .mockResolvedValueOnce({ body: {} }) + .mockResolvedValueOnce({ + body: { + subsets: [ + { ports: [{ port: 9090 }], addresses: [{ targetRef: { kind: 'Node', name: 'not-a-pod' } }] }, + { ports: [{ name: 'grpc', port: 5000 }, { name: 'other', port: 5001 }], addresses: [{ targetRef: { kind: 'Pod', name: 'backend-3' } }] }, + ], + }, + }) + await expect(start(makeEvent())).rejects.toThrow('Ready endpoint for service svc-a not found') + await expect(start(makeEvent())).rejects.toThrow('Ready endpoint for service svc-a not found') + h.api.readNamespacedService.mockReset() + }) +}) + +describe('k8s:events with defaults', () => { + it('maps events when kind and name are missing', async () => { + h.api.listNamespacedEvent.mockResolvedValueOnce({ body: { items: [{}] } }) + const rows = (await handler('k8s:events')({}, 'ctx', 'default')) as Array> + expect(rows[0]).toEqual({ + name: '', namespace: '', type: 'Normal', reason: '', message: '', + object: 'undefined/undefined', count: 1, age: undefined, + }) + }) +}) diff --git a/src/main/ipc/__tests__/keychain.handlers.test.ts b/src/main/ipc/__tests__/keychain.handlers.test.ts new file mode 100644 index 0000000..e67a4ee --- /dev/null +++ b/src/main/ipc/__tests__/keychain.handlers.test.ts @@ -0,0 +1,466 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const { ipc, storeData, mockState } = vi.hoisted(() => ({ + ipc: { handlers: new Map unknown>() }, + storeData: new Map>(), + mockState: { throwOnSettingsStore: false }, +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn(), + }, + systemPreferences: { + canPromptTouchID: vi.fn(() => false), + promptTouchID: vi.fn(async () => {}), + }, + BrowserWindow: { + getAllWindows: vi.fn(() => []), + }, +})) + +vi.mock('electron-store', () => ({ + default: class MockStore { + private data: Map + + constructor(opts?: { name?: string; defaults?: Record }) { + const name = opts?.name ?? 'config' + if (mockState.throwOnSettingsStore && name === 'settings') { + throw new Error('settings store unavailable') + } + let data = storeData.get(name) + if (!data) { + data = new Map() + storeData.set(name, data) + } + this.data = data + for (const [key, value] of Object.entries(opts?.defaults ?? {})) { + if (!this.data.has(key)) this.data.set(key, value) + } + } + + get(key: string) { return this.data.get(key) } + set(key: string, value: unknown) { this.data.set(key, value) } + }, +})) + +vi.mock('keytar', () => ({ + default: { + setPassword: vi.fn(async () => {}), + getPassword: vi.fn(async () => null), + deletePassword: vi.fn(async () => true), + }, +})) + +import { systemPreferences, BrowserWindow } from 'electron' +import keytar from 'keytar' +import { + registerKeychainHandlers, + saveCredential, + getCredential, + deleteCredentials, + isUnlocked, + canUseTouchID, + getAuthRateLimitState, + resetAutoLockTimer, + type AuthMode, +} from '../keychain' + +registerKeychainHandlers() + +function dataFor(name: string): Map { + let data = storeData.get(name) + if (!data) { + data = new Map() + storeData.set(name, data) + } + return data +} + +function makeEvent() { + return { sender: { id: 1, send: vi.fn(), isDestroyed: () => false } } +} + +function handler(channel: string) { + const fn = ipc.handlers.get(channel) + if (!fn) throw new Error(`${channel} handler not registered`) + return fn +} + +interface AuthResult { + success: boolean + error?: string +} + +function unlock(credential?: string): Promise { + return handler('auth:unlock')(makeEvent(), credential) as Promise +} + +function lock(): void { + handler('auth:lock')(makeEvent()) +} + +function setup(mode: AuthMode | string, newCredential?: string, currentCredential?: string): Promise { + return handler('auth:setup')(makeEvent(), mode, newCredential, currentCredential) as Promise +} + +function authConfig(): { mode: string; hash?: string; salt?: string } { + return dataFor('auth').get('auth') as { mode: string; hash?: string; salt?: string } +} + +let now = new Date('2026-01-01T00:00:00Z').getTime() + +beforeEach(async () => { + vi.clearAllMocks() + vi.useFakeTimers() + // Jump an hour ahead each test so any lockout from a previous test has expired + now += 60 * 60_000 + vi.setSystemTime(now) + mockState.throwOnSettingsStore = false + dataFor('auth').set('auth', { mode: 'none' }) + dataFor('settings').clear() + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([]) + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(false) + vi.mocked(systemPreferences.promptTouchID).mockResolvedValue(undefined as never) + vi.mocked(keytar.deletePassword).mockResolvedValue(true) + // A successful unlock in 'none' mode resets the brute-force counters, + // then lock() returns the module to a clean locked state + await unlock() + lock() + vi.clearAllMocks() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('credential helpers', () => { + it('saveCredential writes to the noxed keychain service', async () => { + await saveCredential('sess-1', 'password', 'hunter2') + expect(keytar.setPassword).toHaveBeenCalledWith('noxed', 'sess-1:password', 'hunter2') + }) + + it('getCredential throws while the app is locked', async () => { + await expect(getCredential('sess-1', 'password')).rejects.toThrow('App is locked') + expect(keytar.getPassword).not.toHaveBeenCalled() + }) + + it('getCredential reads from keytar once unlocked', async () => { + await unlock() + vi.mocked(keytar.getPassword).mockResolvedValue('secret') + await expect(getCredential('sess-1', 'password')).resolves.toBe('secret') + expect(keytar.getPassword).toHaveBeenCalledWith('noxed', 'sess-1:password') + }) + + it('deleteCredentials removes the password entry', async () => { + await deleteCredentials('sess-1') + expect(keytar.deletePassword).toHaveBeenCalledWith('noxed', 'sess-1:password') + }) + + it('deleteCredentials logs and continues when keytar throws', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(keytar.deletePassword).mockRejectedValue(new Error('keychain revoked')) + await expect(deleteCredentials('sess-1')).resolves.toBeUndefined() + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('keychain revoked')) + }) +}) + +describe('canUseTouchID and auth:isAvailable', () => { + it('reflects systemPreferences availability', () => { + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(true) + expect(canUseTouchID()).toBe(true) + expect(handler('auth:isAvailable')(makeEvent())).toBe(true) + + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(false) + expect(canUseTouchID()).toBe(false) + expect(handler('auth:isAvailable')(makeEvent())).toBe(false) + }) + + it('returns false when the platform check throws', () => { + vi.mocked(systemPreferences.canPromptTouchID).mockImplementation(() => { + throw new Error('not supported') + }) + expect(canUseTouchID()).toBe(false) + }) +}) + +describe('auth:getMode and auth:isUnlocked', () => { + it('reports the configured mode', async () => { + expect(handler('auth:getMode')(makeEvent())).toBe('none') + await setup('pin', '1234') + expect(handler('auth:getMode')(makeEvent())).toBe('pin') + }) + + it('tracks the lock state across unlock and lock', async () => { + expect(handler('auth:isUnlocked')(makeEvent())).toBe(false) + expect(isUnlocked()).toBe(false) + await expect(unlock()).resolves.toEqual({ success: true }) + expect(handler('auth:isUnlocked')(makeEvent())).toBe(true) + lock() + expect(isUnlocked()).toBe(false) + }) +}) + +describe('auth:unlock — biometrics', () => { + beforeEach(async () => { + await setup('biometrics') + }) + + it('fails when Touch ID is unavailable', async () => { + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(false) + await expect(unlock()).resolves.toEqual({ + success: false, + error: 'Touch ID is not available on this device', + }) + expect(isUnlocked()).toBe(false) + }) + + it('unlocks when the Touch ID prompt succeeds', async () => { + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(true) + await expect(unlock()).resolves.toEqual({ success: true }) + expect(systemPreferences.promptTouchID).toHaveBeenCalledWith('to unlock noxed') + expect(isUnlocked()).toBe(true) + }) + + it('maps a rejected prompt to a friendly error without counting a failed attempt', async () => { + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(true) + vi.mocked(systemPreferences.promptTouchID).mockRejectedValue(new Error('user canceled')) + await expect(unlock()).resolves.toEqual({ + success: false, + error: 'Touch ID failed — please try again', + }) + expect(getAuthRateLimitState().failedAttempts).toBe(0) + }) +}) + +describe('auth:unlock — pin and password', () => { + it('requires a credential', async () => { + await setup('pin', '1234') + await expect(unlock()).resolves.toEqual({ success: false, error: 'Credential required' }) + expect(isUnlocked()).toBe(false) + expect(getAuthRateLimitState().failedAttempts).toBe(1) + }) + + it('rejects a wrong PIN and records the failure', async () => { + await setup('pin', '1234') + await expect(unlock('9999')).resolves.toEqual({ success: false, error: 'Incorrect PIN' }) + expect(isUnlocked()).toBe(false) + expect(getAuthRateLimitState().failedAttempts).toBe(1) + }) + + it('rejects a wrong password with a password-specific message', async () => { + await setup('password', 'correct horse') + await expect(unlock('wrong horse')).resolves.toEqual({ success: false, error: 'Incorrect password' }) + }) + + it('unlocks with the correct PIN and resets the failure counter', async () => { + await setup('pin', '1234') + await unlock('9999') + await expect(unlock('1234')).resolves.toEqual({ success: true }) + expect(isUnlocked()).toBe(true) + expect(getAuthRateLimitState()).toEqual({ failedAttempts: 0, lockedUntil: 0 }) + }) + + it('fails when the stored config is missing its hash or salt', async () => { + dataFor('auth').set('auth', { mode: 'pin' }) + await expect(unlock('1234')).resolves.toEqual({ success: false, error: 'Auth not configured' }) + }) + + it('treats an unrecognized mode as unlocked (default branch)', async () => { + dataFor('auth').set('auth', { mode: 'retina-scan' }) + await expect(unlock()).resolves.toEqual({ success: true }) + expect(isUnlocked()).toBe(true) + }) +}) + +describe('auth:unlock — brute-force lockout', () => { + async function failTimes(n: number): Promise { + for (let i = 0; i < n; i++) await unlock('wrong') + } + + beforeEach(async () => { + await setup('pin', '1234') + }) + + it('locks out after five failed attempts, even for the correct PIN', async () => { + await failTimes(5) + expect(getAuthRateLimitState().lockedUntil).toBe(Date.now() + 30_000) + await expect(unlock('1234')).resolves.toEqual({ + success: false, + error: 'Too many failed attempts. Try again in 30s', + }) + expect(isUnlocked()).toBe(false) + }) + + it('counts down the remaining lockout seconds', async () => { + await failTimes(5) + vi.advanceTimersByTime(12_000) + const result = await unlock('1234') + expect(result.error).toBe('Too many failed attempts. Try again in 18s') + }) + + it('allows the correct PIN again once the lockout expires', async () => { + await failTimes(5) + vi.advanceTimersByTime(30_001) + await expect(unlock('1234')).resolves.toEqual({ success: true }) + expect(getAuthRateLimitState()).toEqual({ failedAttempts: 0, lockedUntil: 0 }) + }) + + it('escalates the lockout duration on repeated failures and caps at five minutes', async () => { + await failTimes(5) + expect(getAuthRateLimitState().lockedUntil - Date.now()).toBe(30_000) + + vi.advanceTimersByTime(30_001) + await failTimes(1) // 6th failure -> 60s tier + expect(getAuthRateLimitState().lockedUntil - Date.now()).toBe(60_000) + + vi.advanceTimersByTime(60_001) + await failTimes(1) // 7th failure -> 5min tier + expect(getAuthRateLimitState().lockedUntil - Date.now()).toBe(300_000) + + vi.advanceTimersByTime(300_001) + await failTimes(1) // 8th failure -> still capped at 5min + expect(getAuthRateLimitState().lockedUntil - Date.now()).toBe(300_000) + }) +}) + +describe('auto-lock', () => { + function fakeWindow() { + return { webContents: { send: vi.fn() } } + } + + it('locks after the default 15 minutes and notifies every window', async () => { + const win = fakeWindow() + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([win] as never) + await unlock() + + vi.advanceTimersByTime(15 * 60_000 - 1) + expect(isUnlocked()).toBe(true) + + vi.advanceTimersByTime(1) + expect(isUnlocked()).toBe(false) + expect(win.webContents.send).toHaveBeenCalledWith('auth:locked') + }) + + it.each([ + ['5 minutes', 5], + ['15 minutes', 15], + ['30 minutes', 30], + ['1 hour', 60], + ])('honors the %s auto-lock setting', async (setting, minutes) => { + dataFor('settings').set('settings', { autoLockTimeout: setting }) + await unlock() + vi.advanceTimersByTime(minutes * 60_000 - 1) + expect(isUnlocked()).toBe(true) + vi.advanceTimersByTime(1) + expect(isUnlocked()).toBe(false) + }) + + it('never locks when the timeout is set to Never', async () => { + dataFor('settings').set('settings', { autoLockTimeout: 'Never' }) + await unlock() + vi.advanceTimersByTime(24 * 60 * 60_000) + expect(isUnlocked()).toBe(true) + }) + + it('falls back to 15 minutes for an unrecognized setting', async () => { + dataFor('settings').set('settings', { autoLockTimeout: '2 fortnights' }) + await unlock() + vi.advanceTimersByTime(15 * 60_000) + expect(isUnlocked()).toBe(false) + }) + + it('falls back to 15 minutes when the settings store is unavailable', async () => { + mockState.throwOnSettingsStore = true + await unlock() + vi.advanceTimersByTime(15 * 60_000) + expect(isUnlocked()).toBe(false) + }) + + it('resetAutoLockTimer restarts the countdown while unlocked', async () => { + await unlock() + vi.advanceTimersByTime(10 * 60_000) + resetAutoLockTimer() + vi.advanceTimersByTime(10 * 60_000) + expect(isUnlocked()).toBe(true) // only 10 of the fresh 15 minutes have elapsed + vi.advanceTimersByTime(5 * 60_000) + expect(isUnlocked()).toBe(false) + }) + + it('resetAutoLockTimer does nothing while locked', async () => { + resetAutoLockTimer() + vi.advanceTimersByTime(60 * 60_000) + expect(isUnlocked()).toBe(false) + expect(BrowserWindow.getAllWindows).not.toHaveBeenCalled() + }) + + it('locking manually cancels the pending auto-lock timer', async () => { + const win = fakeWindow() + vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([win] as never) + await unlock() + lock() + vi.advanceTimersByTime(60 * 60_000) + expect(win.webContents.send).not.toHaveBeenCalled() + }) +}) + +describe('auth:setup', () => { + it('sets up a PIN from scratch, storing a salted hash instead of the credential', async () => { + await expect(setup('pin', '1234')).resolves.toEqual({ success: true }) + const config = authConfig() + expect(config.mode).toBe('pin') + expect(config.hash).toMatch(/^[0-9a-f]{64}$/) + expect(config.salt).toMatch(/^[0-9a-f]{32}$/) + expect(JSON.stringify(config)).not.toContain('1234') + }) + + it('requires a credential for pin and password modes', async () => { + await expect(setup('pin')).resolves.toEqual({ + success: false, + error: 'A credential is required for this mode', + }) + expect(authConfig().mode).toBe('none') + }) + + it('refuses to change modes without the current credential', async () => { + await setup('pin', '1234') + await expect(setup('none')).resolves.toEqual({ success: false, error: 'Credential required' }) + await expect(setup('none', undefined, '9999')).resolves.toEqual({ success: false, error: 'Incorrect PIN' }) + expect(authConfig().mode).toBe('pin') + }) + + it('changes from pin to password after verifying the current PIN', async () => { + await setup('pin', '1234') + await expect(setup('password', 'long passphrase', '1234')).resolves.toEqual({ success: true }) + expect(authConfig().mode).toBe('password') + await expect(unlock('long passphrase')).resolves.toEqual({ success: true }) + }) + + it('disables auth entirely once the current PIN is verified', async () => { + await setup('pin', '1234') + await expect(setup('none', undefined, '1234')).resolves.toEqual({ success: true }) + expect(authConfig()).toEqual({ mode: 'none' }) + await expect(unlock()).resolves.toEqual({ success: true }) + }) + + it('stores biometrics mode without any hash material', async () => { + await expect(setup('biometrics')).resolves.toEqual({ success: true }) + expect(authConfig()).toEqual({ mode: 'biometrics' }) + }) + + it('verifies via Touch ID when the current mode is biometrics', async () => { + await setup('biometrics') + vi.mocked(systemPreferences.canPromptTouchID).mockReturnValue(true) + vi.mocked(systemPreferences.promptTouchID).mockRejectedValueOnce(new Error('nope')) + await expect(setup('none')).resolves.toEqual({ + success: false, + error: 'Touch ID failed — please try again', + }) + + await expect(setup('none')).resolves.toEqual({ success: true }) + expect(systemPreferences.promptTouchID).toHaveBeenCalledWith('to change noxed authentication') + }) +}) diff --git a/src/main/ipc/__tests__/localTerminal.handlers.test.ts b/src/main/ipc/__tests__/localTerminal.handlers.test.ts new file mode 100644 index 0000000..1ba8a26 --- /dev/null +++ b/src/main/ipc/__tests__/localTerminal.handlers.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' + +const { ipc, fake, osState } = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + listeners: new Map unknown>(), + }, + fake: { ptys: [] as FakePty[] }, + osState: { platform: 'darwin' }, +})) + +interface FakePty { + shell: string + args: string[] + opts: { name: string; cols: number; rows: number; cwd: string; env: Record } + dataCb?: (data: string) => void + exitCb?: (e: { exitCode: number; signal?: number }) => void + write: ReturnType + resize: ReturnType + kill: ReturnType + onData: (cb: (data: string) => void) => void + onExit: (cb: (e: { exitCode: number; signal?: number }) => void) => void +} + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.listeners.set(channel, fn) + }), + }, +})) + +vi.mock('node:os', () => ({ + homedir: () => '/home/tester', + platform: () => osState.platform, +})) + +vi.mock('node-pty', () => ({ + spawn: vi.fn((shell: string, args: string[], opts: FakePty['opts']) => { + const pty: FakePty = { + shell, + args, + opts, + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn(), + onData: (cb) => { pty.dataCb = cb }, + onExit: (cb) => { pty.exitCb = cb }, + } + fake.ptys.push(pty) + return pty + }), +})) + +import { registerLocalTerminalHandlers, defaultShell, disposeLocalPtysForSender } from '../localTerminal' +import { ValidationError, NotFoundError, OwnershipError } from '../errors' + +registerLocalTerminalHandlers() + +let nextSenderId = 1 + +interface FakeEvent { + sender: { + id: number + isDestroyed: ReturnType + send: ReturnType + } +} + +function makeEvent(): FakeEvent { + return { + sender: { + id: nextSenderId++, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }, + } +} + +function start(event: FakeEvent = makeEvent(), cols = 80, rows = 24) { + const handler = ipc.handlers.get('localpty:start') + if (!handler) throw new Error('localpty:start handler not registered') + const id = handler(event, cols, rows) as string + const pty = fake.ptys.at(-1) + if (!pty) throw new Error('no pty spawned') + return { id, pty, event } +} + +afterEach(() => { + vi.unstubAllEnvs() + vi.restoreAllMocks() + osState.platform = 'darwin' +}) + +describe('defaultShell', () => { + it('uses PowerShell on Windows', () => { + osState.platform = 'win32' + expect(defaultShell()).toEqual({ shell: 'powershell.exe', args: [] }) + }) + + it('prefers the SHELL environment variable as a login shell', () => { + vi.stubEnv('SHELL', '/opt/homebrew/bin/fish') + expect(defaultShell()).toEqual({ shell: '/opt/homebrew/bin/fish', args: ['-l'] }) + }) + + it('falls back to zsh on macOS when SHELL is unset', () => { + vi.stubEnv('SHELL', undefined) + expect(defaultShell()).toEqual({ shell: '/bin/zsh', args: ['-l'] }) + }) + + it('falls back to bash on Linux when SHELL is unset', () => { + osState.platform = 'linux' + vi.stubEnv('SHELL', undefined) + expect(defaultShell()).toEqual({ shell: '/bin/bash', args: ['-l'] }) + }) +}) + +describe('localpty:start', () => { + it('spawns a pty in the home directory and returns a uuid', () => { + const { id, pty } = start(makeEvent(), 120, 40) + expect(id).toMatch(/^[0-9a-f-]{36}$/i) + expect(pty.opts).toMatchObject({ + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: '/home/tester', + }) + expect(pty.opts.env.TERM).toBe('xterm-256color') + }) + + it('rejects invalid dimensions', () => { + const handler = ipc.handlers.get('localpty:start') + const event = makeEvent() + expect(() => handler?.(event, 0, 24)).toThrow(ValidationError) + expect(() => handler?.(event, 80, 1001)).toThrow(ValidationError) + expect(() => handler?.(event, 1.5, 24)).toThrow(ValidationError) + expect(() => handler?.(event, 80, 'tall')).toThrow(ValidationError) + }) + + it('forwards pty output to the renderer', () => { + const { id, pty, event } = start() + pty.dataCb?.('hello from shell') + expect(event.sender.send).toHaveBeenCalledWith('localpty:data', id, 'hello from shell') + }) + + it('does not send output to a destroyed renderer', () => { + const { pty, event } = start() + event.sender.isDestroyed.mockReturnValue(true) + pty.dataCb?.('late output') + expect(event.sender.send).not.toHaveBeenCalled() + }) + + it('notifies the renderer and unregisters the pty on exit', () => { + const { id, pty, event } = start() + pty.exitCb?.({ exitCode: 0 }) + expect(event.sender.send).toHaveBeenCalledWith('localpty:exit', id, 0) + + // Entry is gone: further writes are ignored + ipc.listeners.get('localpty:write')?.(event, id, 'ls\n') + expect(pty.write).not.toHaveBeenCalled() + }) + + it('does not send the exit event to a destroyed renderer', () => { + const { pty, event } = start() + event.sender.isDestroyed.mockReturnValue(true) + pty.exitCb?.({ exitCode: 1 }) + expect(event.sender.send).not.toHaveBeenCalled() + }) +}) + +describe('localpty:write', () => { + it('writes renderer input to the pty', () => { + const { id, pty, event } = start() + ipc.listeners.get('localpty:write')?.(event, id, 'echo hi\n') + expect(pty.write).toHaveBeenCalledWith('echo hi\n') + }) + + it('silently drops invalid ids, unknown ids, foreign senders, and bad payloads', () => { + const { id, pty, event } = start() + const write = ipc.listeners.get('localpty:write') + write?.(event, 42, 'x') + write?.(event, '11111111-1111-4111-8111-111111111111', 'x') + write?.(makeEvent(), id, 'stolen input') + write?.(event, id, Buffer.from('not a string')) + write?.(event, id, 'y'.repeat(65 * 1024)) + expect(pty.write).not.toHaveBeenCalled() + }) + + it('rethrows unexpected internal errors instead of swallowing them', () => { + const { id } = start() + const broken = { sender: null } as never + expect(() => ipc.listeners.get('localpty:write')?.(broken, id, 'x')).toThrow(TypeError) + }) +}) + +describe('localpty:resize', () => { + it('resizes the pty with validated dimensions', () => { + const { id, pty, event } = start() + ipc.listeners.get('localpty:resize')?.(event, id, 132, 50) + expect(pty.resize).toHaveBeenCalledWith(132, 50) + }) + + it('ignores invalid dimensions, unknown ids, and foreign senders', () => { + const { id, pty, event } = start() + const resize = ipc.listeners.get('localpty:resize') + resize?.(event, id, 0, 24) + resize?.(event, id, 80, 2000) + resize?.(event, 'not-registered', 80, 24) + resize?.(makeEvent(), id, 80, 24) + expect(pty.resize).not.toHaveBeenCalled() + }) + + it('rethrows unexpected internal errors instead of swallowing them', () => { + const { id } = start() + const broken = { sender: null } as never + expect(() => ipc.listeners.get('localpty:resize')?.(broken, id, 80, 24)).toThrow(TypeError) + }) +}) + +describe('localpty:kill', () => { + it('kills the pty and unregisters it', () => { + const { id, pty, event } = start() + ipc.handlers.get('localpty:kill')?.(event, id) + expect(pty.kill).toHaveBeenCalled() + + // Entry is gone: further writes are ignored + ipc.listeners.get('localpty:write')?.(event, id, 'x') + expect(pty.write).not.toHaveBeenCalled() + }) + + it('validates the id and enforces ownership', () => { + const { id, pty } = start() + const kill = ipc.handlers.get('localpty:kill') + expect(() => kill?.(makeEvent(), 42)).toThrow(ValidationError) + expect(() => kill?.(makeEvent(), '11111111-1111-4111-8111-111111111111')).toThrow(NotFoundError) + expect(() => kill?.(makeEvent(), id)).toThrow(OwnershipError) + expect(pty.kill).not.toHaveBeenCalled() + }) + + it('logs instead of crashing when killing the pty throws', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { id, pty, event } = start() + pty.kill.mockImplementation(() => { throw new Error('already dead') }) + ipc.handlers.get('localpty:kill')?.(event, id) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('already dead')) + }) +}) + +describe('disposeLocalPtysForSender', () => { + it('kills every pty owned by that sender and leaves others alone', () => { + const event = makeEvent() + const first = start(event) + const second = start(event) + const other = start() + + disposeLocalPtysForSender(event.sender.id) + expect(first.pty.kill).toHaveBeenCalled() + expect(second.pty.kill).toHaveBeenCalled() + expect(other.pty.kill).not.toHaveBeenCalled() + + // Disposed ptys no longer accept writes + ipc.listeners.get('localpty:write')?.(event, first.id, 'x') + expect(first.pty.write).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/__tests__/localfs.handlers.test.ts b/src/main/ipc/__tests__/localfs.handlers.test.ts new file mode 100644 index 0000000..d1862f5 --- /dev/null +++ b/src/main/ipc/__tests__/localfs.handlers.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { ipc } = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + }, +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn(), + }, +})) + +vi.mock('node:os', () => ({ + homedir: () => '/home/tester', +})) + +vi.mock('node:fs', () => ({ + readdirSync: vi.fn(), + statSync: vi.fn(), +})) + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), +})) + +import { readdirSync, statSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { registerLocalFsHandlers } from '../localfs' +import { ValidationError } from '../errors' + +registerLocalFsHandlers() + +const event = { sender: { id: 1, send: vi.fn(), isDestroyed: () => false } } + +function invoke(channel: string, ...args: unknown[]): unknown { + const handler = ipc.handlers.get(channel) + if (!handler) throw new Error(`${channel} handler not registered`) + return handler(event, ...args) +} + +function makeStats(overrides: Record = {}) { + return { + size: 5, + mtimeMs: 1111, + mode: 0o644, + isFile: () => true, + ...overrides, + } +} + +beforeEach(() => { + vi.mocked(readdirSync).mockReset() + vi.mocked(statSync).mockReset() + vi.mocked(readFile).mockReset() + vi.mocked(writeFile).mockReset() +}) + +describe('localfs:home', () => { + it('returns the home directory', () => { + expect(invoke('localfs:home')).toBe('/home/tester') + }) +}) + +describe('localfs:list', () => { + it('lists directory entries with their stats', () => { + vi.mocked(readdirSync).mockReturnValue([ + { name: 'notes.txt', isDirectory: () => false }, + { name: 'projects', isDirectory: () => true }, + ] as never) + vi.mocked(statSync).mockImplementation(((path: string) => { + if (path === '/home/tester/docs/notes.txt') return makeStats({ size: 42, mtimeMs: 1234, mode: 0o600 }) + return makeStats({ size: 0, mtimeMs: 5678, mode: 0o755, isFile: () => false }) + }) as never) + + expect(invoke('localfs:list', '/home/tester/docs')).toEqual([ + { + name: 'notes.txt', + size: 42, + mtime: 1234, + permissions: 0o600, + isDirectory: false, + path: '/home/tester/docs/notes.txt', + }, + { + name: 'projects', + size: 0, + mtime: 5678, + permissions: 0o755, + isDirectory: true, + path: '/home/tester/docs/projects', + }, + ]) + }) + + it('expands ~ to the home directory', () => { + vi.mocked(readdirSync).mockReturnValue([] as never) + expect(invoke('localfs:list', '~/docs')).toEqual([]) + expect(readdirSync).toHaveBeenCalledWith('/home/tester/docs', { withFileTypes: true }) + }) + + it('falls back to zeroed stats for entries that cannot be statted', () => { + vi.mocked(readdirSync).mockReturnValue([ + { name: 'broken-link', isDirectory: () => false }, + ] as never) + vi.mocked(statSync).mockImplementation(() => { throw new Error('ENOENT') }) + + expect(invoke('localfs:list', '/home/tester')).toEqual([ + { + name: 'broken-link', + size: 0, + mtime: 0, + permissions: 0, + isDirectory: false, + path: '/home/tester/broken-link', + }, + ]) + }) + + it('rejects paths outside the home directory and invalid paths', () => { + expect(() => invoke('localfs:list', '/etc')).toThrow(ValidationError) + expect(() => invoke('localfs:list', '/home/tester/../../etc')).toThrow(ValidationError) + expect(() => invoke('localfs:list', '')).toThrow(ValidationError) + expect(() => invoke('localfs:list', 42)).toThrow(ValidationError) + expect(readdirSync).not.toHaveBeenCalled() + }) +}) + +describe('localfs:readTextFile', () => { + it('reads a text file as utf8', async () => { + vi.mocked(statSync).mockReturnValue(makeStats() as never) + vi.mocked(readFile).mockResolvedValue(Buffer.from('hello world') as never) + await expect(invoke('localfs:readTextFile', '/home/tester/notes.txt')).resolves.toBe('hello world') + }) + + it('rejects paths outside the home directory', async () => { + await expect(invoke('localfs:readTextFile', '/etc/passwd')).rejects.toThrow(ValidationError) + expect(readFile).not.toHaveBeenCalled() + }) + + it('rejects non-regular files', async () => { + vi.mocked(statSync).mockReturnValue(makeStats({ isFile: () => false }) as never) + await expect(invoke('localfs:readTextFile', '/home/tester/somedir')).rejects.toThrow('Not a regular file') + }) + + it('rejects files with a known binary extension', async () => { + vi.mocked(statSync).mockReturnValue(makeStats() as never) + await expect(invoke('localfs:readTextFile', '/home/tester/photo.png')) + .rejects.toThrow('Cannot open binary file in editor') + expect(readFile).not.toHaveBeenCalled() + }) + + it('rejects files that are too large to open', async () => { + vi.mocked(statSync).mockReturnValue(makeStats({ size: 11 * 1024 * 1024 }) as never) + await expect(invoke('localfs:readTextFile', '/home/tester/huge.log')) + .rejects.toThrow('Cannot open binary file in editor') + }) + + it('rejects files whose content sniffs as binary', async () => { + vi.mocked(statSync).mockReturnValue(makeStats() as never) + vi.mocked(readFile).mockResolvedValue(Buffer.from('ab\0cd') as never) + await expect(invoke('localfs:readTextFile', '/home/tester/mystery')) + .rejects.toThrow('File appears to be binary') + }) +}) + +describe('localfs:writeTextFile', () => { + it('writes string content and resolves true', async () => { + await expect(invoke('localfs:writeTextFile', '~/notes.txt', 'new content')).resolves.toBe(true) + expect(writeFile).toHaveBeenCalledWith('/home/tester/notes.txt', 'new content', 'utf8') + }) + + it('rejects paths outside the home directory', async () => { + await expect(invoke('localfs:writeTextFile', '/etc/hosts', 'evil')).rejects.toThrow(ValidationError) + expect(writeFile).not.toHaveBeenCalled() + }) + + it('rejects non-string content', async () => { + await expect(invoke('localfs:writeTextFile', '/home/tester/notes.txt', Buffer.from('x'))) + .rejects.toThrow('Invalid file content') + expect(writeFile).not.toHaveBeenCalled() + }) + + it('rejects content larger than the 10MB limit', async () => { + const huge = 'x'.repeat(10 * 1024 * 1024 + 1) + await expect(invoke('localfs:writeTextFile', '/home/tester/notes.txt', huge)) + .rejects.toThrow('File content is too large') + expect(writeFile).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/__tests__/redis.handlers.test.ts b/src/main/ipc/__tests__/redis.handlers.test.ts new file mode 100644 index 0000000..0d1ed74 --- /dev/null +++ b/src/main/ipc/__tests__/redis.handlers.test.ts @@ -0,0 +1,503 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { EventEmitter } from 'node:events' + +const { ipc, fakeRedis } = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + listeners: new Map unknown>(), + }, + fakeRedis: { + instances: [] as FakeRedisClient[], + connectError: null as Error | null, + }, +})) + +interface FakeScanStream extends EventEmitter { + destroy: ReturnType +} + +interface FakeRedisClient extends EventEmitter { + options: Record + connect: ReturnType + disconnect: ReturnType + quit: ReturnType + info: ReturnType + type: ReturnType + get: ReturnType + ttl: ReturnType + hgetall: ReturnType + lrange: ReturnType + smembers: ReturnType + zrange: ReturnType + set: ReturnType + del: ReturnType + call: ReturnType + scanStream: ReturnType + lastScanStream: FakeScanStream | undefined +} + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.listeners.set(channel, fn) + }), + }, +})) + +vi.mock('ioredis', async () => { + const { EventEmitter } = await import('node:events') + + class FakeRedis extends EventEmitter { + options: Record + connect = vi.fn(async () => { + if (fakeRedis.connectError) throw fakeRedis.connectError + }) + disconnect = vi.fn() + quit = vi.fn(async () => 'OK') + info = vi.fn(async () => '# Server\r\nredis_version:7.0.0') + type = vi.fn(async () => 'string') + get = vi.fn(async () => 'the-value') + ttl = vi.fn(async () => 60) + hgetall = vi.fn(async () => ({ field: 'value' })) + lrange = vi.fn(async () => ['a', 'b']) + smembers = vi.fn(async () => ['m1', 'm2']) + zrange = vi.fn(async () => ['m1', '1', 'm2', '2']) + set = vi.fn(async () => 'OK') + del = vi.fn(async () => 2) + call = vi.fn(async () => 'PONG') + lastScanStream: FakeScanStream | undefined + scanStream = vi.fn(() => { + const stream = new EventEmitter() as FakeScanStream + stream.destroy = vi.fn() + this.lastScanStream = stream + return stream + }) + + constructor(options: Record) { + super() + this.options = options + fakeRedis.instances.push(this as unknown as FakeRedisClient) + } + } + + return { default: FakeRedis } +}) + +import { registerRedisHandlers, disposeRedisClientsForSender } from '../redis' +import { ValidationError, OwnershipError, ConnectionError, NotFoundError } from '../errors' + +registerRedisHandlers() + +let nextSenderId = 1 + +interface FakeEvent { + sender: { + id: number + isDestroyed: ReturnType + send: ReturnType + } +} + +function makeEvent(): FakeEvent { + return { + sender: { + id: nextSenderId++, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }, + } +} + +const VALID_CONFIG = { host: '127.0.0.1', port: 6379, db: 0 } + +function invoke(channel: string, ...args: unknown[]): Promise { + const handler = ipc.handlers.get(channel) + if (!handler) throw new Error(`${channel} handler not registered`) + return Promise.resolve(handler(...args)) +} + +async function connect(event: FakeEvent = makeEvent(), config: unknown = VALID_CONFIG) { + const id = (await invoke('redis:connect', event, config)) as string + const client = fakeRedis.instances.at(-1) + if (!client) throw new Error('no redis client created') + return { id, client, event } +} + +afterEach(() => { + fakeRedis.connectError = null +}) + +describe('redis:connect', () => { + it('resolves with a uuid and configures the client from the validated config', async () => { + const { id, client } = await connect(makeEvent(), { + host: 'redis.example.com', + port: '6380', + password: 'secret', + db: 3, + }) + expect(id).toMatch(/^[0-9a-f-]{36}$/i) + expect(client.options).toMatchObject({ + host: 'redis.example.com', + port: 6380, + password: 'secret', + db: 3, + lazyConnect: true, + connectTimeout: 15000, + maxRetriesPerRequest: 2, + }) + expect(client.connect).toHaveBeenCalled() + }) + + it('defaults the port and db and maps an empty password to undefined', async () => { + const { client } = await connect(makeEvent(), { host: 'localhost', password: '' }) + expect(client.options).toMatchObject({ host: 'localhost', port: 6379, db: 0 }) + expect(client.options.password).toBeUndefined() + }) + + it('gives up retrying after three attempts', async () => { + const { client } = await connect() + const retryStrategy = client.options.retryStrategy as (times: number) => number | null + expect(retryStrategy(1)).toBe(500) + expect(retryStrategy(3)).toBe(1500) + expect(retryStrategy(4)).toBeNull() + expect(retryStrategy(10)).toBeNull() + }) + + it('rejects with a ConnectionError and disposes the client when connecting fails', async () => { + fakeRedis.connectError = new Error('ECONNREFUSED 127.0.0.1:6379') + const pending = invoke('redis:connect', makeEvent(), VALID_CONFIG) + await expect(pending).rejects.toThrow(ConnectionError) + await expect(pending).rejects.toThrow('ECONNREFUSED') + const client = fakeRedis.instances.at(-1) + expect(client?.disconnect).toHaveBeenCalled() + expect(client?.listenerCount('error')).toBe(0) + }) + + it('records runtime errors without crashing the process', async () => { + const { client } = await connect() + expect(() => client.emit('error', new Error('read ECONNRESET'))).not.toThrow() + }) + + it('rejects malformed configs', async () => { + const event = makeEvent() + await expect(invoke('redis:connect', event, null)).rejects.toThrow(ValidationError) + await expect(invoke('redis:connect', event, 'nope')).rejects.toThrow(ValidationError) + await expect(invoke('redis:connect', event, { host: 'bad host!' })).rejects.toThrow('illegal characters') + await expect(invoke('redis:connect', event, { host: '' })).rejects.toThrow('Invalid Redis host') + await expect(invoke('redis:connect', event, { host: 'localhost', port: 0 })).rejects.toThrow('Invalid Redis port') + await expect(invoke('redis:connect', event, { host: 'localhost', port: 70000 })).rejects.toThrow('Invalid Redis port') + await expect(invoke('redis:connect', event, { host: 'localhost', password: 42 })).rejects.toThrow('Invalid Redis password') + await expect(invoke('redis:connect', event, { host: 'localhost', db: 16 })).rejects.toThrow('Invalid Redis database number') + await expect(invoke('redis:connect', event, { host: 'localhost', db: -1 })).rejects.toThrow('Invalid Redis database number') + await expect(invoke('redis:connect', event, { host: 'localhost', db: 1.5 })).rejects.toThrow('Invalid Redis database number') + }) +}) + +describe('ownership and id validation', () => { + it('rejects invalid client ids with a ValidationError', async () => { + const event = makeEvent() + await expect(invoke('redis:info', event, 'not-a-uuid')).rejects.toThrow(ValidationError) + await expect(invoke('redis:info', event, 42)).rejects.toThrow(ValidationError) + await expect(invoke('redis:info', event, undefined)).rejects.toThrow(ValidationError) + }) + + it('rejects unknown client ids with a NotFoundError', async () => { + await expect(invoke('redis:info', makeEvent(), '11111111-1111-4111-8111-111111111111')) + .rejects.toThrow(NotFoundError) + }) + + it('rejects every channel when another window supplies a foreign sender id', async () => { + const { id } = await connect() + const intruder = makeEvent() + await expect(invoke('redis:disconnect', intruder, id)).rejects.toThrow(OwnershipError) + await expect(invoke('redis:info', intruder, id)).rejects.toThrow(OwnershipError) + await expect(invoke('redis:keys', intruder, id, '*')).rejects.toThrow(OwnershipError) + await expect(invoke('redis:get', intruder, id, 'k')).rejects.toThrow(OwnershipError) + await expect(invoke('redis:set', intruder, id, 'k', 'v')).rejects.toThrow(OwnershipError) + await expect(invoke('redis:del', intruder, id, 'k')).rejects.toThrow(OwnershipError) + await expect(invoke('redis:command', intruder, id, 'PING')).rejects.toThrow(OwnershipError) + }) +}) + +describe('redis:info', () => { + it('returns the raw INFO payload for the owner', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:info', event, id)).resolves.toContain('redis_version') + expect(client.info).toHaveBeenCalled() + }) +}) + +describe('redis:keys', () => { + it('scans with the given pattern and resolves deduplicated keys', async () => { + const { id, client, event } = await connect() + const pending = invoke('redis:keys', event, id, 'user:*') + await new Promise((r) => setImmediate(r)) + expect(client.scanStream).toHaveBeenCalledWith({ match: 'user:*', count: 200 }) + const stream = client.lastScanStream + if (!stream) throw new Error('no scan stream created') + stream.emit('data', ['user:1', 'user:2']) + stream.emit('data', ['user:2', 'user:3']) + stream.emit('end') + await expect(pending).resolves.toEqual(['user:1', 'user:2', 'user:3']) + }) + + it('falls back to * when the pattern is missing or empty', async () => { + const { id, client, event } = await connect() + const first = invoke('redis:keys', event, id, undefined) + await new Promise((r) => setImmediate(r)) + client.lastScanStream?.emit('end') + await expect(first).resolves.toEqual([]) + expect(client.scanStream).toHaveBeenLastCalledWith({ match: '*', count: 200 }) + + const second = invoke('redis:keys', event, id, '') + await new Promise((r) => setImmediate(r)) + client.lastScanStream?.emit('end') + await expect(second).resolves.toEqual([]) + expect(client.scanStream).toHaveBeenLastCalledWith({ match: '*', count: 200 }) + }) + + it('stops scanning and truncates once 10k keys have arrived', async () => { + const { id, client, event } = await connect() + const pending = invoke('redis:keys', event, id, '*') + await new Promise((r) => setImmediate(r)) + const stream = client.lastScanStream + if (!stream) throw new Error('no scan stream created') + const batch = Array.from({ length: 10_001 }, (_, i) => `key:${i}`) + stream.emit('data', batch) + expect(stream.destroy).toHaveBeenCalled() + stream.emit('end') + const keys = (await pending) as string[] + expect(keys).toHaveLength(10_000) + }) + + it('rejects with a ConnectionError when the scan stream errors', async () => { + const { id, client, event } = await connect() + const pending = invoke('redis:keys', event, id, '*') + await new Promise((r) => setImmediate(r)) + client.lastScanStream?.emit('error', new Error('LOADING Redis is loading the dataset')) + await expect(pending).rejects.toThrow(ConnectionError) + await expect(pending).rejects.toThrow('LOADING') + }) + + it('rejects oversized and non-string patterns', async () => { + const { id, event } = await connect() + await expect(invoke('redis:keys', event, id, 'p'.repeat(513))).rejects.toThrow('pattern exceeds 512 bytes') + await expect(invoke('redis:keys', event, id, 42)).rejects.toThrow(ValidationError) + }) +}) + +describe('redis:get', () => { + it('returns string values with their ttl', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:get', event, id, 'greeting')).resolves.toEqual({ + type: 'string', + value: 'the-value', + ttl: 60, + }) + expect(client.get).toHaveBeenCalledWith('greeting') + }) + + it('returns hash, list, set, and zset values by type', async () => { + const { id, client, event } = await connect() + + client.type.mockResolvedValueOnce('hash') + await expect(invoke('redis:get', event, id, 'h')).resolves.toEqual({ + type: 'hash', + value: { field: 'value' }, + ttl: 60, + }) + expect(client.hgetall).toHaveBeenCalledWith('h') + + client.type.mockResolvedValueOnce('list') + await expect(invoke('redis:get', event, id, 'l')).resolves.toEqual({ + type: 'list', + value: ['a', 'b'], + ttl: 60, + }) + expect(client.lrange).toHaveBeenCalledWith('l', 0, 99) + + client.type.mockResolvedValueOnce('set') + await expect(invoke('redis:get', event, id, 's')).resolves.toEqual({ + type: 'set', + value: ['m1', 'm2'], + ttl: 60, + }) + expect(client.smembers).toHaveBeenCalledWith('s') + + client.type.mockResolvedValueOnce('zset') + await expect(invoke('redis:get', event, id, 'z')).resolves.toEqual({ + type: 'zset', + value: ['m1', '1', 'm2', '2'], + ttl: 60, + }) + expect(client.zrange).toHaveBeenCalledWith('z', 0, 99, 'WITHSCORES') + }) + + it('returns a null value for missing or exotic types', async () => { + const { id, client, event } = await connect() + client.type.mockResolvedValueOnce('none') + await expect(invoke('redis:get', event, id, 'gone')).resolves.toEqual({ + type: 'none', + value: null, + ttl: -1, + }) + }) + + it('rejects oversized and non-string keys', async () => { + const { id, event } = await connect() + await expect(invoke('redis:get', event, id, 'k'.repeat(4 * 1024 + 1))).rejects.toThrow('key exceeds 4096 bytes') + await expect(invoke('redis:get', event, id, { key: 'x' })).rejects.toThrow(ValidationError) + }) +}) + +describe('redis:set', () => { + it('sets a plain value without a ttl', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:set', event, id, 'k', 'v')).resolves.toBe('OK') + expect(client.set).toHaveBeenCalledWith('k', 'v') + }) + + it('treats a null ttl like no ttl', async () => { + const { id, client, event } = await connect() + await invoke('redis:set', event, id, 'k', 'v', null) + expect(client.set).toHaveBeenCalledWith('k', 'v') + }) + + it('applies a positive ttl with EX, flooring fractions', async () => { + const { id, client, event } = await connect() + await invoke('redis:set', event, id, 'k', 'v', 90.7) + expect(client.set).toHaveBeenCalledWith('k', 'v', 'EX', 90) + }) + + it('accepts a numeric string ttl', async () => { + const { id, client, event } = await connect() + await invoke('redis:set', event, id, 'k', 'v', '30') + expect(client.set).toHaveBeenCalledWith('k', 'v', 'EX', 30) + }) + + it('a ttl of zero means no expiry', async () => { + const { id, client, event } = await connect() + await invoke('redis:set', event, id, 'k', 'v', 0) + expect(client.set).toHaveBeenCalledWith('k', 'v') + }) + + it('rejects invalid ttls', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:set', event, id, 'k', 'v', -1)).rejects.toThrow('Invalid TTL') + await expect(invoke('redis:set', event, id, 'k', 'v', 'soon')).rejects.toThrow('Invalid TTL') + await expect(invoke('redis:set', event, id, 'k', 'v', Infinity)).rejects.toThrow('Invalid TTL') + await expect(invoke('redis:set', event, id, 'k', 'v', 60 * 60 * 24 * 365 + 1)).rejects.toThrow('Invalid TTL') + expect(client.set).not.toHaveBeenCalled() + }) + + it('rejects oversized values and invalid keys', async () => { + const { id, event } = await connect() + await expect(invoke('redis:set', event, id, 'k', 'v'.repeat(1024 * 1024 + 1))) + .rejects.toThrow('value exceeds 1048576 bytes') + await expect(invoke('redis:set', event, id, 'k', 42)).rejects.toThrow('Invalid Redis value') + await expect(invoke('redis:set', event, id, null, 'v')).rejects.toThrow('Invalid Redis key') + }) +}) + +describe('redis:del', () => { + it('deletes the given keys', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:del', event, id, 'a', 'b')).resolves.toBe(2) + expect(client.del).toHaveBeenCalledWith('a', 'b') + }) + + it('returns 0 without touching redis when no keys are given', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:del', event, id)).resolves.toBe(0) + expect(client.del).not.toHaveBeenCalled() + }) + + it('rejects more than 256 keys per call', async () => { + const { id, client, event } = await connect() + const keys = Array.from({ length: 257 }, (_, i) => `k${i}`) + await expect(invoke('redis:del', event, id, ...keys)) + .rejects.toThrow('Cannot delete more than 256 keys at once') + expect(client.del).not.toHaveBeenCalled() + }) + + it('rejects non-string keys', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:del', event, id, 'ok', 42)).rejects.toThrow('Invalid Redis key') + expect(client.del).not.toHaveBeenCalled() + }) +}) + +describe('redis:command', () => { + it('dispatches the verb and arguments through client.call', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:command', event, id, ' GET user:1 ')).resolves.toBe('PONG') + expect(client.call).toHaveBeenCalledWith('GET', 'user:1') + }) + + it('handles single-word commands', async () => { + const { id, client, event } = await connect() + await invoke('redis:command', event, id, 'PING') + expect(client.call).toHaveBeenCalledWith('PING') + }) + + it('rejects blocked commands regardless of case, naming the verb', async () => { + const { id, client, event } = await connect() + await expect(invoke('redis:command', event, id, 'flushall')) + .rejects.toThrow('Command blocked for safety: "FLUSHALL" is not allowed') + await expect(invoke('redis:command', event, id, 'CONFIG GET maxmemory')) + .rejects.toThrow('"CONFIG" is not allowed') + await expect(invoke('redis:command', event, id, 'Shutdown NOSAVE')).rejects.toThrow(ValidationError) + expect(client.call).not.toHaveBeenCalled() + }) + + it('rejects empty, non-string, and oversized commands', async () => { + const { id, event } = await connect() + await expect(invoke('redis:command', event, id, ' ')).rejects.toThrow('Empty command') + await expect(invoke('redis:command', event, id, 42)).rejects.toThrow('Invalid Redis command') + await expect(invoke('redis:command', event, id, `GET ${'x'.repeat(16 * 1024)}`)) + .rejects.toThrow('command exceeds 16384 bytes') + }) + + it('wraps execution failures in a ConnectionError', async () => { + const { id, client, event } = await connect() + client.call.mockRejectedValueOnce(new Error('ERR unknown command')) + const pending = invoke('redis:command', event, id, 'NOTACOMMAND') + await expect(pending).rejects.toThrow(ConnectionError) + await expect(pending).rejects.toThrow('ERR unknown command') + }) +}) + +describe('redis:disconnect and cleanup', () => { + it('quits gracefully and disposes the client', async () => { + const { id, client, event } = await connect() + await invoke('redis:disconnect', event, id) + expect(client.quit).toHaveBeenCalled() + expect(client.disconnect).toHaveBeenCalled() + + // The client is gone: further calls report NotFoundError + await expect(invoke('redis:info', event, id)).rejects.toThrow(NotFoundError) + }) + + it('force-disconnects when quit fails', async () => { + const { id, client, event } = await connect() + client.quit.mockRejectedValueOnce(new Error('Connection is closed.')) + await invoke('redis:disconnect', event, id) + expect(client.disconnect).toHaveBeenCalled() + await expect(invoke('redis:info', event, id)).rejects.toThrow(NotFoundError) + }) + + it('disposeRedisClientsForSender tears down only that sender\'s clients', async () => { + const mine = await connect() + const other = await connect() + disposeRedisClientsForSender(mine.event.sender.id) + + expect(mine.client.disconnect).toHaveBeenCalled() + expect(other.client.disconnect).not.toHaveBeenCalled() + + await expect(invoke('redis:info', mine.event, mine.id)).rejects.toThrow(NotFoundError) + await expect(invoke('redis:info', other.event, other.id)).resolves.toContain('redis_version') + }) +}) diff --git a/src/main/ipc/__tests__/runner.handlers.test.ts b/src/main/ipc/__tests__/runner.handlers.test.ts new file mode 100644 index 0000000..dfe6c68 --- /dev/null +++ b/src/main/ipc/__tests__/runner.handlers.test.ts @@ -0,0 +1,350 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { EventEmitter } from 'node:events' + +const { ipc } = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + listeners: new Map unknown>(), + }, +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.listeners.set(channel, fn) + }), + }, +})) + +vi.mock('../sshClients', () => ({ + connectSessionClient: vi.fn(), +})) + +vi.mock('../sessions', () => ({ + getSessionById: vi.fn(), +})) + +import { registerRunnerHandlers, disposeRunsForSender } from '../runner' +import { connectSessionClient } from '../sshClients' +import { getSessionById } from '../sessions' +import type { Session } from '../sessions' +import { NotFoundError, OwnershipError, ValidationError } from '../errors' + +registerRunnerHandlers() + +interface FakeExecStream extends EventEmitter { + stderr: EventEmitter +} + +interface FakeConn { + client: { exec: (command: string, cb: (err: Error | null, stream?: FakeExecStream) => void) => void } + dispose: ReturnType + execCalls: Array<{ command: string; cb: (err: Error | null, stream?: FakeExecStream) => void }> +} + +function makeStream(): FakeExecStream { + const stream = new EventEmitter() as FakeExecStream + stream.stderr = new EventEmitter() + return stream +} + +function makeConn(): FakeConn { + const execCalls: FakeConn['execCalls'] = [] + return { + execCalls, + dispose: vi.fn(), + client: { + exec: (command, cb) => { + execCalls.push({ command, cb }) + }, + }, + } +} + +let nextSenderId = 1 + +interface FakeEvent { + sender: { + id: number + isDestroyed: ReturnType + send: ReturnType + } +} + +function makeEvent(): FakeEvent { + return { + sender: { + id: nextSenderId++, + isDestroyed: vi.fn(() => false), + send: vi.fn(), + }, + } +} + +function sshSession(id: string): Session { + return { id, label: id, host: `${id}.example.com`, port: 22, username: 'deploy', authType: 'password', createdAt: 0, type: 'ssh' } +} + +function run(event: FakeEvent, sessionIds: unknown = ['s1'], command: unknown = 'uptime'): string { + const handler = ipc.handlers.get('runner:run') + if (!handler) throw new Error('runner:run handler not registered') + return handler(event, sessionIds, command) as string +} + +function cancel(event: FakeEvent, runId: unknown): unknown { + const handler = ipc.handlers.get('runner:cancel') + if (!handler) throw new Error('runner:cancel handler not registered') + return handler(event, runId) +} + +// Flushes the microtask hops inside runOnHost without relying on real timers +async function flush(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve() +} + +function doneCalls(event: FakeEvent) { + return event.sender.send.mock.calls.filter((c) => c[0] === 'runner:done') +} + +function outputCalls(event: FakeEvent) { + return event.sender.send.mock.calls.filter((c) => c[0] === 'runner:output') +} + +beforeEach(() => { + vi.mocked(getSessionById).mockReset() + vi.mocked(getSessionById).mockImplementation((id: string) => sshSession(id)) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.mocked(connectSessionClient).mockReset() +}) + +describe('runner:run validation', () => { + it('rejects when a session does not exist', () => { + vi.mocked(getSessionById).mockReturnValue(undefined) + expect(() => run(makeEvent())).toThrow(NotFoundError) + }) + + it('rejects non-SSH sessions, naming them by label or host', () => { + vi.mocked(getSessionById).mockReturnValue({ ...sshSession('db1'), type: 'database' }) + expect(() => run(makeEvent(), ['db1'])).toThrow(ValidationError) + expect(() => run(makeEvent(), ['db1'])).toThrow('db1 is not an SSH connection') + + vi.mocked(getSessionById).mockReturnValue({ ...sshSession('db1'), label: '', type: 'redis' }) + expect(() => run(makeEvent(), ['db1'])).toThrow('db1.example.com is not an SSH connection') + }) + + it('rejects malformed requests before touching sessions', () => { + expect(() => run(makeEvent(), [], 'uptime')).toThrow(ValidationError) + expect(() => run(makeEvent(), ['s1'], ' ')).toThrow(ValidationError) + expect(getSessionById).not.toHaveBeenCalled() + }) + + it('accepts sessions without an explicit type as SSH', async () => { + vi.mocked(getSessionById).mockReturnValue({ ...sshSession('s1'), type: undefined }) + vi.mocked(connectSessionClient).mockResolvedValue(makeConn() as never) + expect(run(makeEvent())).toMatch(/^[0-9a-f-]{36}$/i) + await flush() + }) +}) + +describe('runner:run execution', () => { + it('executes the command per host and streams stdout/stderr to the renderer', async () => { + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const event = makeEvent() + + const runId = run(event, ['s1'], 'uptime') + expect(runId).toMatch(/^[0-9a-f-]{36}$/i) + await flush() + + expect(connectSessionClient).toHaveBeenCalledWith('s1') + expect(conn.execCalls).toHaveLength(1) + expect(conn.execCalls[0].command).toBe('uptime') + + const stream = makeStream() + conn.execCalls[0].cb(null, stream) + stream.emit('data', Buffer.from('load average')) + stream.stderr.emit('data', Buffer.from('a warning')) + + expect(event.sender.send).toHaveBeenCalledWith('runner:output', runId, 's1', 'load average') + expect(event.sender.send).toHaveBeenCalledWith('runner:output', runId, 's1', 'a warning') + + stream.emit('close', 0) + expect(event.sender.send).toHaveBeenCalledWith('runner:done', runId, 's1', 0, null) + expect(conn.dispose).toHaveBeenCalled() + }) + + it('runs on every host and reports each outcome independently', async () => { + const conns = [makeConn(), makeConn()] + let next = 0 + vi.mocked(connectSessionClient).mockImplementation(async () => conns[next++] as never) + const event = makeEvent() + + const runId = run(event, ['s1', 's2'], 'hostname') + await flush() + + const streamA = makeStream() + conns[0].execCalls[0].cb(null, streamA) + streamA.emit('close', 0) + + const streamB = makeStream() + conns[1].execCalls[0].cb(null, streamB) + streamB.emit('error', new Error('broken pipe')) + + expect(event.sender.send).toHaveBeenCalledWith('runner:done', runId, 's1', 0, null) + expect(event.sender.send).toHaveBeenCalledWith('runner:done', runId, 's2', null, 'broken pipe') + + // All hosts finished, so the run is gone: cancelling is a silent no-op + expect(cancel(makeEvent(), runId)).toBeUndefined() + }) + + it('reports a connection failure without an exit code', async () => { + vi.mocked(connectSessionClient).mockRejectedValue(new Error('no route to host')) + const event = makeEvent() + const runId = run(event) + await flush() + expect(event.sender.send).toHaveBeenCalledWith('runner:done', runId, 's1', null, 'no route to host') + }) + + it('reports an exec failure and disposes the connection', async () => { + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const event = makeEvent() + const runId = run(event) + await flush() + + conn.execCalls[0].cb(new Error('exec rejected by server')) + expect(event.sender.send).toHaveBeenCalledWith('runner:done', runId, 's1', null, 'exec rejected by server') + expect(conn.dispose).toHaveBeenCalled() + }) + + it('times out after 120s and ignores the late close', async () => { + vi.useFakeTimers() + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const event = makeEvent() + const runId = run(event) + await flush() + + const stream = makeStream() + conn.execCalls[0].cb(null, stream) + await vi.advanceTimersByTimeAsync(120_000) + + expect(event.sender.send).toHaveBeenCalledWith('runner:done', runId, 's1', null, 'Timed out after 120s') + + // The eventual close of the dead stream must not double-report the host + stream.emit('close', 0) + expect(doneCalls(event)).toHaveLength(1) + }) + + it('stops forwarding output past the per-host cap', async () => { + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const event = makeEvent() + run(event) + await flush() + + const stream = makeStream() + conn.execCalls[0].cb(null, stream) + stream.emit('data', Buffer.alloc(1024 * 1024, 'a')) + stream.emit('data', Buffer.from('over the limit')) + + expect(outputCalls(event)).toHaveLength(1) + }) + + it('does not send output or completion to a destroyed renderer', async () => { + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const event = makeEvent() + run(event) + await flush() + + const stream = makeStream() + conn.execCalls[0].cb(null, stream) + event.sender.isDestroyed.mockReturnValue(true) + stream.emit('data', Buffer.from('too late')) + stream.emit('close', 0) + + expect(event.sender.send).not.toHaveBeenCalled() + expect(conn.dispose).toHaveBeenCalled() + }) +}) + +describe('runner:cancel', () => { + it('validates the run id and ignores unknown runs', () => { + const event = makeEvent() + expect(() => cancel(event, 42)).toThrow(ValidationError) + expect(cancel(event, 'not-a-known-run')).toBeUndefined() + }) + + it('refuses to cancel another window\'s run', async () => { + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const owner = makeEvent() + const runId = run(owner) + await flush() + + expect(() => cancel(makeEvent(), runId)).toThrow(OwnershipError) + }) + + it('disposes connections and timers, and later stream events are ignored', async () => { + vi.useFakeTimers() + const conn = makeConn() + vi.mocked(connectSessionClient).mockResolvedValue(conn as never) + const event = makeEvent() + const runId = run(event) + await flush() + + const stream = makeStream() + conn.execCalls[0].cb(null, stream) + cancel(event, runId) + expect(conn.dispose).toHaveBeenCalled() + + // Neither the timeout nor the eventual close report anything after cancel + await vi.advanceTimersByTimeAsync(120_000) + stream.emit('close', 0) + expect(doneCalls(event)).toHaveLength(0) + }) + + it('disposes a connection that finishes connecting after the cancel', async () => { + const conn = makeConn() + let release: (value: FakeConn) => void = () => {} + vi.mocked(connectSessionClient).mockImplementation( + () => new Promise((resolve) => { release = resolve as never }) as never + ) + const event = makeEvent() + const runId = run(event) + await flush() + + cancel(event, runId) + release(conn) + await flush() + + expect(conn.dispose).toHaveBeenCalled() + expect(conn.execCalls).toHaveLength(0) + }) +}) + +describe('disposeRunsForSender', () => { + it('tears down only the runs owned by that sender', async () => { + const conns = [makeConn(), makeConn()] + let next = 0 + vi.mocked(connectSessionClient).mockImplementation(async () => conns[next++] as never) + + const mine = makeEvent() + const theirs = makeEvent() + run(mine, ['s1']) + run(theirs, ['s2']) + await flush() + + disposeRunsForSender(mine.sender.id) + expect(conns[0].dispose).toHaveBeenCalled() + expect(conns[1].dispose).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/__tests__/sessions.handlers.test.ts b/src/main/ipc/__tests__/sessions.handlers.test.ts new file mode 100644 index 0000000..0f18625 --- /dev/null +++ b/src/main/ipc/__tests__/sessions.handlers.test.ts @@ -0,0 +1,372 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { ipc, storeData } = vi.hoisted(() => ({ + ipc: { handlers: new Map unknown>() }, + storeData: new Map>(), +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn(), + }, + dialog: { + showSaveDialog: vi.fn(), + showOpenDialog: vi.fn(), + }, + BrowserWindow: { + fromWebContents: vi.fn(), + }, +})) + +vi.mock('electron-store', () => ({ + default: class MockStore { + private data: Map + + constructor(opts?: { name?: string; defaults?: Record }) { + const name = opts?.name ?? 'config' + let data = storeData.get(name) + if (!data) { + data = new Map() + storeData.set(name, data) + } + this.data = data + for (const [key, value] of Object.entries(opts?.defaults ?? {})) { + if (!this.data.has(key)) this.data.set(key, value) + } + } + + get(key: string) { return this.data.get(key) } + set(key: string, value: unknown) { this.data.set(key, value) } + }, +})) + +vi.mock('../keychain', () => ({ + saveCredential: vi.fn(async () => {}), + getCredential: vi.fn(async () => null), + deleteCredentials: vi.fn(async () => {}), + isUnlocked: vi.fn(() => true), +})) + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(async () => ''), + writeFile: vi.fn(async () => {}), +})) + +import { dialog, BrowserWindow } from 'electron' +import { readFile, writeFile } from 'node:fs/promises' +import { registerSessionHandlers, getSessionById, listSessions, type Session } from '../sessions' +import { saveCredential, getCredential, deleteCredentials, isUnlocked } from '../keychain' +import { ValidationError } from '../errors' + +registerSessionHandlers() + +interface StoredSession extends Session { + password?: string +} + +function seed(sessions: StoredSession[]): void { + storeData.get('config')?.set('sessions', sessions) +} + +function stored(): StoredSession[] { + return storeData.get('config')?.get('sessions') as StoredSession[] +} + +function makeEvent() { + return { sender: { id: 1, send: vi.fn(), isDestroyed: () => false } } +} + +function invoke(channel: string, ...args: unknown[]): T { + const handler = ipc.handlers.get(channel) + if (!handler) throw new Error(`${channel} handler not registered`) + return handler(makeEvent(), ...args) as T +} + +function makeSession(overrides: Partial = {}): StoredSession { + return { + id: 'session-1', + label: 'Prod', + host: 'prod.example.com', + port: 22, + username: 'root', + authType: 'password', + createdAt: 1000, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isUnlocked).mockReturnValue(true) + vi.mocked(getCredential).mockResolvedValue(null) + vi.mocked(BrowserWindow.fromWebContents).mockReturnValue({} as never) + seed([]) +}) + +describe('getSessionById and listSessions', () => { + it('returns the session without its legacy password', () => { + seed([makeSession({ password: 'legacy' })]) + const session = getSessionById('session-1') + expect(session).toBeDefined() + expect(session).not.toHaveProperty('password') + expect(session?.host).toBe('prod.example.com') + }) + + it('returns undefined for an unknown id', () => { + seed([makeSession()]) + expect(getSessionById('nope')).toBeUndefined() + }) + + it('listSessions strips passwords from every session', () => { + seed([makeSession({ password: 'legacy' }), makeSession({ id: 'session-2' })]) + const sessions = listSessions() + expect(sessions).toHaveLength(2) + for (const s of sessions) expect(s).not.toHaveProperty('password') + }) +}) + +describe('sessions:list', () => { + it('returns sessions with derived hasPassword flags', async () => { + seed([ + makeSession({ id: 'a', hasPassword: true }), + makeSession({ id: 'b' }), + ]) + const result = await invoke>('sessions:list') + expect(result).toHaveLength(2) + expect(result[0].hasPassword).toBe(true) + expect(result[1].hasPassword).toBe(false) + }) + + it('migrates legacy plaintext passwords into the keychain', async () => { + seed([ + makeSession({ id: 'legacy', password: 'old-secret' }), + makeSession({ id: 'clean' }), + ]) + const result = await invoke>('sessions:list') + + expect(saveCredential).toHaveBeenCalledTimes(1) + expect(saveCredential).toHaveBeenCalledWith('legacy', 'password', 'old-secret') + expect(result[0]).not.toHaveProperty('password') + expect(result[0].hasPassword).toBe(true) + expect(stored()[0]).not.toHaveProperty('password') + expect(stored()[0].hasPassword).toBe(true) + }) + + it('does not migrate again on subsequent lists', async () => { + seed([makeSession({ id: 'legacy', password: 'old-secret' })]) + await invoke>('sessions:list') + await invoke>('sessions:list') + expect(saveCredential).toHaveBeenCalledTimes(1) + }) +}) + +describe('sessions:create', () => { + it('creates a session with a generated id and createdAt', async () => { + const before = Date.now() + const session = await invoke>('sessions:create', { + label: 'New', host: 'new.example.com', port: 2222, username: 'deploy', authType: 'key', + }) + expect(session.id).toMatch(/^[0-9a-f-]{36}$/i) + expect(session.createdAt).toBeGreaterThanOrEqual(before) + expect(session.hasPassword).toBe(false) + expect(saveCredential).not.toHaveBeenCalled() + expect(stored()).toHaveLength(1) + expect(stored()[0].host).toBe('new.example.com') + }) + + it('stores the password in the keychain, never in the store', async () => { + const session = await invoke>('sessions:create', { + label: 'PW', host: 'pw.example.com', port: 22, username: 'u', authType: 'password', password: 'hunter2', + }) + expect(saveCredential).toHaveBeenCalledWith(session.id, 'password', 'hunter2') + expect(session.hasPassword).toBe(true) + expect(session).not.toHaveProperty('password') + expect(stored()[0]).not.toHaveProperty('password') + }) + + it('ignores a renderer-claimed hasPassword flag', async () => { + const session = await invoke>('sessions:create', { + label: 'Claim', host: 'c.example.com', port: 22, username: 'u', authType: 'password', hasPassword: true, + }) + expect(session.hasPassword).toBe(false) + }) +}) + +describe('sessions:update', () => { + it('merges fields and returns the updated session', async () => { + seed([makeSession(), makeSession({ id: 'session-2', label: 'Other' })]) + const updated = await invoke>('sessions:update', 'session-1', { label: 'Renamed', port: 2200 }) + expect(updated.label).toBe('Renamed') + expect(updated.port).toBe(2200) + expect(updated.host).toBe('prod.example.com') + expect(stored()[1].label).toBe('Other') + expect(saveCredential).not.toHaveBeenCalled() + expect(deleteCredentials).not.toHaveBeenCalled() + }) + + it('saves a new password to the keychain and sets hasPassword', async () => { + seed([makeSession()]) + const updated = await invoke>('sessions:update', 'session-1', { password: 'new-pw' }) + expect(saveCredential).toHaveBeenCalledWith('session-1', 'password', 'new-pw') + expect(updated.hasPassword).toBe(true) + expect(stored()[0]).not.toHaveProperty('password') + }) + + it('deletes credentials when the password is cleared with an empty string', async () => { + seed([makeSession({ hasPassword: true })]) + const updated = await invoke>('sessions:update', 'session-1', { password: '' }) + expect(deleteCredentials).toHaveBeenCalledWith('session-1') + expect(saveCredential).not.toHaveBeenCalled() + expect(updated.hasPassword).toBe(false) + }) + + it('never trusts a renderer-supplied hasPassword flag', async () => { + seed([makeSession()]) + const updated = await invoke>('sessions:update', 'session-1', { hasPassword: true }) + expect(updated.hasPassword).toBeUndefined() + }) + + it('returns undefined for an unknown id', async () => { + seed([makeSession()]) + const updated = await invoke>('sessions:update', 'missing', { label: 'x' }) + expect(updated).toBeUndefined() + expect(stored()[0].label).toBe('Prod') + }) +}) + +describe('sessions:delete', () => { + it('deletes the session and its credentials', async () => { + seed([makeSession(), makeSession({ id: 'session-2' })]) + await invoke>('sessions:delete', 'session-1') + expect(deleteCredentials).toHaveBeenCalledWith('session-1') + expect(stored()).toHaveLength(1) + expect(stored()[0].id).toBe('session-2') + }) +}) + +describe('sessions:getCredentials', () => { + it('throws when the app is locked', async () => { + vi.mocked(isUnlocked).mockReturnValue(false) + await expect(invoke>('sessions:getCredentials', 'session-1')) + .rejects.toThrow('App is locked — unlock noxed to access credentials') + expect(getCredential).not.toHaveBeenCalled() + }) + + it('returns the stored password when unlocked', async () => { + vi.mocked(getCredential).mockResolvedValue('secret') + const result = await invoke>('sessions:getCredentials', 'session-1') + expect(getCredential).toHaveBeenCalledWith('session-1', 'password') + expect(result).toEqual({ password: 'secret' }) + }) + + it('maps a missing credential to undefined', async () => { + const result = await invoke>('sessions:getCredentials', 'session-1') + expect(result).toEqual({ password: undefined }) + }) +}) + +describe('sessions:count and sessions:clearAll', () => { + it('counts stored sessions', () => { + seed([makeSession(), makeSession({ id: 'session-2' })]) + expect(invoke('sessions:count')).toBe(2) + }) + + it('clearAll deletes credentials for every session and empties the store', async () => { + seed([makeSession(), makeSession({ id: 'session-2' })]) + await invoke>('sessions:clearAll') + expect(deleteCredentials).toHaveBeenCalledTimes(2) + expect(deleteCredentials).toHaveBeenCalledWith('session-1') + expect(deleteCredentials).toHaveBeenCalledWith('session-2') + expect(stored()).toEqual([]) + }) +}) + +describe('sessions:export', () => { + it('throws when no window is attached to the sender', async () => { + vi.mocked(BrowserWindow.fromWebContents).mockReturnValue(null as never) + await expect(invoke>('sessions:export')).rejects.toThrow('No window for export dialog') + }) + + it('returns canceled without writing when the dialog is dismissed', async () => { + vi.mocked(dialog.showSaveDialog).mockResolvedValue({ canceled: true, filePath: undefined } as never) + const result = await invoke>('sessions:export') + expect(result).toEqual({ exported: 0, canceled: true }) + expect(writeFile).not.toHaveBeenCalled() + }) + + it('writes a credential-free export file', async () => { + seed([makeSession({ password: 'legacy', hasPassword: true }), makeSession({ id: 'session-2', host: 'other.example.com' })]) + vi.mocked(dialog.showSaveDialog).mockResolvedValue({ canceled: false, filePath: '/tmp/out.json' } as never) + + const result = await invoke>('sessions:export') + expect(result).toEqual({ exported: 2, canceled: false }) + expect(writeFile).toHaveBeenCalledTimes(1) + + const [path, content, encoding] = vi.mocked(writeFile).mock.calls[0] + expect(path).toBe('/tmp/out.json') + expect(encoding).toBe('utf-8') + const doc = JSON.parse(content as string) + expect(doc.format).toBe('noxed-connections') + expect(doc.connections).toHaveLength(2) + for (const conn of doc.connections) { + expect(conn).not.toHaveProperty('password') + expect(conn).not.toHaveProperty('id') + expect(conn).not.toHaveProperty('hasPassword') + } + }) +}) + +describe('sessions:import', () => { + function exportDoc(connections: unknown[]): string { + return JSON.stringify({ format: 'noxed-connections', version: 1, connections }) + } + + it('throws when no window is attached to the sender', async () => { + vi.mocked(BrowserWindow.fromWebContents).mockReturnValue(null as never) + await expect(invoke>('sessions:import')).rejects.toThrow('No window for import dialog') + }) + + it('returns canceled when the dialog is dismissed', async () => { + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ canceled: true, filePaths: [] } as never) + const result = await invoke>('sessions:import') + expect(result).toEqual({ imported: 0, skipped: 0, canceled: true }) + expect(readFile).not.toHaveBeenCalled() + }) + + it('imports new connections and skips duplicates of existing sessions', async () => { + seed([makeSession({ host: 'dup.example.com', username: 'u' })]) + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ canceled: false, filePaths: ['/tmp/in.json'] } as never) + vi.mocked(readFile).mockResolvedValue(exportDoc([ + { label: 'Fresh', host: 'fresh.example.com', port: 22, username: 'u', authType: 'password', type: 'ssh' }, + { label: 'Dup', host: 'dup.example.com', port: 22, username: 'u', authType: 'password', type: 'ssh' }, + ]) as never) + + const result = await invoke>('sessions:import') + expect(readFile).toHaveBeenCalledWith('/tmp/in.json', 'utf-8') + expect(result).toEqual({ imported: 1, skipped: 1, canceled: false }) + expect(stored()).toHaveLength(2) + const imported = stored()[1] + expect(imported.host).toBe('fresh.example.com') + expect(imported.id).toMatch(/^[0-9a-f-]{36}$/i) + expect(imported.createdAt).toBeGreaterThan(0) + }) + + it('skips duplicates within the imported file itself', async () => { + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ canceled: false, filePaths: ['/tmp/in.json'] } as never) + const conn = { label: 'Twin', host: 'twin.example.com', port: 22, username: 'u', authType: 'password', type: 'ssh' } + vi.mocked(readFile).mockResolvedValue(exportDoc([conn, { ...conn }]) as never) + + const result = await invoke>('sessions:import') + expect(result).toEqual({ imported: 1, skipped: 1, canceled: false }) + expect(stored()).toHaveLength(1) + }) + + it('rejects files that are not valid noxed exports', async () => { + vi.mocked(dialog.showOpenDialog).mockResolvedValue({ canceled: false, filePaths: ['/tmp/in.json'] } as never) + vi.mocked(readFile).mockResolvedValue('this is not json' as never) + await expect(invoke>('sessions:import')).rejects.toThrow(ValidationError) + }) +}) diff --git a/src/main/ipc/__tests__/settings.handlers.test.ts b/src/main/ipc/__tests__/settings.handlers.test.ts new file mode 100644 index 0000000..3acaa99 --- /dev/null +++ b/src/main/ipc/__tests__/settings.handlers.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest' + +const { ipc } = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + }, +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn(), + }, +})) + +vi.mock('electron-store', () => ({ + // Like the real electron-store, values round-trip through JSON so callers + // never share object references with the store. + default: class MockStore { + private data: Map + constructor(opts?: { defaults?: Record }) { + this.data = new Map(Object.entries(structuredClone(opts?.defaults ?? {}))) + } + get(key: string) { return structuredClone(this.data.get(key)) } + set(key: string, value: unknown) { this.data.set(key, structuredClone(value)) } + }, +})) + +import { registerSettingsHandlers, getStoredSettings, type AppSettings } from '../settings' + +registerSettingsHandlers() + +const event = { sender: { id: 1, send: vi.fn(), isDestroyed: () => false } } + +function invoke(channel: string, ...args: unknown[]): unknown { + const handler = ipc.handlers.get(channel) + if (!handler) throw new Error(`${channel} handler not registered`) + return handler(event, ...args) +} + +describe('settings:get', () => { + it('returns the stored settings seeded with defaults', () => { + const settings = invoke('settings:get') as AppSettings + expect(settings).toMatchObject({ + dateFormat: 'YYYY-MM-DD HH:mm', + sidebarDefault: 'expanded', + terminalFontSize: 14, + isDarkMode: false, + groupColors: {}, + projectGroupOrder: [], + }) + }) +}) + +describe('getStoredSettings', () => { + it('merges defaults with whatever is stored', () => { + expect(getStoredSettings()).toMatchObject({ + terminalFont: 'JetBrains Mono', + scrollbackSize: 100000, + confirmClose: true, + }) + }) +}) + +describe('settings:set', () => { + it('updates a known key and returns the full settings object', () => { + const settings = invoke('settings:set', 'terminalFontSize', 18) as AppSettings + expect(settings.terminalFontSize).toBe(18) + expect((invoke('settings:get') as AppSettings).terminalFontSize).toBe(18) + expect(getStoredSettings().terminalFontSize).toBe(18) + }) + + it('accepts namespaced snippet keys', () => { + const settings = invoke('settings:set', 'snippets:session-1', [{ name: 'restart' }]) as AppSettings + expect(settings['snippets:session-1']).toEqual([{ name: 'restart' }]) + }) + + it('rejects unknown keys', () => { + expect(() => invoke('settings:set', 'evilKey', true)).toThrow('Unknown setting: evilKey') + expect((invoke('settings:get') as AppSettings)).not.toHaveProperty('evilKey') + }) +}) + +describe('settings:reset', () => { + it('restores the defaults', () => { + invoke('settings:set', 'isDarkMode', true) + const defaults = invoke('settings:reset') as AppSettings + expect(defaults.isDarkMode).toBe(false) + expect(defaults.terminalFontSize).toBe(14) + expect((invoke('settings:get') as AppSettings).isDarkMode).toBe(false) + }) +}) diff --git a/src/main/ipc/__tests__/sshClients.handlers.test.ts b/src/main/ipc/__tests__/sshClients.handlers.test.ts new file mode 100644 index 0000000..a2bd680 --- /dev/null +++ b/src/main/ipc/__tests__/sshClients.handlers.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { EventEmitter } from 'node:events' + +interface FakeSshClient extends EventEmitter { + connectConfig: Record + forwardOutCalls: Array<{ destHost: string; destPort: number }> + end: ReturnType + setNoDelay: ReturnType + connect: (config: unknown) => void + forwardOut: (srcIp: string, srcPort: number, destHost: string, destPort: number, cb: (err: Error | null, stream?: unknown) => void) => void +} + +const { fakeSsh } = vi.hoisted(() => ({ + fakeSsh: { + clients: [] as unknown[], + connectImpl: null as null | ((client: unknown) => void), + forwardOutImpl: null as null | ((destHost: string, destPort: number, cb: (err: Error | null, stream?: unknown) => void) => void), + }, +})) + +vi.mock('ssh2', async () => { + const { EventEmitter } = await import('node:events') + + class FakeClient extends EventEmitter { + connectConfig: Record = {} + forwardOutCalls: Array<{ destHost: string; destPort: number }> = [] + end = vi.fn() + setNoDelay = vi.fn() + + constructor() { + super() + fakeSsh.clients.push(this) + } + + connect(config: Record): void { + this.connectConfig = config + fakeSsh.connectImpl?.(this) + } + + forwardOut(_srcIp: string, _srcPort: number, destHost: string, destPort: number, cb: (err: Error | null, stream?: unknown) => void): void { + this.forwardOutCalls.push({ destHost, destPort }) + fakeSsh.forwardOutImpl?.(destHost, destPort, cb) + } + } + + return { Client: FakeClient } +}) + +vi.mock('../sessions', () => ({ + getSessionById: vi.fn(), +})) +vi.mock('../keychain', () => ({ + getCredential: vi.fn(), + isUnlocked: vi.fn(), +})) +vi.mock('../security', () => ({ + isAllowedKeyPath: vi.fn(), +})) +vi.mock('../settings', () => ({ + getStoredSettings: vi.fn(), +})) +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), +})) + +import { readFileSync } from 'node:fs' +import { Client } from 'ssh2' +import { + parseKeepaliveIntervalMs, + sshConnectOptions, + connectRawClient, + openJumpSocket, + credentialsForSession, + connectSessionClient, +} from '../sshClients' +import { getSessionById } from '../sessions' +import type { Session } from '../sessions' +import { getCredential, isUnlocked } from '../keychain' +import { isAllowedKeyPath } from '../security' +import { getStoredSettings } from '../settings' +import { AuthError, ConnectionError, NotFoundError, ValidationError } from '../errors' + +function session(overrides: Partial = {}): Session { + return { + id: 's1', + label: 'Prod Web', + host: 'web.example.com', + port: 22, + username: 'deploy', + authType: 'password', + createdAt: 0, + ...overrides, + } +} + +function lastClient(): FakeSshClient { + const client = fakeSsh.clients.at(-1) as FakeSshClient | undefined + if (!client) throw new Error('no ssh client created') + return client +} + +beforeEach(() => { + fakeSsh.clients.length = 0 + fakeSsh.connectImpl = (client) => { + queueMicrotask(() => (client as FakeSshClient).emit('ready')) + } + fakeSsh.forwardOutImpl = (destHost, destPort, cb) => { + cb(null, { tunnelTo: `${destHost}:${destPort}` }) + } + vi.mocked(getStoredSettings).mockReturnValue({} as never) + vi.mocked(isUnlocked).mockReturnValue(true) + vi.mocked(getCredential).mockResolvedValue('secret-pw') + vi.mocked(isAllowedKeyPath).mockReturnValue({ ok: true, resolved: '/home/me/.ssh/id_ed25519' }) + vi.mocked(readFileSync).mockReturnValue('PRIVATE KEY DATA') +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('parseKeepaliveIntervalMs', () => { + it('maps each setting to its interval, defaulting to 30s', () => { + expect(parseKeepaliveIntervalMs('Off')).toBe(0) + expect(parseKeepaliveIntervalMs('15 seconds')).toBe(15_000) + expect(parseKeepaliveIntervalMs('60 seconds')).toBe(60_000) + expect(parseKeepaliveIntervalMs('30 seconds')).toBe(30_000) + expect(parseKeepaliveIntervalMs(undefined)).toBe(30_000) + }) +}) + +describe('sshConnectOptions', () => { + it('applies the stored keepalive setting over the defaults', () => { + vi.mocked(getStoredSettings).mockReturnValue({ sshKeepalive: '15 seconds' } as never) + const opts = sshConnectOptions() + expect(opts.keepaliveInterval).toBe(15_000) + expect(opts.readyTimeout).toBe(30_000) + expect(opts.keepaliveCountMax).toBe(4) + }) +}) + +describe('connectRawClient', () => { + it('resolves once the client is ready and disables Nagle', async () => { + const client = await connectRawClient({ host: 'h', port: 2222, username: 'u', password: 'pw' }) as unknown as FakeSshClient + expect(client.setNoDelay).toHaveBeenCalledWith(true) + expect(client.connectConfig).toMatchObject({ + host: 'h', + port: 2222, + username: 'u', + password: 'pw', + tryKeyboard: true, + }) + expect(client.connectConfig.algorithms).toBeTruthy() + }) + + it('rejects with a ConnectionError when the connection fails', async () => { + fakeSsh.connectImpl = (client) => { + queueMicrotask(() => (client as FakeSshClient).emit('error', new Error('ECONNREFUSED'))) + } + await expect(connectRawClient({ host: 'h', port: 22, username: 'u' })) + .rejects.toThrow(ConnectionError) + }) + + it('ignores errors after the connection has settled', async () => { + const client = await connectRawClient({ host: 'h', port: 22, username: 'u' }) as unknown as FakeSshClient + expect(() => client.emit('error', new Error('late failure'))).not.toThrow() + }) + + it('answers keyboard-interactive prompts with the password', async () => { + fakeSsh.connectImpl = null + const pending = connectRawClient({ host: 'h', port: 22, username: 'u', password: 'pw' }) + const client = lastClient() + const finish = vi.fn() + client.emit('keyboard-interactive', '', '', '', ['Password:', 'Verification:'], finish) + expect(finish).toHaveBeenCalledWith(['pw', 'pw']) + client.emit('ready') + await expect(pending).resolves.toBeTruthy() + }) + + it('answers keyboard-interactive with an empty list when no password is set', async () => { + fakeSsh.connectImpl = null + const pending = connectRawClient({ host: 'h', port: 22, username: 'u' }) + const client = lastClient() + const finish = vi.fn() + client.emit('keyboard-interactive', '', '', '', ['Password:'], finish) + expect(finish).toHaveBeenCalledWith([]) + client.emit('ready') + await pending + }) +}) + +describe('openJumpSocket', () => { + it('resolves with the forwarded stream', async () => { + const via = new Client() as unknown as FakeSshClient + const sock = await openJumpSocket(via as never, 'inner.example.com', 2200) + expect(sock).toEqual({ tunnelTo: 'inner.example.com:2200' }) + expect(via.forwardOutCalls).toEqual([{ destHost: 'inner.example.com', destPort: 2200 }]) + }) + + it('rejects with a ConnectionError naming the unreachable destination', async () => { + fakeSsh.forwardOutImpl = (_h, _p, cb) => cb(new Error('administratively prohibited')) + const via = new Client() as unknown as FakeSshClient + await expect(openJumpSocket(via as never, 'inner.example.com', 2200)) + .rejects.toThrow('Jump host could not reach inner.example.com:2200: administratively prohibited') + }) +}) + +describe('credentialsForSession', () => { + it('rejects key auth without a configured key file', async () => { + await expect(credentialsForSession(session({ authType: 'key' }))) + .rejects.toThrow(ValidationError) + await expect(credentialsForSession(session({ authType: 'key' }))) + .rejects.toThrow('Prod Web: key authentication selected but no key file configured') + }) + + it('rejects key paths outside the allowlist with the checker\'s reason', async () => { + vi.mocked(isAllowedKeyPath).mockReturnValue({ ok: false, reason: 'Key file is outside your home directory' }) + await expect(credentialsForSession(session({ authType: 'key', keyPath: '/etc/shadow' }))) + .rejects.toThrow('Key file is outside your home directory') + }) + + it('reads the private key from the resolved allowlisted path', async () => { + const creds = await credentialsForSession(session({ authType: 'key', keyPath: '~/.ssh/id_ed25519' })) + expect(isAllowedKeyPath).toHaveBeenCalledWith('~/.ssh/id_ed25519') + expect(readFileSync).toHaveBeenCalledWith('/home/me/.ssh/id_ed25519', 'utf-8') + expect(creds).toEqual({ privateKey: 'PRIVATE KEY DATA' }) + }) + + it('rejects password auth while the app is locked', async () => { + vi.mocked(isUnlocked).mockReturnValue(false) + await expect(credentialsForSession(session())).rejects.toThrow(AuthError) + expect(getCredential).not.toHaveBeenCalled() + }) + + it('rejects when no password is stored for the session', async () => { + vi.mocked(getCredential).mockResolvedValue(null) + await expect(credentialsForSession(session())).rejects.toThrow('No password stored for Prod Web') + }) + + it('returns the stored password', async () => { + await expect(credentialsForSession(session())).resolves.toEqual({ password: 'secret-pw' }) + expect(getCredential).toHaveBeenCalledWith('s1', 'password') + }) +}) + +describe('connectSessionClient', () => { + it('rejects unknown sessions', async () => { + vi.mocked(getSessionById).mockReturnValue(undefined) + await expect(connectSessionClient('missing')).rejects.toThrow(NotFoundError) + }) + + it('rejects sessions missing a host or username', async () => { + vi.mocked(getSessionById).mockReturnValue(session({ host: '' })) + await expect(connectSessionClient('s1')).rejects.toThrow(ValidationError) + vi.mocked(getSessionById).mockReturnValue(session({ username: '' })) + await expect(connectSessionClient('s1')).rejects.toThrow('Prod Web is missing a host or username') + }) + + it('connects with keychain credentials and disposes exactly once', async () => { + vi.mocked(getSessionById).mockReturnValue(session()) + const managed = await connectSessionClient('s1') + const client = lastClient() + expect(client.connectConfig).toMatchObject({ + host: 'web.example.com', + port: 22, + username: 'deploy', + password: 'secret-pw', + }) + + managed.dispose() + managed.dispose() + expect(client.end).toHaveBeenCalledTimes(1) + }) + + it('disposes itself when the client closes', async () => { + vi.mocked(getSessionById).mockReturnValue(session()) + await connectSessionClient('s1') + const client = lastClient() + client.emit('close') + expect(client.end).toHaveBeenCalledTimes(1) + }) + + it('logs instead of throwing when ending the client fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.mocked(getSessionById).mockReturnValue(session()) + const managed = await connectSessionClient('s1') + lastClient().end.mockImplementation(() => { throw new Error('already gone') }) + + expect(() => managed.dispose()).not.toThrow() + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('[ssh] end client for s1')) + }) + + it('tunnels through a jump host and tears down the whole chain on dispose', async () => { + const bastion = session({ id: 'bastion', label: 'Bastion', host: 'bastion.example.com' }) + const leaf = session({ id: 'leaf', label: 'Leaf', host: 'leaf.internal', port: 2222, jumpHostId: 'bastion' }) + vi.mocked(getSessionById).mockImplementation((id: string) => (id === 'bastion' ? bastion : id === 'leaf' ? leaf : undefined)) + + const managed = await connectSessionClient('leaf') + expect(fakeSsh.clients).toHaveLength(2) + const bastionClient = fakeSsh.clients[0] as FakeSshClient + const leafClient = fakeSsh.clients[1] as FakeSshClient + + expect(bastionClient.connectConfig.host).toBe('bastion.example.com') + expect(bastionClient.forwardOutCalls).toEqual([{ destHost: 'leaf.internal', destPort: 2222 }]) + expect(leafClient.connectConfig).toMatchObject({ + host: 'leaf.internal', + port: 2222, + sock: { tunnelTo: 'leaf.internal:2222' }, + }) + + managed.dispose() + expect(leafClient.end).toHaveBeenCalled() + expect(bastionClient.end).toHaveBeenCalled() + }) + + it('rejects jump chains deeper than three hops', async () => { + vi.mocked(getSessionById).mockImplementation((id: string) => session({ id, jumpHostId: id })) + await expect(connectSessionClient('loop')).rejects.toThrow(ConnectionError) + await expect(connectSessionClient('loop')).rejects.toThrow('Jump host chain deeper than 3 hops') + expect(fakeSsh.clients).toHaveLength(0) + }) + + it('disposes the bastion when the jump tunnel cannot be opened', async () => { + const bastion = session({ id: 'bastion', host: 'bastion.example.com' }) + const leaf = session({ id: 'leaf', host: 'leaf.internal', jumpHostId: 'bastion' }) + vi.mocked(getSessionById).mockImplementation((id: string) => (id === 'bastion' ? bastion : leaf)) + fakeSsh.forwardOutImpl = (_h, _p, cb) => cb(new Error('no route')) + + await expect(connectSessionClient('leaf')).rejects.toThrow(ConnectionError) + expect((fakeSsh.clients[0] as FakeSshClient).end).toHaveBeenCalled() + }) + + it('disposes the bastion when the leaf credentials are unavailable', async () => { + const bastion = session({ id: 'bastion', host: 'bastion.example.com' }) + const leaf = session({ id: 'leaf', host: 'leaf.internal', jumpHostId: 'bastion' }) + vi.mocked(getSessionById).mockImplementation((id: string) => (id === 'bastion' ? bastion : leaf)) + vi.mocked(getCredential).mockImplementation(async (id: string) => (id === 'bastion' ? 'secret-pw' : null)) + + await expect(connectSessionClient('leaf')).rejects.toThrow(AuthError) + expect((fakeSsh.clients[0] as FakeSshClient).end).toHaveBeenCalled() + }) + + it('disposes the bastion when the leaf connection fails', async () => { + const bastion = session({ id: 'bastion', host: 'bastion.example.com' }) + const leaf = session({ id: 'leaf', host: 'leaf.internal', jumpHostId: 'bastion' }) + vi.mocked(getSessionById).mockImplementation((id: string) => (id === 'bastion' ? bastion : leaf)) + fakeSsh.connectImpl = (client) => { + const isLeaf = fakeSsh.clients.indexOf(client) === 1 + queueMicrotask(() => { + const c = client as FakeSshClient + if (isLeaf) c.emit('error', new Error('auth failed')) + else c.emit('ready') + }) + } + + await expect(connectSessionClient('leaf')).rejects.toThrow(ConnectionError) + expect((fakeSsh.clients[0] as FakeSshClient).end).toHaveBeenCalled() + }) +}) diff --git a/src/preload/__tests__/index.test.ts b/src/preload/__tests__/index.test.ts new file mode 100644 index 0000000..761b3c4 --- /dev/null +++ b/src/preload/__tests__/index.test.ts @@ -0,0 +1,367 @@ +import { contextBridge, ipcRenderer } from 'electron' + +vi.mock('electron', () => ({ + contextBridge: { exposeInMainWorld: vi.fn() }, + ipcRenderer: { + invoke: vi.fn().mockResolvedValue(undefined), + send: vi.fn(), + on: vi.fn(), + off: vi.fn(), + }, +})) + +// Importing the module runs contextBridge.exposeInMainWorld('api', {...}) +await import('../index') + +const exposeMock = vi.mocked(contextBridge.exposeInMainWorld) +const api = exposeMock.mock.calls[0][1] as any +const invokeMock = vi.mocked(ipcRenderer.invoke) +const sendMock = vi.mocked(ipcRenderer.send) +const onMock = vi.mocked(ipcRenderer.on) +const offMock = vi.mocked(ipcRenderer.off) + +beforeEach(() => { + invokeMock.mockClear() + sendMock.mockClear() + onMock.mockClear() + offMock.mockClear() +}) + +// Asserts that calling `fn(...args)` forwards to ipcRenderer.invoke(channel, ...forwarded) +function expectInvoke(fn: (...args: any[]) => any, args: any[], channel: string, forwarded: any[] = args) { + fn(...args) + expect(invokeMock).toHaveBeenCalledTimes(1) + expect(invokeMock).toHaveBeenCalledWith(channel, ...forwarded) + invokeMock.mockClear() +} + +// Asserts that calling `fn(...args)` forwards to ipcRenderer.send(channel, ...args) +function expectSend(fn: (...args: any[]) => any, args: any[], channel: string) { + fn(...args) + expect(sendMock).toHaveBeenCalledTimes(1) + expect(sendMock).toHaveBeenCalledWith(channel, ...args) + sendMock.mockClear() +} + +// Asserts subscription: registers on `channel`, forwards emitted args to the callback +// (stripping the event), and the returned unsubscribe removes the same handler. +function expectSubscription( + subscribe: (cb: (...args: any[]) => void) => () => void, + channel: string, + emitted: any[], + expectedCbArgs: any[] = emitted +) { + const cb = vi.fn() + const unsubscribe = subscribe(cb) + expect(onMock).toHaveBeenCalledTimes(1) + expect(onMock).toHaveBeenCalledWith(channel, expect.any(Function)) + const handler = onMock.mock.calls[0][1] as (...args: any[]) => void + handler({ fakeEvent: true }, ...emitted) + expect(cb).toHaveBeenCalledTimes(1) + expect(cb).toHaveBeenCalledWith(...expectedCbArgs) + expect(offMock).not.toHaveBeenCalled() + unsubscribe() + expect(offMock).toHaveBeenCalledTimes(1) + expect(offMock).toHaveBeenCalledWith(channel, handler) + onMock.mockClear() + offMock.mockClear() +} + +describe('preload api', () => { + it('exposes the api object in the main world', () => { + expect(exposeMock).toHaveBeenCalledTimes(1) + expect(exposeMock.mock.calls[0][0]).toBe('api') + expect(api).toBeTypeOf('object') + }) + + it('exposes the current platform', () => { + expect(api.platform).toBe(process.platform) + }) +}) + +describe('sessions', () => { + it('forwards invoke calls', () => { + expectInvoke(api.sessions.list, [], 'sessions:list') + expectInvoke(api.sessions.create, [{ name: 'srv' }], 'sessions:create') + expectInvoke(api.sessions.update, ['id-1', { name: 'x' }], 'sessions:update') + expectInvoke(api.sessions.delete, ['id-1'], 'sessions:delete') + expectInvoke(api.sessions.getCredentials, ['id-1'], 'sessions:getCredentials') + expectInvoke(api.sessions.count, [], 'sessions:count') + expectInvoke(api.sessions.clearAll, [], 'sessions:clearAll') + expectInvoke(api.sessions.export, [], 'sessions:export') + expectInvoke(api.sessions.import, [], 'sessions:import') + }) +}) + +describe('sshConfig', () => { + it('forwards invoke calls', () => { + expectInvoke(api.sshConfig.hosts, [], 'sshconfig:hosts') + }) +}) + +describe('tunnels', () => { + it('forwards invoke calls', () => { + expectInvoke(api.tunnels.list, [], 'tunnels:list') + expectInvoke(api.tunnels.save, [{ host: 'h' }, 't-1'], 'tunnels:save') + expectInvoke(api.tunnels.delete, ['t-1'], 'tunnels:delete') + expectInvoke(api.tunnels.start, ['t-1'], 'tunnels:start') + expectInvoke(api.tunnels.stop, ['t-1'], 'tunnels:stop') + }) + + it('onChanged subscribes and unsubscribes', () => { + expectSubscription(api.tunnels.onChanged, 'tunnel:changed', ['ignored'], []) + }) +}) + +describe('auth', () => { + it('forwards invoke calls', () => { + expectInvoke(api.auth.getMode, [], 'auth:getMode') + expectInvoke(api.auth.isAvailable, [], 'auth:isAvailable') + expectInvoke(api.auth.isUnlocked, [], 'auth:isUnlocked') + expectInvoke(api.auth.unlock, ['pin'], 'auth:unlock') + expectInvoke(api.auth.lock, [], 'auth:lock') + expectInvoke(api.auth.setup, ['pin', '1234', 'old'], 'auth:setup') + }) + + it('onLocked subscribes and unsubscribes', () => { + expectSubscription(api.auth.onLocked, 'auth:locked', [], []) + }) +}) + +describe('ssh', () => { + it('forwards invoke calls', () => { + expectInvoke(api.ssh.connect, [{ host: 'h' }], 'ssh:connect') + expectInvoke(api.ssh.disconnect, ['s-1'], 'ssh:disconnect') + expectInvoke(api.ssh.startMetrics, ['s-1'], 'ssh:metrics-start') + }) + + it('forwards send calls', () => { + expectSend(api.ssh.send, ['s-1', 'ls\n'], 'ssh:data') + expectSend(api.ssh.resize, ['s-1', 80, 24], 'ssh:resize') + expectSend(api.ssh.stopMetrics, ['s-1'], 'ssh:metrics-stop') + }) + + it('subscriptions forward events and unsubscribe', () => { + expectSubscription(api.ssh.onData, 'ssh:data', ['s-1', 'chunk']) + expectSubscription(api.ssh.onClose, 'ssh:closed', ['s-1']) + expectSubscription(api.ssh.onMetrics, 'ssh:metrics', [ + 's-1', + { cpu: 1, memUsed: 2, memTotal: 4, available: true }, + ]) + }) +}) + +describe('sftp', () => { + it('forwards invoke calls', () => { + expectInvoke(api.sftp.connect, [{ host: 'h' }], 'sftp:connect') + expectInvoke(api.sftp.list, ['c-1', '/tmp'], 'sftp:list') + expectInvoke(api.sftp.readFile, ['c-1', '/tmp/a'], 'sftp:readFile') + expectInvoke(api.sftp.writeFile, ['c-1', '/tmp/a', 'body'], 'sftp:writeFile') + expectInvoke(api.sftp.download, ['c-1', '/r/a', '/l/a'], 'sftp:download') + expectInvoke(api.sftp.upload, ['c-1', '/l/a', '/r/a'], 'sftp:upload') + expectInvoke(api.sftp.delete, ['c-1', '/r/a'], 'sftp:delete') + expectInvoke(api.sftp.rename, ['c-1', '/r/a', '/r/b'], 'sftp:rename') + expectInvoke(api.sftp.mkdir, ['c-1', '/r/dir'], 'sftp:mkdir') + expectInvoke(api.sftp.rmdir, ['c-1', '/r/dir'], 'sftp:rmdir') + expectInvoke(api.sftp.chmod, ['c-1', '/r/a', 0o644], 'sftp:chmod') + expectInvoke(api.sftp.stat, ['c-1', '/r/a'], 'sftp:stat') + expectInvoke(api.sftp.disconnect, ['c-1'], 'sftp:disconnect') + }) +}) + +describe('database', () => { + it('forwards invoke calls', () => { + const config = { dbType: 'postgres', host: 'h', port: 5432, username: 'u', database: 'd' } + expectInvoke(api.database.connect, [config], 'db:connect') + expectInvoke(api.database.disconnect, ['db-1'], 'db:disconnect') + expectInvoke(api.database.query, ['db-1', 'select 1', [1, 'a']], 'db:query') + expectInvoke(api.database.tables, ['db-1'], 'db:tables') + expectInvoke(api.database.tableInfo, ['db-1', 'users'], 'db:tableInfo') + }) +}) + +describe('localfs', () => { + it('forwards invoke calls', () => { + expectInvoke(api.localfs.home, [], 'localfs:home') + expectInvoke(api.localfs.list, ['/home/u'], 'localfs:list') + expectInvoke(api.localfs.readTextFile, ['/home/u/a.txt'], 'localfs:readTextFile') + expectInvoke(api.localfs.writeTextFile, ['/home/u/a.txt', 'text'], 'localfs:writeTextFile') + }) +}) + +describe('fs', () => { + it('forwards invoke calls', () => { + expectInvoke(api.fs.readFile, ['/home/u/.ssh/id_rsa'], 'fs:readFile') + }) +}) + +describe('k8s', () => { + it('forwards context and namespace calls', () => { + expectInvoke(api.k8s.contexts, [], 'k8s:contexts') + expectInvoke(api.k8s.contextsDetailed, [], 'k8s:contextsDetailed') + expectInvoke(api.k8s.importKubeconfig, ['/kube/config'], 'k8s:importKubeconfig') + expectInvoke(api.k8s.showFilePicker, [], 'k8s:showFilePicker') + expectInvoke(api.k8s.namespaces, ['ctx', '/kc'], 'k8s:namespaces') + }) + + it('forwards workload calls', () => { + expectInvoke(api.k8s.pods, ['ctx', 'ns', '/kc'], 'k8s:pods') + expectInvoke(api.k8s.deletePod, ['ctx', 'ns', 'pod-1', '/kc'], 'k8s:deletePod') + expectInvoke(api.k8s.deployments, ['ctx', 'ns', '/kc'], 'k8s:deployments') + expectInvoke(api.k8s.scaleDeployment, ['ctx', 'ns', 'dep', 3, '/kc'], 'k8s:scaleDeployment') + expectInvoke(api.k8s.restartDeployment, ['ctx', 'ns', 'dep', '/kc'], 'k8s:restartDeployment') + expectInvoke(api.k8s.statefulsets, ['ctx', 'ns', '/kc'], 'k8s:statefulsets') + expectInvoke(api.k8s.daemonsets, ['ctx', 'ns', '/kc'], 'k8s:daemonsets') + expectInvoke(api.k8s.replicasets, ['ctx', 'ns', '/kc'], 'k8s:replicasets') + expectInvoke(api.k8s.jobs, ['ctx', 'ns', '/kc'], 'k8s:jobs') + expectInvoke(api.k8s.cronjobs, ['ctx', 'ns', '/kc'], 'k8s:cronjobs') + }) + + it('forwards network, config, nodes and events calls', () => { + expectInvoke(api.k8s.services, ['ctx', 'ns', '/kc'], 'k8s:services') + expectInvoke(api.k8s.ingresses, ['ctx', 'ns', '/kc'], 'k8s:ingresses') + expectInvoke(api.k8s.configmaps, ['ctx', 'ns', '/kc'], 'k8s:configmaps') + expectInvoke(api.k8s.secrets, ['ctx', 'ns', '/kc'], 'k8s:secrets') + expectInvoke(api.k8s.secretDetail, ['ctx', 'ns', 'sec', '/kc'], 'k8s:secretDetail') + expectInvoke(api.k8s.nodes, ['ctx', '/kc'], 'k8s:nodes') + expectInvoke(api.k8s.events, ['ctx', 'ns', '/kc'], 'k8s:events') + expectInvoke(api.k8s.resourceDetail, ['ctx', 'ns', 'Pod', 'pod-1', '/kc'], 'k8s:resourceDetail') + }) + + it('forwards log calls and subscriptions', () => { + expectInvoke(api.k8s.logsGet, ['ctx', 'ns', 'pod', 'main', 100, '/kc'], 'k8s:logsGet') + expectInvoke(api.k8s.logsStream, ['ctx', 'ns', 'pod', 'main', 100, '/kc'], 'k8s:logsStream') + expectInvoke(api.k8s.logsStop, ['sess-1'], 'k8s:logsStop') + expectSubscription(api.k8s.onLogChunk, 'k8s:logChunk', ['sess-1', 'line']) + expectSubscription(api.k8s.onLogEnd, 'k8s:logEnd', ['sess-1', 'boom']) + }) + + it('forwards exec calls and subscriptions', () => { + expectInvoke(api.k8s.execStart, ['ctx', 'ns', 'pod', 'main', '/kc'], 'k8s:execStart') + expectInvoke(api.k8s.execStop, ['sess-1'], 'k8s:execStop') + expectSend(api.k8s.execSend, ['sess-1', 'ls\n'], 'k8s:execSend') + expectSend(api.k8s.execResize, ['sess-1', 120, 40], 'k8s:execResize') + expectSubscription(api.k8s.onExecData, 'k8s:execData', ['sess-1', 'out']) + expectSubscription(api.k8s.onExecClose, 'k8s:execClose', ['sess-1', { code: 0 }]) + }) + + it('forwards port forwarding calls', () => { + expectInvoke(api.k8s.portForwardStart, ['ctx', 'ns', 'pod', 8080, 9090, '/kc'], 'k8s:portForwardStart') + expectInvoke(api.k8s.servicePortForwardStart, ['ctx', 'ns', 'svc', 80, 8080, '/kc'], 'k8s:servicePortForwardStart') + expectInvoke(api.k8s.portForwardStop, ['pf-1'], 'k8s:portForwardStop') + expectInvoke(api.k8s.portForwardList, [], 'k8s:portForwardList') + }) +}) + +describe('localpty', () => { + it('forwards invoke and send calls', () => { + expectInvoke(api.localpty.start, [80, 24], 'localpty:start') + expectInvoke(api.localpty.kill, ['pty-1'], 'localpty:kill') + expectSend(api.localpty.write, ['pty-1', 'echo hi\n'], 'localpty:write') + expectSend(api.localpty.resize, ['pty-1', 100, 30], 'localpty:resize') + }) + + it('subscriptions forward events and unsubscribe', () => { + expectSubscription(api.localpty.onData, 'localpty:data', ['pty-1', 'out']) + expectSubscription(api.localpty.onExit, 'localpty:exit', ['pty-1', 0]) + }) +}) + +describe('runner', () => { + it('forwards invoke calls', () => { + expectInvoke(api.runner.run, [['s-1', 's-2'], 'uptime'], 'runner:run') + expectInvoke(api.runner.cancel, ['run-1'], 'runner:cancel') + }) + + it('subscriptions forward events and unsubscribe', () => { + expectSubscription(api.runner.onOutput, 'runner:output', ['run-1', 's-1', 'out']) + expectSubscription(api.runner.onDone, 'runner:done', ['run-1', 's-1', 0, null]) + }) +}) + +describe('docker', () => { + it('forwards invoke calls', () => { + expectInvoke(api.docker.connect, ['sess-1'], 'docker:connect') + expectInvoke(api.docker.disconnect, ['d-1'], 'docker:disconnect') + expectInvoke(api.docker.containers, ['d-1'], 'docker:containers') + expectInvoke(api.docker.stats, ['d-1'], 'docker:stats') + expectInvoke(api.docker.images, ['d-1'], 'docker:images') + expectInvoke(api.docker.info, ['d-1'], 'docker:info') + expectInvoke(api.docker.action, ['d-1', 'nginx', 'restart'], 'docker:action') + expectInvoke(api.docker.logsStart, ['d-1', 'nginx', 200], 'docker:logsStart') + expectInvoke(api.docker.logsStop, ['log-1'], 'docker:logsStop') + }) + + it('subscriptions forward events and unsubscribe', () => { + expectSubscription(api.docker.onLogChunk, 'docker:logChunk', ['log-1', 'line']) + expectSubscription(api.docker.onLogEnd, 'docker:logEnd', ['log-1', null]) + }) +}) + +describe('settings', () => { + it('forwards invoke calls', () => { + expectInvoke(api.settings.get, [], 'settings:get') + expectInvoke(api.settings.set, ['theme', 'dark'], 'settings:set') + expectInvoke(api.settings.reset, [], 'settings:reset') + }) +}) + +describe('redis', () => { + it('forwards invoke calls', () => { + expectInvoke(api.redis.connect, [{ host: 'h' }], 'redis:connect') + expectInvoke(api.redis.disconnect, ['r-1'], 'redis:disconnect') + expectInvoke(api.redis.info, ['r-1'], 'redis:info') + expectInvoke(api.redis.keys, ['r-1', 'user:*'], 'redis:keys') + expectInvoke(api.redis.get, ['r-1', 'user:1'], 'redis:get') + expectInvoke(api.redis.set, ['r-1', 'user:1', 'val', 60], 'redis:set') + expectInvoke(api.redis.command, ['r-1', 'PING'], 'redis:command') + }) + + it('spreads variadic keys for del', () => { + expectInvoke(api.redis.del, ['r-1', 'k1', 'k2', 'k3'], 'redis:del') + }) +}) + +describe('rdp', () => { + it('forwards invoke calls', () => { + const config = { host: 'h', username: 'u', password: 'p' } + expectInvoke(api.rdp.connect, [config], 'rdp:connect') + expectInvoke(api.rdp.disconnect, ['rdp-1'], 'rdp:disconnect') + }) + + it('subscriptions forward events and unsubscribe', () => { + const pixels = new Uint8Array([1, 2, 3]) + expectSubscription(api.rdp.onFrame, 'rdp:frame', ['rdp-1', 800, 600, pixels]) + expectSubscription(api.rdp.onClose, 'rdp:closed', ['rdp-1', 'lost']) + }) +}) + +describe('tabs', () => { + it('onCycle subscribes and unsubscribes', () => { + expectSubscription(api.tabs.onCycle, 'tab:cycle', ['next']) + }) +}) + +describe('menu', () => { + it.each([ + 'new-connection', + 'open-connection', + 'new-local-terminal', + 'close-tab', + ] as const)('on(%s) subscribes to the menu channel and unsubscribes', (action) => { + expectSubscription((cb) => api.menu.on(action, cb), `menu:${action}`, ['ignored'], []) + }) +}) + +describe('updater', () => { + it('forwards invoke calls', () => { + expectInvoke(api.updater.version, [], 'updater:version') + expectInvoke(api.updater.check, [], 'updater:check') + expectInvoke(api.updater.download, [], 'updater:download') + expectInvoke(api.updater.quitAndInstall, [], 'updater:quitAndInstall') + }) + + it('onStatus subscribes and unsubscribes', () => { + expectSubscription(api.updater.onStatus, 'updater:status', [{ state: 'downloading' }]) + }) +}) diff --git a/src/renderer/src/components/CommandPalette/__tests__/CommandPalette.test.tsx b/src/renderer/src/components/CommandPalette/__tests__/CommandPalette.test.tsx new file mode 100644 index 0000000..21b0bcd --- /dev/null +++ b/src/renderer/src/components/CommandPalette/__tests__/CommandPalette.test.tsx @@ -0,0 +1,246 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import CommandPalette from '../CommandPalette' +import { installWindowApi, seedStore, makeSession, makeTab } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +// jsdom does not implement scrollIntoView, which the palette calls on selection +Element.prototype.scrollIntoView = vi.fn() + +describe('CommandPalette', () => { + let onClose: ReturnType + + beforeEach(() => { + installWindowApi() + onClose = vi.fn() + seedStore({ + sessions: [], tabs: [], activeTabId: null, showAddSession: false, + groupColors: {}, notifications: [], + }) + }) + + const type = (value: string) => + fireEvent.change(screen.getByPlaceholderText('Search servers, commands…'), { target: { value } }) + + it('renders search input, quick action, sessions and command sections', () => { + seedStore({ + sessions: [ + makeSession({ id: 'p1', label: 'Prod Web', host: 'prod.example.com', group: 'Prod' }), + makeSession({ id: 'd1', label: 'Dev Box', host: 'dev.example.com' }), + ], + tabs: [makeTab({ sessionId: 'p1', status: 'connected' })], + }) + render() + + expect(screen.getByPlaceholderText('Search servers, commands…')).toBeTruthy() + expect(screen.getByText('Quick actions')).toBeTruthy() + expect(screen.getByText('New Local Terminal')).toBeTruthy() + // connected session lands in the Active section (label + row badge), idle one under Servers + expect(screen.getAllByText('Active')).toHaveLength(2) + expect(screen.getByText('Prod Web')).toBeTruthy() + expect(screen.getByText('Servers')).toBeTruthy() + expect(screen.getByText('Dev Box')).toBeTruthy() + expect(screen.getByText('Commands')).toBeTruthy() + expect(screen.getByText('New SSH Session')).toBeTruthy() + expect(screen.getByText('Open Dashboard')).toBeTruthy() + expect(screen.getByText('Open Tunnels')).toBeTruthy() + expect(screen.getByText('Run Command on Hosts…')).toBeTruthy() + // group chip renders for grouped sessions + expect(screen.getByText('Prod')).toBeTruthy() + // per-host Docker commands are hidden until searched for + expect(screen.queryByText(/Docker on/)).toBeNull() + }) + + it('filters sessions by query and hides section labels while searching', () => { + seedStore({ + sessions: [ + makeSession({ id: 'p1', label: 'Prod Web', host: 'prod.example.com' }), + makeSession({ id: 'd1', label: 'Dev Box', host: 'dev.example.com' }), + ], + }) + render() + type('prod web') + + expect(screen.getByText('Prod Web')).toBeTruthy() + expect(screen.queryByText('Dev Box')).toBeNull() + expect(screen.queryByText('New Local Terminal')).toBeNull() + expect(screen.queryByText('Servers')).toBeNull() + expect(screen.queryByText('Commands')).toBeNull() + }) + + it('matches sessions on host, username and group', () => { + seedStore({ + sessions: [ + makeSession({ id: 'h1', label: 'ByHost', host: 'special-host.io', username: 'root' }), + makeSession({ id: 'g1', label: 'ByGroup', host: 'other.io', username: 'deploy', group: 'staging' }), + ], + }) + render() + + type('special-host') + expect(screen.getByText('ByHost')).toBeTruthy() + expect(screen.queryByText('ByGroup')).toBeNull() + + type('staging') + expect(screen.getByText('ByGroup')).toBeTruthy() + expect(screen.queryByText('ByHost')).toBeNull() + + type('deploy') + expect(screen.getByText('ByGroup')).toBeTruthy() + }) + + it('shows the empty state when nothing matches', () => { + seedStore({ sessions: [makeSession({ id: 's1', label: 'Alpha' })] }) + render() + type('zzz-no-match') + + expect(screen.getByText('No results')).toBeTruthy() + expect(screen.getByText(/Nothing matches/)).toBeTruthy() + }) + + it('clears the query with the clear button', () => { + render() + const input = screen.getByPlaceholderText('Search servers, commands…') as HTMLInputElement + type('zzz-no-match') + expect(screen.getByText('No results')).toBeTruthy() + + const clearBtn = input.parentElement!.querySelector('button')! + fireEvent.click(clearBtn) + expect(input.value).toBe('') + expect(screen.getByText('New Local Terminal')).toBeTruthy() + }) + + it('opens a local terminal tab on Enter for the default selection', () => { + render() + fireEvent.keyDown(window, { key: 'Enter' }) + + const { tabs, activeTabId } = useAppStore.getState() + expect(tabs).toHaveLength(1) + expect(tabs[0].view).toBe('local-term') + expect(activeTabId).toBe(tabs[0].id) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('navigates with arrow keys and activates a session with Enter', () => { + seedStore({ sessions: [makeSession({ id: 's1', label: 'Only Server', host: 'one.example.com' })] }) + render() + + // item 0 = New Local Terminal, item 1 = the session + fireEvent.keyDown(window, { key: 'ArrowDown' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + const { tabs } = useAppStore.getState() + expect(tabs).toHaveLength(1) + expect(tabs[0].sessionId).toBe('s1') + expect(tabs[0].view).toBe('terminal') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('clamps arrow navigation at the list edges', () => { + seedStore({ sessions: [] }) + render() + + // ArrowUp at the top stays on New Local Terminal + fireEvent.keyDown(window, { key: 'ArrowUp' }) + fireEvent.keyDown(window, { key: 'Enter' }) + expect(useAppStore.getState().tabs[0].view).toBe('local-term') + + // ArrowDown past the end stays on the last command (Run Command on Hosts…) + for (let i = 0; i < 20; i++) fireEvent.keyDown(window, { key: 'ArrowDown' }) + fireEvent.keyDown(window, { key: 'Enter' }) + const { tabs } = useAppStore.getState() + expect(tabs[tabs.length - 1].view).toBe('runner') + }) + + it('closes on Escape without touching the store', () => { + render() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + expect(useAppStore.getState().tabs).toHaveLength(0) + }) + + it('closes when clicking the backdrop but not the panel', () => { + const { container } = render() + const backdrop = container.firstChild as HTMLElement + + fireEvent.click(screen.getByPlaceholderText('Search servers, commands…')) + expect(onClose).not.toHaveBeenCalled() + + fireEvent.click(backdrop) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('opens the add-session modal when clicking New SSH Session', () => { + render() + fireEvent.click(screen.getByText('New SSH Session')) + + expect(useAppStore.getState().showAddSession).toBe(true) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('opens dashboard and tunnels singleton tabs from commands', () => { + render() + fireEvent.click(screen.getByText('Open Dashboard')) + fireEvent.click(screen.getByText('Open Tunnels')) + + const views = useAppStore.getState().tabs.map((t) => t.view) + expect(views).toContain('dashboard') + expect(views).toContain('tunnels') + expect(onClose).toHaveBeenCalledTimes(2) + }) + + it('surfaces per-host Docker commands when searched and opens a docker tab', () => { + seedStore({ + sessions: [ + makeSession({ id: 's1', label: 'Web One', host: 'web1.example.com' }), + makeSession({ id: 'k1', label: 'Cluster', host: 'k8s.example.com', type: 'k8s' }), + ], + }) + render() + type('docker') + + // only ssh sessions get a docker entry + expect(screen.getByText('Docker on Web One')).toBeTruthy() + expect(screen.queryByText('Docker on Cluster')).toBeNull() + + fireEvent.click(screen.getByText('Docker on Web One')) + const { tabs } = useAppStore.getState() + expect(tabs).toHaveLength(1) + expect(tabs[0].view).toBe('docker') + expect(tabs[0].sessionId).toBe('s1') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('switches to the existing tab when activating a connected session', () => { + const tab = makeTab({ sessionId: 's1', status: 'connected' }) + seedStore({ + sessions: [makeSession({ id: 's1', label: 'Live Box', host: 'live.example.com' })], + tabs: [tab], + activeTabId: null, + }) + render() + expect(screen.getAllByText('Active').length).toBeGreaterThan(0) + + fireEvent.click(screen.getByText('Live Box')) + const state = useAppStore.getState() + expect(state.tabs).toHaveLength(1) + expect(state.activeTabId).toBe(tab.id) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('marks a connecting session and selects rows on hover', () => { + seedStore({ + sessions: [makeSession({ id: 's1', label: 'Spinning Up', host: 'boot.example.com' })], + tabs: [makeTab({ sessionId: 's1', status: 'connecting' })], + }) + render() + + const row = screen.getByText('Spinning Up').closest('button')! + // not selected yet: no Connect affordance + expect(screen.queryByText('Connect')).toBeNull() + + fireEvent.mouseMove(row) + expect(screen.getByText('Connect')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/Database/DatabaseExplorer.tsx b/src/renderer/src/components/Database/DatabaseExplorer.tsx index 3279790..863eb2e 100644 --- a/src/renderer/src/components/Database/DatabaseExplorer.tsx +++ b/src/renderer/src/components/Database/DatabaseExplorer.tsx @@ -527,14 +527,14 @@ function ResultsGrid({ results, sortedRows, resultSort, onToggleSort, selectedRo ))} - {/* Rows can contain interactive cells (JsonCell buttons), so the row - itself stays a div; keyboard selection goes through the row-number - button. The row keydown ignores bubbled events from those child - buttons so Enter there doesn't both activate and select. */} + {/* Rows contain interactive cells (JsonCell buttons), so the row keydown + ignores bubbled events from those child buttons so Enter there + doesn't both activate and select. tabIndex={-1} lets a clicked row + take focus so Enter/Space toggles selection on the row itself. */} {sortedRows.map((row, i) => ( -
onSelectRow(i === selectedRow ? null : i)} - onKeyDown={e => { if (e.target !== e.currentTarget) return; if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectRow(i === selectedRow ? null : i) } }} + onKeyDown={e => { if (e.target !== e.currentTarget) { return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectRow(i === selectedRow ? null : i) } }} className="grid cursor-default transition-colors" style={{ gridTemplateColumns: gridColumns, background: selectedRow === i ? 'rgba(59,92,204,0.06)' : undefined }} onMouseEnter={e => { if (selectedRow !== i) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (selectedRow !== i) e.currentTarget.style.background = '' }}> - ))} -
+ {/* Real table semantics with the CSS grid layout kept via display + overrides: thead is the header grid, each tr is a row grid (grid + items blockify, so td/th render like the previous divs), and + display:contents wrappers keep buttons as direct grid items. */} + + + + + {results.columns.map(col => ( + + ))} + + {/* Rows contain interactive cells (JsonCell buttons), so the row keydown ignores bubbled events from those child buttons so Enter there doesn't both activate and select. tabIndex={-1} lets a clicked row take focus so Enter/Space toggles selection on the row itself. */} - {sortedRows.map((row, i) => ( -
onSelectRow(i === selectedRow ? null : i)} - onKeyDown={e => { if (e.target !== e.currentTarget) { return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectRow(i === selectedRow ? null : i) } }} - className="grid cursor-default transition-colors" style={{ gridTemplateColumns: gridColumns, background: selectedRow === i ? 'rgba(59,92,204,0.06)' : undefined }} - onMouseEnter={e => { if (selectedRow !== i) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (selectedRow !== i) e.currentTarget.style.background = '' }}> - - {results.columns.map(col => ( - - ))} -
- ))} - + + {sortedRows.map((row, i) => ( + onSelectRow(i === selectedRow ? null : i)} + onKeyDown={e => { if (e.target !== e.currentTarget) { return } if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelectRow(i === selectedRow ? null : i) } }} + className="grid cursor-default transition-colors" style={{ gridTemplateColumns: gridColumns, background: selectedRow === i ? 'rgba(59,92,204,0.06)' : undefined }} + onMouseEnter={e => { if (selectedRow !== i) e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (selectedRow !== i) e.currentTarget.style.background = '' }}> + + {results.columns.map(col => ( + + ))} + + ))} + +
# + +
+ +
{sortedRows.length === 0 &&

No rows

} @@ -617,7 +629,7 @@ function ResultCell({ row, rowIndex, col, editing, changed, editValue, setEditVa const val = row[col] const isNull = val == null return ( -
{ e.stopPropagation(); startCellEdit(rowIndex, col, val) }}> {editing ? ( setEditValue(e.target.value)} @@ -629,7 +641,7 @@ function ResultCell({ row, rowIndex, col, editing, changed, editValue, setEditVa ) : ( )} -
+ ) } diff --git a/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx b/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx index 3959a1e..d52f17f 100644 --- a/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx +++ b/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx @@ -311,7 +311,7 @@ describe('DatabaseExplorer — results grid', () => { it('selects rows via click and Enter/Space keydown and shows the detail panel', async () => { const { api } = await renderConnected() await runSql(api, 'SELECT * FROM users') - const aliceRow = (await screen.findByText('alice')).closest('div.grid') as HTMLElement + const aliceRow = (await screen.findByText('alice')).closest('tr') as HTMLElement fireEvent.click(aliceRow) fireEvent.click(screen.getByTitle('Row detail')) const detail = screen.getByText('Row 1').parentElement!.parentElement as HTMLElement @@ -321,7 +321,7 @@ describe('DatabaseExplorer — results grid', () => { fireEvent.click(aliceRow) expect(screen.queryByText('Row 1')).toBeNull() // select bob via Enter key: NULL meta shown in detail - const bobRow = screen.getByText('bob').closest('div.grid') as HTMLElement + const bobRow = screen.getByText('bob').closest('tr') as HTMLElement fireEvent.keyDown(bobRow, { key: 'Enter' }) await screen.findByText('Row 2') // deselect via Space key @@ -424,7 +424,7 @@ describe('DatabaseExplorer — cell editing', () => { it('edits a cell (object value uses JSON.stringify) and issues an UPDATE', async () => { const { api } = await browseUsers() - const metaBadge = screen.getByText('{1}').closest('div')! + const metaBadge = screen.getByText('{1}').closest('td')! fireEvent.doubleClick(metaBadge) const input = await screen.findByDisplayValue('{"role":"admin"}') fireEvent.change(input, { target: { value: '{"role":"user"}' } }) @@ -620,13 +620,13 @@ describe('DatabaseExplorer — watch mode', () => { // first tick: changed cell highlighted await act(async () => { await vi.advanceTimersByTimeAsync(1000) }) - const changed = screen.getByText('alicia').closest('div') as HTMLElement + const changed = screen.getByText('alicia').closest('td') as HTMLElement expect(changed.style.background).toContain('245') // highlight clears after 3s (next tick at 4s adds a row first) await act(async () => { await vi.advanceTimersByTimeAsync(2000) }) // findBy* hangs under fake timers — the advance above already flushed the tick - const added = screen.getByText('bob').closest('div') as HTMLElement + const added = screen.getByText('bob').closest('td') as HTMLElement expect(added.style.background).toContain('245') // stop watching @@ -635,7 +635,7 @@ describe('DatabaseExplorer — watch mode', () => { const callsAfterStop = api.database.query.mock.calls.length await act(async () => { await vi.advanceTimersByTimeAsync(6000) }) expect(api.database.query.mock.calls).toHaveLength(callsAfterStop) - expect((screen.getByText('bob').closest('div') as HTMLElement).style.background).not.toContain('245') + expect((screen.getByText('bob').closest('td') as HTMLElement).style.background).not.toContain('245') }) it('keeps polling silently through query errors and clears timers on unmount', async () => { diff --git a/src/renderer/src/components/Sidebar/__tests__/Sidebar.more.test.tsx b/src/renderer/src/components/Sidebar/__tests__/Sidebar.more.test.tsx index 216c6cc..8dcc00c 100644 --- a/src/renderer/src/components/Sidebar/__tests__/Sidebar.more.test.tsx +++ b/src/renderer/src/components/Sidebar/__tests__/Sidebar.more.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, afterEach, vi } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import Sidebar from '../Sidebar' import { installWindowApi, seedStore, makeSession, WindowApiMock } from '../../../__tests__/harness'