diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e95562..6b73721 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,8 @@ jobs: - name: Install dependencies run: npm ci - - name: Run tests - run: npm test + - name: Run tests with coverage + run: npm run test:coverage - name: Run SonarQube scan if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository diff --git a/.gitignore b/.gitignore index 760b222..b780b2e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ native/rdp-spike/*.ppm # FreeRDP static-build working dirs (see build-freerdp-static.sh) native/rdp-spike/.freerdp-src/ native/rdp-spike/.freerdp-static/ +coverage/ diff --git a/native/rdp-spike/sidecar.c b/native/rdp-spike/sidecar.c index 2f87d65..d632bd7 100644 --- a/native/rdp-spike/sidecar.c +++ b/native/rdp-spike/sidecar.c @@ -25,15 +25,23 @@ * This milestone is output-only (read-only desktop view). Input injection * (mouse/keyboard over stdin) is the next milestone. * - * Usage: rdp-sidecar [width] [height] + * Usage: rdp-sidecar [width] [height] + * The password is read as the first line of stdin so it never appears in the + * process list. */ #include #include #include +#ifdef _WIN32 +#include +#include +#endif + #include #include +#include #include #include #include @@ -151,6 +159,88 @@ static BOOL sidecar_end_paint(rdpContext* context) return TRUE; } +/* Certificate handling. The default client callbacks + * (client_cli_verify_certificate_ex) are interactive: they print the cert + * details to *stdout* — corrupting the binary frame channel — and then read a + * Y/N answer from *stdin*, which rdp.ts closes right after the password. The + * EOF rejects the certificate, so any host not already in + * ~/.config/freerdp/known_hosts2 failed to connect. This was the "works some + * of the time" bug: only hosts trusted during earlier interactive testing + * connected, and they broke again whenever Windows rotated its self-signed + * cert. + * + * Windows RDP hosts almost universally present self-signed certs, so we + * accept for the session (return 2 = temporary trust: nothing persisted, no + * stale known_hosts state to go bad later) and log the fingerprint to stderr. + * Same trust-on-use posture as the app's SSH host-key handling; an in-app + * verification UI is a later milestone for both. */ +static DWORD sidecar_verify_certificate(freerdp* instance, const char* host, UINT16 port, + const char* common_name, const char* subject, + const char* issuer, const char* fingerprint, DWORD flags) +{ + (void)instance; + (void)subject; + (void)issuer; + fprintf(stderr, "[sidecar] accepting certificate for %s:%u (CN=%s)\n", host, (unsigned)port, + common_name ? common_name : "?"); + if (fingerprint && !(flags & VERIFY_CERT_FLAG_FP_IS_PEM)) + fprintf(stderr, "[sidecar] fingerprint: %s\n", fingerprint); + return 2; /* trust for this session only */ +} + +static DWORD sidecar_verify_changed_certificate(freerdp* instance, const char* host, UINT16 port, + const char* common_name, const char* subject, + const char* issuer, const char* new_fingerprint, + const char* old_subject, const char* old_issuer, + const char* old_fingerprint, DWORD flags) +{ + (void)old_subject; + (void)old_issuer; + (void)old_fingerprint; + return sidecar_verify_certificate(instance, host, port, common_name, subject, issuer, + new_fingerprint, flags); +} + +/* Map the common connect failures to messages a person can act on. rdp.ts + * surfaces the last "[sidecar] error: ..." stderr line in the RDP tab, so this + * is what the user sees when a connect fails. */ +static const char* connect_error_message(UINT32 code) +{ + switch (code) + { + case FREERDP_ERROR_CONNECT_LOGON_FAILURE: + case FREERDP_ERROR_AUTHENTICATION_FAILED: + return "Sign-in failed: the username or password is incorrect."; + case FREERDP_ERROR_CONNECT_ACCOUNT_LOCKED_OUT: + return "Sign-in failed: the account is locked out."; + case FREERDP_ERROR_CONNECT_ACCOUNT_DISABLED: + return "Sign-in failed: the account is disabled."; + case FREERDP_ERROR_CONNECT_ACCOUNT_EXPIRED: + return "Sign-in failed: the account has expired."; + case FREERDP_ERROR_CONNECT_ACCOUNT_RESTRICTION: + return "Sign-in failed: an account restriction blocked the logon."; + case FREERDP_ERROR_CONNECT_PASSWORD_EXPIRED: + case FREERDP_ERROR_CONNECT_PASSWORD_CERTAINLY_EXPIRED: + return "Sign-in failed: the password has expired and must be changed."; + case FREERDP_ERROR_CONNECT_PASSWORD_MUST_CHANGE: + return "Sign-in failed: the password must be changed before signing in."; + case FREERDP_ERROR_CONNECT_FAILED: + case FREERDP_ERROR_CONNECT_TRANSPORT_FAILED: + return "Could not reach the host. Check the address, port, and that Remote Desktop is enabled."; + case FREERDP_ERROR_DNS_NAME_NOT_FOUND: + case FREERDP_ERROR_DNS_ERROR: + return "Could not resolve the hostname. Check the address."; + case FREERDP_ERROR_TLS_CONNECT_FAILED: + return "TLS negotiation with the host failed."; + case FREERDP_ERROR_SECURITY_NEGO_CONNECT_FAILED: + return "Security negotiation failed. The host may require NLA settings this client did not offer."; + case FREERDP_ERROR_CONNECT_CANCELLED: + return "The connection was cancelled."; + default: + return NULL; + } +} + static BOOL sidecar_post_connect(freerdp* instance) { if (!gdi_init(instance, PIXEL_FORMAT_BGRA32)) @@ -164,6 +254,10 @@ static BOOL sidecar_client_new(freerdp* instance, rdpContext* context) { (void)context; instance->PostConnect = sidecar_post_connect; + /* Replace the interactive CLI cert prompts (stdout/stdin) — see + * sidecar_verify_certificate above. */ + instance->VerifyCertificateEx = sidecar_verify_certificate; + instance->VerifyChangedCertificateEx = sidecar_verify_changed_certificate; return TRUE; } @@ -208,12 +302,32 @@ int main(int argc, char* argv[]) return 2; } +#ifdef _WIN32 + /* stdout defaults to text mode on Windows and translates \n -> \r\n, + * which corrupts the binary frame stream. */ + _setmode(_fileno(stdout), _O_BINARY); +#endif + const char* host = argv[1]; const UINT32 port = (UINT32)strtoul(argv[2], NULL, 10); const char* user = argv[3]; const UINT32 width = (argc >= 5) ? (UINT32)strtoul(argv[4], NULL, 10) : 1280; const UINT32 height = (argc >= 6) ? (UINT32)strtoul(argv[5], NULL, 10) : 800; + /* "DOMAIN\user" must go into separate Domain/Username settings for NLA; + * xfreerdp does this split in its command-line layer, so we mirror it. UPN + * form ("user@domain") is understood natively and passes through as-is. */ + const char* domain = NULL; + char userbuf[256] = { 0 }; + const char* backslash = strchr(user, '\\'); + if (backslash && backslash != user && (size_t)(backslash - user) < sizeof(userbuf)) + { + memcpy(userbuf, user, (size_t)(backslash - user)); + userbuf[backslash - user] = '\0'; + domain = userbuf; + user = backslash + 1; + } + /* Read password from stdin to avoid exposing it in process list */ char pass[256]; if (!fgets(pass, sizeof(pass), stdin)) { @@ -240,18 +354,45 @@ int main(int argc, char* argv[]) freerdp_settings_set_string(settings, FreeRDP_ServerHostname, host); freerdp_settings_set_uint32(settings, FreeRDP_ServerPort, port); freerdp_settings_set_string(settings, FreeRDP_Username, user); + if (domain) + freerdp_settings_set_string(settings, FreeRDP_Domain, domain); freerdp_settings_set_string(settings, FreeRDP_Password, pass); freerdp_settings_set_bool(settings, FreeRDP_IgnoreCertificate, FALSE); freerdp_settings_set_uint32(settings, FreeRDP_DesktopWidth, width); freerdp_settings_set_uint32(settings, FreeRDP_DesktopHeight, height); freerdp_settings_set_uint32(settings, FreeRDP_ColorDepth, 32); + /* The static FreeRDP we ship is trimmed: channel addins (rdpgfx, rdpdr, + * rdpsnd, cliprdr, ...) are not built in. FreeRDP's defaults still enable + * the features backed by those channels, so freerdp_client_load_addins + * tries to load them, fails, and pre-connect aborts before a TCP + * connection is even attempted. Turn every channel-backed feature off — + * this viewer is a plain GDI framebuffer and needs none of them. */ + freerdp_settings_set_bool(settings, FreeRDP_SupportGraphicsPipeline, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_NetworkAutoDetect, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportHeartbeatPdu, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportMultitransport, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_DeviceRedirection, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_RedirectClipboard, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_AudioPlayback, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_AudioCapture, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportDisplayControl, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportGeometryTracking, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_SupportVideoOptimized, FALSE); + freerdp_settings_set_bool(settings, FreeRDP_MultiTouchInput, FALSE); + freerdp* instance = context->instance; int rc = 0; if (!freerdp_connect(instance)) { - fprintf(stderr, "[sidecar] connect failed err=0x%08X\n", freerdp_get_last_error(context)); + const UINT32 err = freerdp_get_last_error(context); + const char* friendly = connect_error_message(err); + if (friendly) + fprintf(stderr, "[sidecar] error: %s\n", friendly); + else + fprintf(stderr, "[sidecar] error: connect failed — %s (0x%08X)\n", + freerdp_get_last_error_string(err), err); rc = 1; goto cleanup; } @@ -279,6 +420,24 @@ int main(int argc, char* argv[]) break; } + /* If the server ended the session, surface why instead of a silent drop. + * A deliberate sign-out/disconnect is a normal end; everything else + * (kicked by another connection, idle timeout, license/protocol errors) + * exits nonzero so rdp.ts shows the reason in the tab. */ + { + const UINT32 info = freerdp_error_info(instance); + const BOOL normalEnd = info == ERRINFO_SUCCESS || + info == ERRINFO_RPC_INITIATED_DISCONNECT || + info == ERRINFO_RPC_INITIATED_LOGOFF || + info == ERRINFO_LOGOFF_BY_USER; + if (!normalEnd) + { + fprintf(stderr, "[sidecar] error: session ended by server — %s\n", + freerdp_get_error_info_string(info)); + rc = 1; + } + } + freerdp_disconnect(instance); cleanup: diff --git a/package-lock.json b/package-lock.json index 7e745cd..6de13f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "@types/react-dom": "^18.3.0", "@types/ssh2": "^1.15.1", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.19", "electron": "^31.7.7", "electron-builder": "^24.13.3", @@ -446,6 +447,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3119,6 +3130,37 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", @@ -3681,6 +3723,25 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -6286,6 +6347,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -6614,6 +6682,45 @@ "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", "license": "MIT" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -7375,6 +7482,47 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", diff --git a/package.json b/package.json index 1b85a5c..ee0410c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "pack": "electron-vite build && electron-builder --dir", "dist": "electron-vite build && electron-builder", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@codemirror/lang-css": "^6.3.1", @@ -65,6 +66,7 @@ "@types/react-dom": "^18.3.0", "@types/ssh2": "^1.15.1", "@vitejs/plugin-react": "^4.3.1", + "@vitest/coverage-v8": "^4.1.5", "autoprefixer": "^10.4.19", "electron": "^31.7.7", "electron-builder": "^24.13.3", diff --git a/sonar-project.properties b/sonar-project.properties index ff697ea..71ad788 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,7 +3,9 @@ sonar.projectName=noxed sonar.sources=src sonar.tests=src -sonar.test.inclusions=src/**/__tests__/**/*.test.ts -sonar.exclusions=src/**/__tests__/**/*.test.ts,src/preload/index.d.ts +sonar.test.inclusions=src/**/__tests__/**/*.test.ts,src/**/__tests__/**/*.test.tsx +sonar.exclusions=src/**/__tests__/**,src/preload/index.d.ts + +sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.sourceEncoding=UTF-8 diff --git a/src/main/index.ts b/src/main/index.ts index 65f69a5..ed78a7d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -10,8 +10,8 @@ process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err) }) -import { join } from 'path' -import { readFileSync } from 'fs' +import { join } from 'node:path' +import { readFileSync } from 'node:fs' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import { registerSshHandlers, disposeSshStreamsForSender } from './ipc/ssh' import { registerSftpHandlers, disposeSftpClientsForSender } from './ipc/sftp' diff --git a/src/main/ipc/__tests__/database.test.ts b/src/main/ipc/__tests__/database.test.ts new file mode 100644 index 0000000..c92e3e4 --- /dev/null +++ b/src/main/ipc/__tests__/database.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, type Mock } from 'vitest' + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), on: vi.fn() }, +})) +vi.mock('pg', () => { + const query = vi.fn().mockResolvedValue({ fields: [{ name: 'x' }], rows: [{ x: 1 }], rowCount: 1 }) + const connect = vi.fn().mockResolvedValue({ release: vi.fn() }) + // Regular function so `new Pool(...)` works (arrows are not constructible) + const PoolCtor = vi.fn(function Pool() { + return { query, connect, end: vi.fn(), on: vi.fn() } + }) + return { Pool: PoolCtor, __query: query } +}) +vi.mock('mysql2/promise', () => ({ + default: { createPool: vi.fn() }, +})) + +import { ipcMain } from 'electron' +import * as pg from 'pg' +import { registerDatabaseHandlers } from '../database' + +registerDatabaseHandlers() + +type Handler = (...args: unknown[]) => unknown + +function handler(channel: string): Handler { + const call = (ipcMain.handle as Mock).mock.calls.find((c) => c[0] === channel) + if (!call) throw new Error(`No handler registered for ${channel}`) + return call[1] as Handler +} + +const event = { sender: { id: 1 } } + +async function connectPg(): Promise { + return (await handler('db:connect')(event, { + dbType: 'postgresql', host: 'db.example.com', port: 5432, + username: 'u', password: 'p', database: 'appdb', + })) as string +} + +describe('db:query parameter validation', () => { + it('forwards scalar bind parameters to the driver', async () => { + const id = await connectPg() + await handler('db:query')(event, id, 'UPDATE "t" SET "a" = $1 WHERE "id" = $2', ['x', 7]) + const pgQuery = (pg as unknown as { __query: Mock }).__query + expect(pgQuery).toHaveBeenCalledWith('UPDATE "t" SET "a" = $1 WHERE "id" = $2', ['x', 7]) + }) + + it('accepts omitted and null params', async () => { + const id = await connectPg() + const result = (await handler('db:query')(event, id, 'SELECT 1')) as { rowCount: number } + expect(result.rowCount).toBe(1) + await handler('db:query')(event, id, 'SELECT 1', null) + const pgQuery = (pg as unknown as { __query: Mock }).__query + expect(pgQuery).toHaveBeenLastCalledWith('SELECT 1', undefined) + }) + + it('accepts null, boolean, and number parameter values', async () => { + const id = await connectPg() + await handler('db:query')(event, id, 'SELECT $1, $2, $3', [null, true, 3.5]) + const pgQuery = (pg as unknown as { __query: Mock }).__query + expect(pgQuery).toHaveBeenLastCalledWith('SELECT $1, $2, $3', [null, true, 3.5]) + }) + + it('rejects non-array params', async () => { + const id = await connectPg() + await expect(handler('db:query')(event, id, 'SELECT 1', 'nope')).rejects.toThrow('Invalid query parameters') + }) + + it('rejects non-scalar parameter values', async () => { + const id = await connectPg() + await expect(handler('db:query')(event, id, 'SELECT $1', [{ evil: true }])) + .rejects.toThrow('Query parameters must be scalar values') + await expect(handler('db:query')(event, id, 'SELECT $1', [undefined])) + .rejects.toThrow('Query parameters must be scalar values') + }) + + it('rejects oversized parameter arrays', async () => { + const id = await connectPg() + await expect(handler('db:query')(event, id, 'SELECT 1', Array(300).fill(1))) + .rejects.toThrow('Invalid query parameters') + }) +}) diff --git a/src/main/ipc/__tests__/docker.test.ts b/src/main/ipc/__tests__/docker.test.ts index 7a52d41..0f95a78 100644 --- a/src/main/ipc/__tests__/docker.test.ts +++ b/src/main/ipc/__tests__/docker.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { EventEmitter } from 'node:events' vi.mock('electron', () => ({ ipcMain: { handle: vi.fn(), on: vi.fn() }, @@ -10,9 +11,30 @@ vi.mock('electron-store', () => ({ set(key: string, value: unknown) { this.data.set(key, value) } }, })) +vi.mock('../sshClients', () => ({ + connectSessionClient: vi.fn(), +})) + +import { ipcMain } from 'electron' +import { connectSessionClient } from '../sshClients' +import { + parseJsonLines, + validateContainerRef, + validateContainerAction, + registerDockerHandlers, + disposeDockerSessionsForSender, +} from '../docker' +import { ValidationError, NotFoundError, OwnershipError, ConnectionError } from '../errors' + +registerDockerHandlers() -import { parseJsonLines, validateContainerRef, validateContainerAction } from '../docker' -import { ValidationError } from '../errors' +type Handler = (...args: unknown[]) => unknown + +function handler(channel: string): Handler { + const call = (ipcMain.handle as Mock).mock.calls.find((c) => c[0] === channel) + if (!call) throw new Error(`No handler registered for ${channel}`) + return call[1] as Handler +} describe('parseJsonLines', () => { it('parses one JSON object per line', () => { @@ -59,3 +81,246 @@ describe('validateContainerAction', () => { } }) }) + +// ── Handler-level tests (fake SSH connection + exec channels) ──────────────── + +class FakeExecStream extends EventEmitter { + stderr = new EventEmitter() + close = vi.fn() +} + +interface FakeSshClient extends EventEmitter { + exec: Mock +} + +function fakeConn() { + const client = new EventEmitter() as FakeSshClient + client.exec = vi.fn() + return { client, dispose: vi.fn() } +} + +interface FakeEvent { + sender: { id: number; isDestroyed: () => boolean; send: Mock } +} + +let senderSeq = 500 +function makeEvent(): FakeEvent { + return { sender: { id: senderSeq++, isDestroyed: () => false, send: vi.fn() } } +} + +async function connectDocker(event: FakeEvent = makeEvent()) { + const conn = fakeConn() + ;(connectSessionClient as Mock).mockResolvedValueOnce(conn) + const id = (await handler('docker:connect')(event, 'session-1')) as string + return { id, conn, event } +} + +/** Sets up exec to hand back a fresh stream for each call and returns them in order. */ +function execYields(conn: ReturnType): FakeExecStream[] { + const streams: FakeExecStream[] = [] + conn.client.exec.mockImplementation((_cmd: string, cb: (e: Error | null, s?: FakeExecStream) => void) => { + const stream = new FakeExecStream() + streams.push(stream) + cb(null, stream) + }) + return streams +} + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('docker:connect / docker:disconnect', () => { + it('rejects a non-string connection id', async () => { + await expect(handler('docker:connect')(makeEvent(), 42)).rejects.toBeInstanceOf(ValidationError) + }) + + it('creates a session and disposes it on disconnect', async () => { + const { id, conn, event } = await connectDocker() + handler('docker:disconnect')(event, id) + expect(conn.dispose).toHaveBeenCalled() + expect(() => handler('docker:disconnect')(event, id)).toThrow(NotFoundError) + }) + + it('enforces session ownership and id shape', async () => { + const { id } = await connectDocker() + expect(() => handler('docker:disconnect')(makeEvent(), id)).toThrow(OwnershipError) + expect(() => handler('docker:disconnect')(makeEvent(), 7)).toThrow(ValidationError) + }) + + it('disposes the session when the SSH connection closes', async () => { + const { id, conn, event } = await connectDocker() + conn.client.emit('close') + expect(conn.dispose).toHaveBeenCalled() + expect(() => handler('docker:disconnect')(event, id)).toThrow(NotFoundError) + }) + + it('disposeDockerSessionsForSender cleans up only that sender', async () => { + const mine = await connectDocker() + const other = await connectDocker() + disposeDockerSessionsForSender(mine.event.sender.id) + expect(mine.conn.dispose).toHaveBeenCalled() + expect(other.conn.dispose).not.toHaveBeenCalled() + handler('docker:disconnect')(other.event, other.id) + }) +}) + +describe('docker exec-based handlers', () => { + it('lists containers by parsing json-lines stdout', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const pending = handler('docker:containers')(event, id) as Promise + const stream = streams[0] + stream.emit('data', Buffer.from('{"ID":"abc","Names":"web"}\n')) + stream.emit('data', Buffer.from('{"ID":"def","Names":"db"}\n')) + stream.emit('close', 0) + await expect(pending).resolves.toEqual([ + { ID: 'abc', Names: 'web' }, + { ID: 'def', Names: 'db' }, + ]) + expect(conn.client.exec.mock.calls[0][0]).toContain('docker ps -a') + }) + + it('resolves stdout when the channel closes without an exit code but with output', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const pending = handler('docker:stats')(event, id) as Promise + streams[0].emit('data', Buffer.from('{"CPUPerc":"1.0%"}\n')) + streams[0].emit('close', null) + await expect(pending).resolves.toEqual([{ CPUPerc: '1.0%' }]) + }) + + it('maps exit code 127 to a missing-docker error', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const pending = handler('docker:images')(event, id) + streams[0].emit('close', 127) + await expect(pending).rejects.toThrow('Docker CLI not found on this host') + }) + + it('surfaces stderr on non-zero exit, falling back to the exit code', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const withStderr = handler('docker:containers')(event, id) + streams[0].stderr.emit('data', Buffer.from('permission denied\n')) + streams[0].emit('close', 1) + await expect(withStderr).rejects.toThrow('permission denied') + + const bare = handler('docker:containers')(event, id) + streams[1].emit('close', 2) + await expect(bare).rejects.toThrow('Command failed (exit 2)') + }) + + it('rejects when exec itself fails or the stream errors', async () => { + const { id, conn, event } = await connectDocker() + conn.client.exec.mockImplementationOnce((_c: string, cb: (e: Error | null) => void) => cb(new Error('channel open failed'))) + await expect(handler('docker:containers')(event, id)).rejects.toBeInstanceOf(ConnectionError) + + const streams = execYields(conn) + const pending = handler('docker:containers')(event, id) + streams[0].emit('error', new Error('reset')) + await expect(pending).rejects.toBeInstanceOf(ConnectionError) + }) + + it('times out long-running commands', async () => { + vi.useFakeTimers() + try { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const pending = handler('docker:containers')(event, id) + vi.advanceTimersByTime(20_000) + await expect(pending).rejects.toThrow('Remote command timed out') + expect(streams[0].close).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('returns the first parsed object for docker:info, or null', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const withInfo = handler('docker:info')(event, id) + streams[0].emit('data', Buffer.from('{"ServerVersion":"27.0"}\n')) + streams[0].emit('close', 0) + await expect(withInfo).resolves.toEqual({ ServerVersion: '27.0' }) + + const empty = handler('docker:info')(event, id) + streams[1].emit('close', 0) + await expect(empty).resolves.toBeNull() + }) + + it('runs validated container actions, forcing rm', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const pending = handler('docker:action')(event, id, 'web-1', 'rm') + streams[0].emit('close', 0) + await pending + expect(conn.client.exec.mock.calls[0][0]).toBe('docker rm -f web-1') + await expect(handler('docker:action')(event, id, 'web;rm -rf /', 'stop')).rejects.toBeInstanceOf(ValidationError) + await expect(handler('docker:action')(event, id, 'web-1', 'exec')).rejects.toBeInstanceOf(ValidationError) + }) +}) + +describe('docker log streaming', () => { + it('starts a log stream and forwards stdout/stderr chunks to the renderer', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const logId = (await handler('docker:logsStart')(event, id, 'web-1', 500)) as string + expect(typeof logId).toBe('string') + expect(conn.client.exec.mock.calls[0][0]).toBe('docker logs --tail 500 -f web-1 2>&1') + + streams[0].emit('data', Buffer.from('line one\n')) + streams[0].stderr.emit('data', Buffer.from('warn line\n')) + expect(event.sender.send).toHaveBeenCalledWith('docker:logChunk', logId, 'line one\n') + expect(event.sender.send).toHaveBeenCalledWith('docker:logChunk', logId, 'warn line\n') + + streams[0].emit('close') + expect(event.sender.send).toHaveBeenCalledWith('docker:logEnd', logId, null) + }) + + it('clamps the tail and defaults it for invalid values', async () => { + const { id, conn, event } = await connectDocker() + execYields(conn) + await handler('docker:logsStart')(event, id, 'web-1', 1_000_000) + expect(conn.client.exec.mock.calls[0][0]).toContain('--tail 10000 ') + await handler('docker:logsStart')(event, id, 'web-1', 'lots') + expect(conn.client.exec.mock.calls[1][0]).toContain('--tail 200 ') + }) + + it('reports stream errors through docker:logEnd', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const logId = (await handler('docker:logsStart')(event, id, 'web-1', 100)) as string + streams[0].emit('error', new Error('broken pipe')) + expect(event.sender.send).toHaveBeenCalledWith('docker:logEnd', logId, 'broken pipe') + }) + + it('rejects when the log exec fails and validates the container ref', async () => { + const { id, conn, event } = await connectDocker() + conn.client.exec.mockImplementationOnce((_c: string, cb: (e: Error | null) => void) => cb(new Error('nope'))) + await expect(handler('docker:logsStart')(event, id, 'web-1', 100)).rejects.toBeInstanceOf(ConnectionError) + expect(() => handler('docker:logsStart')(event, id, 'bad name', 100)).toThrow(ValidationError) + }) + + it('stops log streams with ownership checks', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + const logId = (await handler('docker:logsStart')(event, id, 'web-1', 100)) as string + + expect(() => handler('docker:logsStop')(makeEvent(), logId)).toThrow(OwnershipError) + expect(() => handler('docker:logsStop')(event, 42)).toThrow(ValidationError) + expect(handler('docker:logsStop')(event, 'unknown-id')).toBeUndefined() + + handler('docker:logsStop')(event, logId) + expect(streams[0].close).toHaveBeenCalled() + }) + + it('stops a sender\'s log streams when its session is disposed', async () => { + const { id, conn, event } = await connectDocker() + const streams = execYields(conn) + await handler('docker:logsStart')(event, id, 'web-1', 100) + handler('docker:disconnect')(event, id) + expect(streams[0].close).toHaveBeenCalled() + expect(conn.dispose).toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/__tests__/k8s.test.ts b/src/main/ipc/__tests__/k8s.test.ts new file mode 100644 index 0000000..845ec94 --- /dev/null +++ b/src/main/ipc/__tests__/k8s.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach, afterAll, type Mock } from 'vitest' +import * as net from 'node:net' +import type { PassThrough } from 'node:stream' + +const h = vi.hoisted(() => ({ + api: { + listNamespacedEvent: vi.fn(), + }, + portForward: vi.fn(async () => undefined), + execFn: vi.fn(), + logFn: vi.fn(), +})) + +vi.mock('@kubernetes/client-node', () => { + class KubeConfig { + loadFromDefault = vi.fn() + loadFromFile = vi.fn() + getContexts() { return [{ name: 'ctx', cluster: 'c' }] } + getClusters() { return [{ name: 'c', server: 'https://cluster.example.test' }] } + 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(() => '/tmp/noxed-test-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 } from 'electron' +import { registerK8sHandlers, disposeK8sSessionsForSender } from '../k8s' +import { OwnershipError, ConnectionError } from '../errors' + +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 +} + +function onHandler(channel: string): Handler { + const call = (ipcMain.on as Mock).mock.calls.find((c) => c[0] === channel) + if (!call) throw new Error(`No on handler registered for ${channel}`) + return call[1] as Handler +} + +interface FakeEvent { + sender: { id: number; isDestroyed: () => boolean; send: Mock } +} + +const usedSenderIds: number[] = [] +let senderSeq = 900 +function makeEvent(): FakeEvent { + const id = senderSeq++ + usedSenderIds.push(id) + return { sender: { id, isDestroyed: () => false, send: vi.fn() } } +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)) + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterAll(() => { + for (const id of usedSenderIds) disposeK8sSessionsForSender(id) +}) + +describe('k8s contexts', () => { + it('lists context names and servers from the default kubeconfig', () => { + expect(handler('k8s:contexts')({})).toEqual(['ctx']) + expect(handler('k8s:contextsDetailed')({})).toEqual([ + { name: 'ctx', server: 'https://cluster.example.test' }, + ]) + }) +}) + +describe('k8s:events', () => { + it('sorts newest-first by lastTimestamp and maps fields with defaults', async () => { + h.api.listNamespacedEvent.mockResolvedValueOnce({ + body: { + items: [ + { + metadata: { name: 'older', namespace: 'default' }, + lastTimestamp: '2026-01-01T00:00:00Z', + type: 'Warning', + reason: 'BackOff', + message: 'restarting', + involvedObject: { kind: 'Pod', name: 'api-1' }, + count: 4, + }, + { + metadata: { name: 'newest', namespace: 'default' }, + lastTimestamp: '2026-06-01T00:00:00Z', + involvedObject: { kind: 'Pod', name: 'api-2' }, + }, + { + metadata: { name: 'undated', namespace: 'default', creationTimestamp: '2025-12-01T00:00:00Z' }, + involvedObject: { kind: 'Node', name: 'n1' }, + }, + ], + }, + }) + const rows = (await handler('k8s:events')({}, 'ctx', 'default')) as Array> + expect(rows.map((r) => r.name)).toEqual(['newest', 'older', 'undated']) + expect(rows[0]).toMatchObject({ type: 'Normal', reason: '', message: '', count: 1, object: 'Pod/api-2' }) + expect(rows[1]).toMatchObject({ type: 'Warning', reason: 'BackOff', count: 4 }) + expect(rows[2].age).toBe('2025-12-01T00:00:00Z') + }) + + it('rejects an unknown context', async () => { + await expect(handler('k8s:events')({}, 'nope', 'default')).rejects.toThrow('Unknown kube context') + }) +}) + +describe('k8s:portForwardStart', () => { + const start = (event: FakeEvent, localPort?: unknown) => + handler('k8s:portForwardStart')(event, 'ctx', 'default', 'pod-1', 8080, localPort) as Promise<{ id: string; localPort: number }> + + it('rejects an invalid explicit local port', async () => { + await expect(start(makeEvent(), 99_999)).rejects.toThrow('Invalid local port') + await expect(start(makeEvent(), 'eighty')).rejects.toThrow('Invalid local port') + }) + + it('listens on an ephemeral port and records the session', async () => { + const event = makeEvent() + const { id, localPort } = await start(event) + expect(id).toMatch(/^k8s_/) + expect(localPort).toBeGreaterThan(0) + + const listed = handler('k8s:portForwardList')(event) as Array> + expect(listed).toEqual([ + { id, context: 'ctx', namespace: 'default', podName: 'pod-1', targetPort: 8080, localPort }, + ]) + // Another sender sees nothing. + expect(handler('k8s:portForwardList')(makeEvent())).toEqual([]) + + handler('k8s:portForwardStop')(event, id) + expect(handler('k8s:portForwardList')(event)).toEqual([]) + }) + + it('forwards incoming sockets through the k8s PortForward API and destroys them on failure', async () => { + const event = makeEvent() + h.portForward.mockRejectedValueOnce(new Error('pod gone')) + const { id, localPort } = await start(event) + + const socket = net.connect(localPort, '127.0.0.1') + socket.on('error', () => {}) // server side destroys the socket; ignore ECONNRESET + const closed = new Promise((resolve) => socket.on('close', () => resolve())) + await vi.waitFor(() => expect(h.portForward).toHaveBeenCalled()) + expect(h.portForward).toHaveBeenCalledWith('default', 'pod-1', [8080], expect.anything(), null, expect.anything()) + await closed + + handler('k8s:portForwardStop')(event, id) + }) + + it('rejects with ConnectionError when the local port is already taken', async () => { + const event = makeEvent() + const first = await start(event) + await expect(start(event, first.localPort)).rejects.toBeInstanceOf(ConnectionError) + handler('k8s:portForwardStop')(event, first.id) + }) + + it('enforces ownership on stop and tolerates bogus ids', async () => { + const event = makeEvent() + const { id } = await start(event) + expect(() => handler('k8s:portForwardStop')(makeEvent(), id)).toThrow(OwnershipError) + expect(handler('k8s:portForwardStop')(event, 42)).toBeUndefined() + expect(handler('k8s:portForwardStop')(event, 'k8s_missing')).toBeUndefined() + handler('k8s:portForwardStop')(event, id) + }) +}) + +describe('k8s exec sessions', () => { + interface CapturedExec { + stdout: PassThrough + stdin: PassThrough + statusCb: (status: unknown) => void + ws: { close: Mock; send: Mock } + } + + async function startExec(event: FakeEvent = makeEvent(), ws?: Partial) { + const captured = {} as CapturedExec + captured.ws = { close: vi.fn(), send: vi.fn(), ...ws } as CapturedExec['ws'] + h.execFn.mockImplementationOnce(async ( + _ns: string, _pod: string, _container: string, _cmd: string[], + stdout: PassThrough, _stderr: PassThrough, stdin: PassThrough, + _tty: boolean, statusCb: (status: unknown) => void, + ) => { + captured.stdout = stdout + captured.stdin = stdin + captured.statusCb = statusCb + return captured.ws + }) + const sessionId = (await handler('k8s:execStart')(event, 'ctx', 'default', 'pod-1', 'main')) as string + return { sessionId, captured, event } + } + + it('starts a shell and streams stdout to the renderer', async () => { + const { sessionId, captured, event } = await startExec() + expect(sessionId).toMatch(/^k8s_/) + captured.stdout.write('shell output') + await flush() + expect(event.sender.send).toHaveBeenCalledWith('k8s:execData', sessionId, 'shell output') + handler('k8s:execStop')(event, sessionId) + }) + + it('k8s:execSend writes to stdin only for the owning sender and sane payloads', async () => { + const { sessionId, captured, event } = await startExec() + const received: string[] = [] + captured.stdin.on('data', (c: Buffer) => received.push(c.toString())) + const send = onHandler('k8s:execSend') + + send(event, sessionId, 'ls -la\n') + await flush() + expect(received.join('')).toBe('ls -la\n') + + send(makeEvent(), sessionId, 'whoami\n') // wrong sender + send(event, sessionId, 42) // non-string payload + send(event, 42, 'x') // non-string session id + send(event, 'k8s_missing', 'x') // unknown session + send(event, sessionId, 'y'.repeat(65 * 1024)) // oversized + await flush() + expect(received.join('')).toBe('ls -la\n') + + handler('k8s:execStop')(event, sessionId) + }) + + it('k8s:execResize sends a resize control frame over the websocket', async () => { + const { sessionId, captured, event } = await startExec() + const resize = onHandler('k8s:execResize') + + resize(event, sessionId, 80, 24) + expect(captured.ws.send).toHaveBeenCalledTimes(1) + const buf = captured.ws.send.mock.calls[0][0] as Buffer + expect([...buf]).toEqual([4, 0, 24, 0, 80]) + + resize(makeEvent(), sessionId, 80, 24) // wrong sender + resize(event, sessionId, 0, 24) // out of range + resize(event, sessionId, 80.5, 24) // non-integer + resize(event, 42, 80, 24) // bad id + expect(captured.ws.send).toHaveBeenCalledTimes(1) + + captured.ws.send.mockImplementation(() => { throw new Error('socket closed') }) + expect(() => resize(event, sessionId, 100, 40)).not.toThrow() + + handler('k8s:execStop')(event, sessionId) + }) + + it('ignores resize when the websocket has no send method', async () => { + const { sessionId, event } = await startExec(makeEvent(), { send: undefined }) + expect(() => onHandler('k8s:execResize')(event, sessionId, 80, 24)).not.toThrow() + handler('k8s:execStop')(event, sessionId) + }) + + it('notifies the renderer and clears the session when the shell exits', async () => { + const { sessionId, captured, event } = await startExec() + captured.statusCb({ status: 'Success' }) + expect(event.sender.send).toHaveBeenCalledWith('k8s:execClose', sessionId, { status: 'Success' }) + // Session is gone: send becomes a no-op and stop finds nothing. + const received: string[] = [] + captured.stdin.on('data', (c: Buffer) => received.push(c.toString())) + onHandler('k8s:execSend')(event, sessionId, 'late\n') + await flush() + expect(received).toEqual([]) + expect(handler('k8s:execStop')(event, sessionId)).toBeUndefined() + }) + + it('enforces ownership on execStop and closes the websocket on dispose', async () => { + const { sessionId, captured, event } = await startExec() + expect(() => handler('k8s:execStop')(makeEvent(), sessionId)).toThrow(OwnershipError) + handler('k8s:execStop')(event, sessionId) + expect(captured.ws.close).toHaveBeenCalled() + }) + + it('disposeK8sSessionsForSender tears down exec sessions', async () => { + const { sessionId, captured, event } = await startExec() + disposeK8sSessionsForSender(event.sender.id) + expect(captured.ws.close).toHaveBeenCalled() + expect(handler('k8s:execStop')(event, sessionId)).toBeUndefined() + }) +}) diff --git a/src/main/ipc/__tests__/keychain.test.ts b/src/main/ipc/__tests__/keychain.test.ts index b2dcdab..39cb4e1 100644 --- a/src/main/ipc/__tests__/keychain.test.ts +++ b/src/main/ipc/__tests__/keychain.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { pbkdf2Sync, randomBytes } from 'crypto' +import { pbkdf2Sync, randomBytes } from 'node:crypto' // Extracted pure functions mirroring keychain.ts implementation for testing @@ -153,13 +153,6 @@ describe('Keychain — brute-force protection', () => { }) describe('Keychain — auth mode verification logic', () => { - it('none mode always succeeds', () => { - // mode === 'none' → always { success: true } - // This is a direct mapping of the production logic - const mode = 'none' - expect(mode === 'none').toBe(true) - }) - it('pin/password mode requires a credential', () => { const credential = undefined const result = !credential ? { success: false, error: 'Credential required' } : { success: true } @@ -179,13 +172,13 @@ describe('Keychain — auth mode verification logic', () => { const salt = randomBytes(16).toString('hex') const storedHash = hashCredential('1234', salt) const inputHash = hashCredential('1234', salt) - expect(inputHash === storedHash).toBe(true) + expect(inputHash).toBe(storedHash) }) it('pin/password mode rejects incorrect credential', () => { const salt = randomBytes(16).toString('hex') const storedHash = hashCredential('1234', salt) const wrongHash = hashCredential('5678', salt) - expect(wrongHash === storedHash).toBe(false) + expect(wrongHash).not.toBe(storedHash) }) }) diff --git a/src/main/ipc/__tests__/metrics.test.ts b/src/main/ipc/__tests__/metrics.test.ts index 04de70f..4f62586 100644 --- a/src/main/ipc/__tests__/metrics.test.ts +++ b/src/main/ipc/__tests__/metrics.test.ts @@ -48,4 +48,66 @@ describe('parseMetricsOutput', () => { expect(metrics.available).toBe(false) expect(cpuStat).toBeUndefined() }) + + it('clamps CPU to 0 when idle jiffies outgrow the total delta', () => { + const prev = { idle: 0, total: 100 } + const out = LINUX_OUTPUT.replace( + 'cpu 100 0 100 700 100 0 0 0 0 0', + 'cpu 50 0 0 900 50 0 0 0 0 0', + ) + const { metrics } = parseMetricsOutput(out, prev) + expect(metrics.cpu).toBe(0) + }) + + it('clamps CPU to 100 when idle time appears to go backwards', () => { + const prev = { idle: 800, total: 1000 } + const out = LINUX_OUTPUT.replace( + 'cpu 100 0 100 700 100 0 0 0 0 0', + 'cpu 1500 0 400 100 0 0 0 0 0 0', + ) + const { metrics } = parseMetricsOutput(out, prev) + expect(metrics.cpu).toBe(100) + }) + + it('reports 0% CPU when the counter total has not advanced', () => { + const first = parseMetricsOutput(LINUX_OUTPUT) + const { metrics } = parseMetricsOutput(LINUX_OUTPUT, first.cpuStat) + expect(metrics.cpu).toBe(0) + }) + + it('tolerates missing sections and partial meminfo', () => { + const out = 'cpu 100 0 100 700 100 0 0 0 0 0\n::MEM::MemTotal: 2048 kB' + const { metrics } = parseMetricsOutput(out) + expect(metrics.memTotal).toBe(2048 * 1024) + // No MemAvailable line: everything counts as used + expect(metrics.memUsed).toBe(2048 * 1024) + expect(metrics.diskTotal).toBe(0) + expect(metrics.diskUsed).toBe(0) + expect(metrics.load1).toBe(0) + expect(metrics.uptimeSec).toBe(0) + expect(metrics.available).toBe(true) + }) + + it('ignores garbage in the disk, load, and uptime sections', () => { + const out = [ + 'cpu 100 0 100 700 100 0 0 0 0 0', + '::MEM::MemTotal: 1000 kB', + 'MemAvailable: 500 kB', + '::DISK::df: /: No such file or directory', + '::LOAD::not-a-number', + '::UP::soon', + ].join('\n') + const { metrics } = parseMetricsOutput(out) + expect(metrics.diskTotal).toBe(0) + expect(metrics.diskUsed).toBe(0) + expect(metrics.load1).toBe(0) + expect(metrics.uptimeSec).toBe(0) + }) + + it('handles non-numeric cpu lines without producing a sample', () => { + const out = `intr weird output\n${LINUX_OUTPUT.slice(LINUX_OUTPUT.indexOf('::MEM::'))}` + const { metrics, cpuStat } = parseMetricsOutput(out) + expect(metrics.cpu).toBe(0) + expect(cpuStat).toBeUndefined() + }) }) diff --git a/src/main/ipc/__tests__/rdp.test.ts b/src/main/ipc/__tests__/rdp.test.ts new file mode 100644 index 0000000..0d6d465 --- /dev/null +++ b/src/main/ipc/__tests__/rdp.test.ts @@ -0,0 +1,293 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { EventEmitter } from 'node:events' + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), on: vi.fn() }, +})) +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})) +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => true), +})) +vi.mock('@electron-toolkit/utils', () => ({ + is: { dev: true }, +})) + +import { ipcMain } from 'electron' +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { registerRdpHandlers, disposeRdpSessionsForSender } from '../rdp' +import { ValidationError, NotFoundError, OwnershipError, ConnectionError } from '../errors' + +registerRdpHandlers() + +// 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 handler registered for ${channel}`) + return call[1] as Handler +} + +class FakeStdin extends EventEmitter { + write = vi.fn() + end = vi.fn() +} + +class FakeProc extends EventEmitter { + stdout = new EventEmitter() + stderr = new EventEmitter() + stdin = new FakeStdin() + kill = vi.fn() +} + +interface FakeEvent { + sender: { id: number; isDestroyed: () => boolean; send: Mock } +} + +let senderSeq = 1 +function makeEvent(destroyed = false): FakeEvent { + return { sender: { id: senderSeq++, isDestroyed: () => destroyed, send: vi.fn() } } +} + +const VALID_CONFIG = { host: 'rdp.example.com', username: 'admin', password: 'secret' } + +function connect(config: Record = VALID_CONFIG, event: FakeEvent = makeEvent()) { + const proc = new FakeProc() + ;(spawn as Mock).mockReturnValueOnce(proc) + const id = handler('rdp:connect')(event, config) as string + return { proc, id, event } +} + +/** Builds a valid NXF1 frame: 16-byte header + w*h*4 BGRA bytes. */ +function frame(w: number, h: number, fill = 0xab): Buffer { + const data = Buffer.alloc(w * h * 4, fill) + const head = Buffer.alloc(16) + head.write('NXF1', 0, 'ascii') + head.writeUInt32LE(w, 4) + head.writeUInt32LE(h, 8) + head.writeUInt32LE(data.length, 12) + return Buffer.concat([head, data]) +} + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) + ;(existsSync as Mock).mockReturnValue(true) +}) + +describe('rdp:connect validation', () => { + it('rejects missing or invalid host, username, and password', () => { + const connectHandler = handler('rdp:connect') + const event = makeEvent() + expect(() => connectHandler(event, { username: 'u', password: 'p' })).toThrow(ValidationError) + expect(() => connectHandler(event, { host: '', username: 'u', password: 'p' })).toThrow(ValidationError) + expect(() => connectHandler(event, { host: 'h', password: 'p' })).toThrow(ValidationError) + expect(() => connectHandler(event, { host: 'h', username: 'u', password: 42 })).toThrow(ValidationError) + expect(() => connectHandler(event, null)).toThrow(ValidationError) + }) + + it('throws a ConnectionError when the sidecar binary is missing', () => { + ;(existsSync as Mock).mockReturnValue(false) + expect(() => handler('rdp:connect')(makeEvent(), VALID_CONFIG)).toThrow(ConnectionError) + }) + + it('spawns the sidecar with defaults when optional numbers are absent', () => { + connect() + const [, args, opts] = (spawn as Mock).mock.calls.at(-1)! + expect(args).toEqual(['rdp.example.com', '3389', 'admin', '1280', '800']) + expect(opts).toEqual({ stdio: ['pipe', 'pipe', 'pipe'] }) + }) + + it('clamps out-of-range port, width, and height', () => { + connect({ ...VALID_CONFIG, port: 99_999, width: 100, height: 100_000 }) + const [, args] = (spawn as Mock).mock.calls.at(-1)! + expect(args).toEqual(['rdp.example.com', '65535', 'admin', '640', '7680']) + }) + + it('writes the password to stdin and closes it', () => { + const { proc } = connect() + expect(proc.stdin.write).toHaveBeenCalledWith('secret\n') + expect(proc.stdin.end).toHaveBeenCalled() + }) + + it('survives a stdin write error (EPIPE from an instantly-dead sidecar)', () => { + const { proc } = connect() + expect(() => proc.stdin.emit('error', new Error('EPIPE'))).not.toThrow() + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('stdin write failed')) + }) +}) + +describe('frame stream parsing', () => { + it('forwards a complete frame to the renderer', () => { + const { proc, id, event } = connect() + proc.stdout.emit('data', frame(2, 2)) + expect(event.sender.send).toHaveBeenCalledTimes(1) + const [channel, sentId, w, h, pixels] = event.sender.send.mock.calls[0] + expect(channel).toBe('rdp:frame') + expect(sentId).toBe(id) + expect(w).toBe(2) + expect(h).toBe(2) + expect(pixels as Buffer).toHaveLength(16) + }) + + it('buffers partial frames across chunks', () => { + const { proc, event } = connect() + const full = frame(3, 2) + proc.stdout.emit('data', full.subarray(0, 10)) // partial header + expect(event.sender.send).not.toHaveBeenCalled() + proc.stdout.emit('data', full.subarray(10, 20)) // header complete, pixels partial + expect(event.sender.send).not.toHaveBeenCalled() + proc.stdout.emit('data', full.subarray(20)) + expect(event.sender.send).toHaveBeenCalledTimes(1) + expect(event.sender.send.mock.calls[0][2]).toBe(3) + }) + + it('drains multiple frames from a single chunk', () => { + const { proc, event } = connect() + proc.stdout.emit('data', Buffer.concat([frame(1, 1, 0x01), frame(2, 1, 0x02)])) + expect(event.sender.send).toHaveBeenCalledTimes(2) + expect(event.sender.send.mock.calls[0][2]).toBe(1) + expect(event.sender.send.mock.calls[1][2]).toBe(2) + }) + + it('resyncs past stray bytes before a frame', () => { + const { proc, event } = connect() + proc.stdout.emit('data', Buffer.concat([Buffer.from('some library log line here'), frame(1, 1)])) + expect(event.sender.send).toHaveBeenCalledTimes(1) + expect(console.error).toHaveBeenCalledWith(expect.stringContaining('resync')) + }) + + it('drops junk with no magic while keeping a possible split-magic tail', () => { + const { proc, event } = connect() + proc.stdout.emit('data', Buffer.from('x'.repeat(20))) // >= header size, no NXF1 anywhere + expect(event.sender.send).not.toHaveBeenCalled() + // Next frame still parses even though 3 junk bytes were retained. + proc.stdout.emit('data', frame(1, 1)) + expect(event.sender.send).toHaveBeenCalledTimes(1) + }) + + it('rejects an implausible header (zero dims) and resyncs to the next frame', () => { + const { proc, event } = connect() + const bogus = Buffer.alloc(16) + bogus.write('NXF1', 0, 'ascii') // width/height/dataLen all zero + proc.stdout.emit('data', Buffer.concat([bogus, frame(1, 1)])) + expect(event.sender.send).toHaveBeenCalledTimes(1) + }) + + it('rejects a header whose dataLen does not match width*height*4', () => { + const { proc, event } = connect() + const bad = Buffer.alloc(16) + bad.write('NXF1', 0, 'ascii') + bad.writeUInt32LE(2, 4) + bad.writeUInt32LE(2, 8) + bad.writeUInt32LE(15, 12) // should be 16 + proc.stdout.emit('data', Buffer.concat([bad, frame(1, 1)])) + expect(event.sender.send).toHaveBeenCalledTimes(1) + expect(event.sender.send.mock.calls[0][2]).toBe(1) + }) + + it('does not send frames to a destroyed sender', () => { + const { proc, event } = connect(VALID_CONFIG, makeEvent(true)) + proc.stdout.emit('data', frame(1, 1)) + expect(event.sender.send).not.toHaveBeenCalled() + }) +}) + +describe('stderr [sidecar] parsing and exit reasons', () => { + it('prefers the last "error:" detail over informational lines', () => { + const { proc, id, event } = connect() + proc.stderr.emit('data', Buffer.from('[sidecar] connecting to host:3389\n')) + proc.stderr.emit('data', Buffer.from('freerdp noise\n[sidecar] error: Logon failed\n')) + proc.stderr.emit('data', Buffer.from('[sidecar] disconnecting\n')) + proc.emit('exit', 1) + expect(event.sender.send).toHaveBeenCalledWith('rdp:closed', id, 'Logon failed') + }) + + it.each([ + { + name: 'falls back to the last informational sidecar message on failure', + stderr: '[sidecar] certificate accepted\n', + exitCode: 3, + reason: 'certificate accepted', + }, + { + name: 'keeps "error:" with no detail as a plain message, not an error detail', + stderr: '[sidecar] error:\n', + exitCode: 1, + reason: 'error:', + }, + { + name: 'ignores empty sidecar lines and reports a generic exit reason', + stderr: '[sidecar] \nplain freerdp output\n', + exitCode: 2, + reason: 'sidecar exited (2)', + }, + ])('$name', ({ stderr, exitCode, reason }) => { + const { proc, id, event } = connect() + proc.stderr.emit('data', Buffer.from(stderr)) + proc.emit('exit', exitCode) + expect(event.sender.send).toHaveBeenCalledWith('rdp:closed', id, reason) + }) + + it('sends a null reason on clean exit', () => { + const { proc, id, event } = connect() + proc.stderr.emit('data', Buffer.from('[sidecar] error: transient\n')) + proc.emit('exit', 0) + expect(event.sender.send).toHaveBeenCalledWith('rdp:closed', id, null) + }) + + it('does not send rdp:closed to a destroyed sender', () => { + const { proc, event } = connect(VALID_CONFIG, makeEvent(true)) + proc.emit('exit', 1) + expect(event.sender.send).not.toHaveBeenCalled() + }) + + it('reports spawn errors and removes the session', () => { + const { proc, id, event } = connect() + proc.emit('error', new Error('spawn ENOENT')) + expect(event.sender.send).toHaveBeenCalledWith('rdp:closed', id, 'spawn ENOENT') + expect(() => handler('rdp:disconnect')(event, id)).toThrow(NotFoundError) + }) +}) + +describe('rdp:disconnect and session ownership', () => { + it('validates the session id shape', () => { + expect(() => handler('rdp:disconnect')(makeEvent(), 42)).toThrow(ValidationError) + }) + + it('throws NotFoundError for unknown sessions', () => { + expect(() => handler('rdp:disconnect')(makeEvent(), 'nope')).toThrow(NotFoundError) + }) + + it('rejects a disconnect from a different sender', () => { + const { id } = connect() + expect(() => handler('rdp:disconnect')(makeEvent(), id)).toThrow(OwnershipError) + }) + + it('kills the sidecar on disconnect and forgets the session', () => { + const { proc, id, event } = connect() + handler('rdp:disconnect')(event, id) + expect(proc.kill).toHaveBeenCalled() + expect(() => handler('rdp:disconnect')(event, id)).toThrow(NotFoundError) + }) + + it('swallows kill failures during dispose', () => { + const { proc, id, event } = connect() + proc.kill.mockImplementation(() => { throw new Error('already dead') }) + expect(() => handler('rdp:disconnect')(event, id)).not.toThrow() + }) + + it('disposeRdpSessionsForSender only kills that sender\'s sessions', () => { + const mine = makeEvent() + const a = connect(VALID_CONFIG, mine) + const b = connect(VALID_CONFIG, mine) + const other = connect() + disposeRdpSessionsForSender(mine.sender.id) + expect(a.proc.kill).toHaveBeenCalled() + expect(b.proc.kill).toHaveBeenCalled() + expect(other.proc.kill).not.toHaveBeenCalled() + handler('rdp:disconnect')(other.event, other.id) // still alive for its own sender + }) +}) diff --git a/src/main/ipc/__tests__/security.test.ts b/src/main/ipc/__tests__/security.test.ts index 08722a6..f94dc0d 100644 --- a/src/main/ipc/__tests__/security.test.ts +++ b/src/main/ipc/__tests__/security.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { homedir } from 'os' -import { resolve, normalize } from 'path' +import { homedir } from 'node:os' +import { resolve, normalize } from 'node:path' // We test isLikelyTextFile directly (pure function, no FS dependency) import { isLikelyTextFile } from '../security' @@ -39,70 +39,33 @@ function validateKeyPathLogic(rawPath: string): { ok: true; resolved: string } | describe('Security — path traversal protection', () => { describe('validateKeyPathLogic', () => { - it('allows ~/.ssh/id_rsa', () => { + it('resolves ~ to the home directory', () => { const result = validateKeyPathLogic('~/.ssh/id_rsa') expect(result.ok).toBe(true) if (result.ok) expect(result.resolved).toBe(resolve(SSH_DIR, 'id_rsa')) }) - it('allows ~/.ssh/id_ed25519', () => { - const result = validateKeyPathLogic('~/.ssh/id_ed25519') - expect(result.ok).toBe(true) - }) - - it('allows absolute path inside .ssh', () => { - const result = validateKeyPathLogic(`${home}/.ssh/my_key`) - expect(result.ok).toBe(true) - }) - - it('allows ~/.config/ssh/ paths', () => { - const result = validateKeyPathLogic('~/.config/ssh/deploy_key') - expect(result.ok).toBe(true) - }) - - it('allows ~/.pem/ paths', () => { - const result = validateKeyPathLogic('~/.pem/server.pem') - expect(result.ok).toBe(true) - }) - - it('BLOCKS /etc/passwd', () => { - const result = validateKeyPathLogic('/etc/passwd') - expect(result.ok).toBe(false) - }) - - it('BLOCKS /etc/shadow', () => { - const result = validateKeyPathLogic('/etc/shadow') - expect(result.ok).toBe(false) - }) - - it('BLOCKS reading arbitrary home directory files', () => { - const result = validateKeyPathLogic('~/.env') - expect(result.ok).toBe(false) - }) - - it('BLOCKS path traversal via ../', () => { - const result = validateKeyPathLogic('~/.ssh/../../etc/passwd') - expect(result.ok).toBe(false) - }) - - it('BLOCKS path traversal via encoded sequences', () => { - const result = validateKeyPathLogic('~/.ssh/../.env') - expect(result.ok).toBe(false) - }) - - it('BLOCKS absolute paths outside allowed dirs', () => { - const result = validateKeyPathLogic('/tmp/evil_key') - expect(result.ok).toBe(false) - }) - - it('BLOCKS empty string', () => { - const result = validateKeyPathLogic('') - expect(result.ok).toBe(false) - }) - - it('BLOCKS null-byte injection', () => { - const result = validateKeyPathLogic('~/.ssh/id_rsa\0/../../etc/passwd') - expect(result.ok).toBe(false) + it.each([ + ['~/.ssh/id_rsa', 'ssh key by tilde path'], + ['~/.ssh/id_ed25519', 'ed25519 key'], + [`${home}/.ssh/my_key`, 'absolute path inside .ssh'], + ['~/.config/ssh/deploy_key', '~/.config/ssh/ path'], + ['~/.pem/server.pem', '~/.pem/ path'], + ])('allows %s (%s)', (path) => { + expect(validateKeyPathLogic(path).ok).toBe(true) + }) + + it.each([ + ['/etc/passwd', 'system file'], + ['/etc/shadow', 'system file'], + ['~/.env', 'arbitrary home directory file'], + ['~/.ssh/../../etc/passwd', 'path traversal via ../'], + ['~/.ssh/../.env', 'path traversal to dotfile'], + ['/tmp/evil_key', 'absolute path outside allowed dirs'], + ['', 'empty string'], + ['~/.ssh/id_rsa\0/../../etc/passwd', 'null-byte injection'], + ])('BLOCKS %s (%s)', (path) => { + expect(validateKeyPathLogic(path).ok).toBe(false) }) it('BLOCKS reading the .ssh directory itself (not a file)', () => { diff --git a/src/main/ipc/__tests__/sessionTransfer.test.ts b/src/main/ipc/__tests__/sessionTransfer.test.ts index 90c4d68..e366f1f 100644 --- a/src/main/ipc/__tests__/sessionTransfer.test.ts +++ b/src/main/ipc/__tests__/sessionTransfer.test.ts @@ -65,6 +65,12 @@ describe('parseSessionsExport', () => { expect(() => parseSessionsExport('[]')).toThrow(ValidationError) }) + it('rejects JSON documents whose root is not an object', () => { + expect(() => parseSessionsExport('42')).toThrow(ValidationError) + expect(() => parseSessionsExport('null')).toThrow(ValidationError) + expect(() => parseSessionsExport('"noxed-connections"')).toThrow(ValidationError) + }) + it('drops entries without a host', () => { const result = parseSessionsExport(wrap([{ label: 'broken', port: 22 }])) expect(result).toEqual([]) @@ -93,6 +99,96 @@ describe('parseSessionsExport', () => { const result = parseSessionsExport(wrap([{ host: 'a.example.com', tags: ['ok', 42, null] }])) expect(result[0].tags).toEqual(['ok']) }) + + it('imports every optional string field', () => { + const result = parseSessionsExport(wrap([{ + host: 'db.example.com', + type: 'database', + keyPath: '~/.ssh/id_rsa', + group: 'Prod', + color: '#ff0000', + dbType: 'postgres', + databaseName: 'app', + sslMode: 'require', + kubeconfigPath: '~/.kube/config', + }])) + expect(result[0]).toMatchObject({ + type: 'database', + keyPath: '~/.ssh/id_rsa', + group: 'Prod', + color: '#ff0000', + dbType: 'postgres', + databaseName: 'app', + sslMode: 'require', + kubeconfigPath: '~/.kube/config', + }) + }) + + it('treats blank or non-string optional fields as absent', () => { + const result = parseSessionsExport(wrap([{ + host: 'a.example.com', + group: ' ', + color: 42, + dbType: null, + keyPath: '', + }])) + expect(result[0].group).toBeUndefined() + expect(result[0].color).toBeUndefined() + expect(result[0].dbType).toBeUndefined() + expect(result[0].keyPath).toBeUndefined() + }) + + it('imports boolean and numeric preferences with strict types', () => { + const result = parseSessionsExport(wrap([{ + host: 'a.example.com', + isFavorite: true, + pollingEnabled: false, + connectOnStart: true, + pollingIntervalSeconds: 30, + redisDb: 3, + }])) + expect(result[0]).toMatchObject({ + isFavorite: true, + pollingEnabled: false, + connectOnStart: true, + pollingIntervalSeconds: 30, + redisDb: 3, + }) + }) + + it('rejects malformed boolean and numeric preferences', () => { + const result = parseSessionsExport(wrap([{ + host: 'a.example.com', + isFavorite: 'yes', + pollingEnabled: 1, + connectOnStart: null, + pollingIntervalSeconds: Number.NaN, + redisDb: 2.5, + }])) + expect(result[0].isFavorite).toBeUndefined() + expect(result[0].pollingEnabled).toBeUndefined() + expect(result[0].connectOnStart).toBeUndefined() + expect(result[0].pollingIntervalSeconds).toBeUndefined() + expect(result[0].redisDb).toBeUndefined() + }) + + it('drops non-object connection entries and unknown types fall back to ssh', () => { + const result = parseSessionsExport(wrap([ + null, + 'garbage', + 42, + { host: 'a.example.com', type: 'carrier-pigeon' }, + { host: 'b.example.com', type: 'redis' }, + ])) + expect(result).toHaveLength(2) + expect(result[0].type).toBe('ssh') + expect(result[1].type).toBe('redis') + }) + + it('rejects oversized import payloads', () => { + const huge = `{"pad":"${'x'.repeat(5 * 1024 * 1024)}"}` + expect(() => parseSessionsExport(huge)).toThrow(ValidationError) + }) }) describe('connectionDedupKey', () => { diff --git a/src/main/ipc/__tests__/sftp.test.ts b/src/main/ipc/__tests__/sftp.test.ts new file mode 100644 index 0000000..737ee43 --- /dev/null +++ b/src/main/ipc/__tests__/sftp.test.ts @@ -0,0 +1,347 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { EventEmitter } from 'node:events' +import { randomUUID } from 'node:crypto' + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), on: vi.fn() }, +})) +vi.mock('ssh2', async () => { + const { EventEmitter } = await import('node:events') + const instances: unknown[] = [] + class Client extends EventEmitter { + connect = vi.fn() + end = vi.fn() + sftp = vi.fn() + constructor() { + super() + instances.push(this) + } + } + return { Client, __clientInstances: instances } +}) +vi.mock('../ssh', () => ({ + getOwnedSshClient: vi.fn(), + sshConnectOptions: () => ({ readyTimeout: 1000, keepaliveInterval: 0, keepaliveCountMax: 1 }), + SSH_CONNECT_DEFAULTS: { algorithms: { kex: [] } }, +})) +vi.mock('../sshClients', () => ({ + connectSessionClient: vi.fn(), + openJumpSocket: vi.fn(), +})) + +import { ipcMain } from 'electron' +import * as ssh2 from 'ssh2' +import { getOwnedSshClient } from '../ssh' +import { connectSessionClient, openJumpSocket } from '../sshClients' +import { registerSftpHandlers, disposeSftpClientsForSender } from '../sftp' +import { ValidationError, NotFoundError, OwnershipError, ConnectionError } from '../errors' + +registerSftpHandlers() + +// 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 handler registered for ${channel}`) + return call[1] as Handler +} + +interface FakeClientInstance extends EventEmitter { + connect: Mock + end: Mock + sftp: Mock +} + +function lastClientInstance(): FakeClientInstance { + const instances = (ssh2 as unknown as { __clientInstances: FakeClientInstance[] }).__clientInstances + return instances[instances.length - 1] +} + +interface FakeEvent { + sender: { id: number; isDestroyed: () => boolean; send: Mock } +} + +let senderSeq = 100 +function makeEvent(): FakeEvent { + return { sender: { id: senderSeq++, isDestroyed: () => false, send: vi.fn() } } +} + +function fakeSftp() { + return { + readdir: vi.fn(), + stat: vi.fn(), + createReadStream: vi.fn(), + createWriteStream: vi.fn(), + fastGet: vi.fn(), + fastPut: vi.fn(), + unlink: vi.fn(), + rename: vi.fn(), + mkdir: vi.fn(), + rmdir: vi.fn(), + chmod: vi.fn(), + } +} + +const BASE_CONFIG = { host: 'sftp.example.com', port: 22, username: 'deploy' } + +/** Registers a client through the streamId fast-path (reuses an "owned" SSH client). */ +async function connectViaStream(event: FakeEvent = makeEvent()) { + const sftp = fakeSftp() + const client = { sftp: vi.fn((cb: (e: Error | null, s: unknown) => void) => cb(null, sftp)), end: vi.fn() } + ;(getOwnedSshClient as Mock).mockReturnValueOnce(client) + const clientId = (await handler('sftp:connect')(event, { ...BASE_CONFIG, streamId: randomUUID() })) as string + return { clientId, sftp, client, event } +} + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('sftp:connect config validation', () => { + const connectHandler = () => handler('sftp:connect') + + it('rejects non-object and malformed configs', () => { + const event = makeEvent() + expect(() => connectHandler()(event, null)).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, streamId: 'not-a-uuid' })).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, host: 'bad host!' })).toThrow() + expect(() => connectHandler()(event, { ...BASE_CONFIG, port: 0 })).toThrow() + expect(() => connectHandler()(event, { ...BASE_CONFIG, username: '' })).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, password: 5 })).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, password: 'x'.repeat(2000) })).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, privateKey: 5 })).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, privateKey: 'k'.repeat(65 * 1024) })).toThrow(ValidationError) + expect(() => connectHandler()(event, { ...BASE_CONFIG, jumpHostId: 'j'.repeat(200) })).toThrow(ValidationError) + }) + + it('throws NotFoundError when the referenced SSH stream does not exist', async () => { + ;(getOwnedSshClient as Mock).mockReturnValueOnce(undefined) + await expect(async () => connectHandler()(makeEvent(), { ...BASE_CONFIG, streamId: randomUUID() })) + .rejects.toBeInstanceOf(NotFoundError) + }) + + it('opens an sftp channel over an existing owned SSH stream', async () => { + const { clientId } = await connectViaStream() + expect(typeof clientId).toBe('string') + }) +}) + +describe('sftp:connect standalone client', () => { + it('resolves once the client is ready and the sftp channel opens', async () => { + const event = makeEvent() + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG, password: 'pw' }) as Promise + const client = lastClientInstance() + expect(client.connect).toHaveBeenCalledWith(expect.objectContaining({ + host: 'sftp.example.com', + port: 22, + username: 'deploy', + password: 'pw', + tryKeyboard: true, + })) + client.sftp.mockImplementation((cb: (e: Error | null, s: unknown) => void) => cb(null, fakeSftp())) + client.emit('ready') + await expect(pending).resolves.toEqual(expect.any(String)) + }) + + it('rejects with ConnectionError on a client error and ignores later events', async () => { + const event = makeEvent() + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG, password: 'pw' }) as Promise + const client = lastClientInstance() + client.sftp.mockImplementation((cb: (e: Error | null, s: unknown) => void) => cb(null, fakeSftp())) + client.emit('error', new Error('connection refused')) + client.emit('ready') // settle() must ignore this second outcome + await expect(pending).rejects.toBeInstanceOf(ConnectionError) + }) + + it('closes the client when the sftp channel fails after ready', async () => { + const event = makeEvent() + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG, password: 'pw' }) as Promise + const client = lastClientInstance() + client.sftp.mockImplementation((cb: (e: Error | null) => void) => cb(new Error('no sftp subsystem'))) + client.emit('ready') + await expect(pending).rejects.toBeInstanceOf(ConnectionError) + expect(client.end).toHaveBeenCalled() + }) + + it('answers keyboard-interactive prompts with the password', async () => { + const event = makeEvent() + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG, password: 'pw' }) as Promise + const client = lastClientInstance() + const finish = vi.fn() + client.emit('keyboard-interactive', 'n', 'i', 'l', [{ prompt: 'Password:' }, { prompt: 'Verification:' }], finish) + expect(finish).toHaveBeenCalledWith(['pw', 'pw']) + client.sftp.mockImplementation((cb: (e: Error | null, s: unknown) => void) => cb(null, fakeSftp())) + client.emit('ready') + await pending + }) + + it('answers keyboard-interactive with no responses when there is no password', async () => { + const event = makeEvent() + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG }) as Promise + const client = lastClientInstance() + const finish = vi.fn() + client.emit('keyboard-interactive', 'n', 'i', 'l', [{ prompt: 'Password:' }], finish) + expect(finish).toHaveBeenCalledWith([]) + client.emit('error', new Error('auth failed')) + await expect(pending).rejects.toBeInstanceOf(ConnectionError) + }) + + it('routes through a jump host and passes the socket to connect', async () => { + const event = makeEvent() + const upstream = { client: { fake: true }, dispose: vi.fn() } + ;(connectSessionClient as Mock).mockResolvedValueOnce(upstream) + ;(openJumpSocket as Mock).mockResolvedValueOnce('jump-sock') + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG, password: 'pw', jumpHostId: 'jump-1' }) as Promise + await vi.waitFor(() => expect(openJumpSocket).toHaveBeenCalledWith(upstream.client, 'sftp.example.com', 22)) + const client = lastClientInstance() + expect(client.connect).toHaveBeenCalledWith(expect.objectContaining({ sock: 'jump-sock' })) + client.sftp.mockImplementation((cb: (e: Error | null, s: unknown) => void) => cb(null, fakeSftp())) + client.emit('ready') + await pending + expect(upstream.dispose).not.toHaveBeenCalled() + }) + + it('disposes the jump connection when the jump socket cannot open', async () => { + const upstream = { client: {}, dispose: vi.fn() } + ;(connectSessionClient as Mock).mockResolvedValueOnce(upstream) + ;(openJumpSocket as Mock).mockRejectedValueOnce(new Error('jump refused')) + await expect(handler('sftp:connect')(makeEvent(), { ...BASE_CONFIG, jumpHostId: 'jump-1' })) + .rejects.toThrow('jump refused') + expect(upstream.dispose).toHaveBeenCalled() + }) +}) + +describe('sftp:list', () => { + it('maps directory entries with type and timestamps', async () => { + const { clientId, sftp, event } = await connectViaStream() + sftp.readdir.mockImplementation((_path: string, cb: (e: Error | null, list?: unknown[]) => void) => + cb(null, [ + { filename: 'notes.txt', attrs: { size: 5, mtime: 1000, mode: 0o100644 } }, + { filename: 'logs', attrs: { size: 0, mtime: 2000, mode: 0o040755 } }, + ])) + const rows = await handler('sftp:list')(event, clientId, '/home/deploy') + expect(rows).toEqual([ + { name: 'notes.txt', size: 5, mtime: 1_000_000, permissions: 0o100644, isDirectory: false }, + { name: 'logs', size: 0, mtime: 2_000_000, permissions: 0o040755, isDirectory: true }, + ]) + }) + + it('wraps readdir failures in ConnectionError', async () => { + const { clientId, sftp, event } = await connectViaStream() + sftp.readdir.mockImplementation((_path: string, cb: (e: Error | null) => void) => cb(new Error('Permission denied'))) + await expect(handler('sftp:list')(event, clientId, '/root')).rejects.toBeInstanceOf(ConnectionError) + }) + + it('validates path and client id, and enforces ownership', async () => { + const { clientId, event } = await connectViaStream() + expect(() => handler('sftp:list')(event, clientId, '')).toThrow(ValidationError) + expect(() => handler('sftp:list')(event, clientId, 'a\0b')).toThrow(ValidationError) + expect(() => handler('sftp:list')(event, 'not-a-uuid', '/tmp')).toThrow(ValidationError) + expect(() => handler('sftp:list')(event, randomUUID(), '/tmp')).toThrow(NotFoundError) + expect(() => handler('sftp:list')(makeEvent(), clientId, '/tmp')).toThrow(OwnershipError) + }) +}) + +describe('sftp:readFile', () => { + function statOk(sftp: ReturnType, size = 10): void { + sftp.stat.mockImplementation((_p: string, cb: (e: Error | null, s?: unknown) => void) => cb(null, { size })) + } + + it('reads a small text file', async () => { + const { clientId, sftp, event } = await connectViaStream() + statOk(sftp) + const stream = new EventEmitter() + sftp.createReadStream.mockReturnValue(stream) + const pending = handler('sftp:readFile')(event, clientId, '/etc/motd') as Promise + stream.emit('data', Buffer.from('hello ')) + stream.emit('data', Buffer.from('world')) + stream.emit('end') + await expect(pending).resolves.toBe('hello world') + }) + + it('rejects when stat fails', async () => { + const { clientId, sftp, event } = await connectViaStream() + sftp.stat.mockImplementation((_p: string, cb: (e: Error | null) => void) => cb(new Error('No such file'))) + await expect(handler('sftp:readFile')(event, clientId, '/missing')).rejects.toBeInstanceOf(ConnectionError) + }) + + it('refuses binary files by extension before streaming', async () => { + const { clientId, sftp, event } = await connectViaStream() + statOk(sftp) + await expect(handler('sftp:readFile')(event, clientId, '/pics/photo.png')) + .rejects.toBeInstanceOf(ValidationError) + expect(sftp.createReadStream).not.toHaveBeenCalled() + }) + + it('refuses files that sniff as binary (null byte in first 8KB)', async () => { + const { clientId, sftp, event } = await connectViaStream() + statOk(sftp) + const stream = new EventEmitter() + sftp.createReadStream.mockReturnValue(stream) + const pending = handler('sftp:readFile')(event, clientId, '/bin/data') + stream.emit('data', Buffer.from([0x41, 0x00, 0x42])) + stream.emit('end') + await expect(pending).rejects.toBeInstanceOf(ValidationError) + }) + + it('wraps read stream errors in ConnectionError', async () => { + const { clientId, sftp, event } = await connectViaStream() + statOk(sftp) + const stream = new EventEmitter() + sftp.createReadStream.mockReturnValue(stream) + const pending = handler('sftp:readFile')(event, clientId, '/etc/motd') + stream.emit('error', new Error('read reset')) + await expect(pending).rejects.toBeInstanceOf(ConnectionError) + }) +}) + +describe('sftp:writeFile', () => { + it('writes content and resolves on finish', async () => { + const { clientId, sftp, event } = await connectViaStream() + const stream = new EventEmitter() as EventEmitter & { end: Mock } + stream.end = vi.fn(() => stream.emit('finish')) + sftp.createWriteStream.mockReturnValue(stream) + await expect(handler('sftp:writeFile')(event, clientId, '/tmp/out.txt', 'data')).resolves.toBe(true) + expect(stream.end).toHaveBeenCalledWith(Buffer.from('data', 'utf8')) + }) + + it('rejects invalid content and stream errors', async () => { + const { clientId, sftp, event } = await connectViaStream() + expect(() => handler('sftp:writeFile')(event, clientId, '/tmp/out.txt', 42)).toThrow(ValidationError) + const stream = new EventEmitter() as EventEmitter & { end: Mock } + stream.end = vi.fn(() => stream.emit('error', new Error('disk full'))) + sftp.createWriteStream.mockReturnValue(stream) + await expect(handler('sftp:writeFile')(event, clientId, '/tmp/out.txt', 'data')).rejects.toBeInstanceOf(ConnectionError) + }) +}) + +describe('sftp:disconnect and sender cleanup', () => { + it('validates ownership on disconnect', async () => { + const { clientId } = await connectViaStream() + expect(() => handler('sftp:disconnect')(makeEvent(), clientId)).toThrow(OwnershipError) + }) + + it('ignores disconnects for unknown clients', () => { + expect(handler('sftp:disconnect')(makeEvent(), randomUUID())).toBeUndefined() + }) + + it('does not end a shared (stream-backed) client on disconnect', async () => { + const { clientId, client, event } = await connectViaStream() + handler('sftp:disconnect')(event, clientId) + expect(client.end).not.toHaveBeenCalled() + expect(() => handler('sftp:list')(event, clientId, '/tmp')).toThrow(NotFoundError) + }) + + it('ends owned clients when their sender goes away', async () => { + const event = makeEvent() + const pending = handler('sftp:connect')(event, { ...BASE_CONFIG, password: 'pw' }) as Promise + const client = lastClientInstance() + client.sftp.mockImplementation((cb: (e: Error | null, s: unknown) => void) => cb(null, fakeSftp())) + client.emit('ready') + const clientId = await pending + disposeSftpClientsForSender(event.sender.id) + expect(client.end).toHaveBeenCalled() + expect(() => handler('sftp:list')(event, clientId, '/tmp')).toThrow(NotFoundError) + }) +}) diff --git a/src/main/ipc/__tests__/socks.test.ts b/src/main/ipc/__tests__/socks.test.ts index 3baab33..08dd336 100644 --- a/src/main/ipc/__tests__/socks.test.ts +++ b/src/main/ipc/__tests__/socks.test.ts @@ -29,7 +29,7 @@ describe('socks replies', () => { it('builds a ten-byte connect reply with the given code', () => { const reply = socksConnectReply(SOCKS_REPLY.success) - expect(reply.length).toBe(10) + expect(reply).toHaveLength(10) expect(reply[0]).toBe(5) expect(reply[1]).toBe(0) }) diff --git a/src/main/ipc/__tests__/ssh.handlers.test.ts b/src/main/ipc/__tests__/ssh.handlers.test.ts new file mode 100644 index 0000000..1dc2aa0 --- /dev/null +++ b/src/main/ipc/__tests__/ssh.handlers.test.ts @@ -0,0 +1,579 @@ +import { describe, it, expect, vi, afterEach } from 'vitest' +import { EventEmitter } from 'node:events' + +const { ipc, fakeSsh } = vi.hoisted(() => ({ + ipc: { + handlers: new Map unknown>(), + listeners: new Map unknown>(), + }, + fakeSsh: { clients: [] as FakeSshClient[] }, +})) + +interface FakeShellStream extends EventEmitter { + stderr: EventEmitter + write: ReturnType + end: ReturnType + setWindow: ReturnType +} + +interface FakeSshClient extends EventEmitter { + connectConfig: unknown + shellImpl: (opts: unknown, cb: (err: Error | null, stream?: unknown) => void) => void + execCalls: Array<{ command: string; cb: (err: Error | null, channel?: unknown) => void }> + lastShellStream: FakeShellStream | undefined + end: ReturnType + setNoDelay: ReturnType + connect: (config: unknown) => void + shell: (opts: unknown, cb: (err: Error | null, stream?: unknown) => void) => void + exec: (command: string, cb: (err: Error | null, channel?: unknown) => 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('electron-store', () => ({ + default: class MockStore { + private data = new Map() + get(key: string) { return this.data.get(key) } + set(key: string, value: unknown) { this.data.set(key, value) } + }, +})) + +vi.mock('ssh2', async () => { + const { EventEmitter } = await import('node:events') + + function makeShellStream(): FakeShellStream { + const stream = new EventEmitter() as FakeShellStream + stream.stderr = new EventEmitter() + stream.write = vi.fn() + stream.end = vi.fn() + stream.setWindow = vi.fn() + return stream + } + + class FakeClient extends EventEmitter { + connectConfig: unknown + shellImpl: (opts: unknown, cb: (err: Error | null, stream?: unknown) => void) => void + execCalls: Array<{ command: string; cb: (err: Error | null, channel?: unknown) => void }> = [] + lastShellStream: FakeShellStream | undefined + end = vi.fn() + setNoDelay = vi.fn() + + constructor() { + super() + this.shellImpl = (_opts, cb) => { + this.lastShellStream = makeShellStream() + cb(null, this.lastShellStream) + } + fakeSsh.clients.push(this as unknown as FakeSshClient) + } + + connect(config: unknown): void { + this.connectConfig = config + } + + shell(opts: unknown, cb: (err: Error | null, stream?: unknown) => void): void { + this.shellImpl(opts, cb) + } + + exec(command: string, cb: (err: Error | null, channel?: unknown) => void): void { + this.execCalls.push({ command, cb }) + } + } + + return { Client: FakeClient } +}) + +vi.mock('../sshClients', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + connectSessionClient: vi.fn(), + openJumpSocket: vi.fn(), + } +}) + +import { registerSshHandlers, getOwnedSshClient, disposeSshStreamsForSender } from '../ssh' +import { connectSessionClient, openJumpSocket } from '../sshClients' +import { METRICS_COMMAND } from '../metrics' +import { ValidationError, OwnershipError, ConnectionError } from '../errors' + +registerSshHandlers() + +const LINUX_METRICS_OUTPUT = [ + 'cpu 100 0 100 700 100 0 0 0 0 0', + '::MEM::MemTotal: 4096000 kB', + 'MemAvailable: 1024000 kB', + '::DISK::/dev/vda1 102400000 51200000 51200000 50% /', + '::LOAD::0.42 0.36 0.30 1/123 4567', + '::UP::123456.78 654321.00', +].join('\n') + +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: 'example.com', port: 22, username: 'deploy', password: 'pw' } + +function invokeConnect(event: FakeEvent, config: unknown = VALID_CONFIG): Promise { + const handler = ipc.handlers.get('ssh:connect') + if (!handler) throw new Error('ssh:connect handler not registered') + return handler(event, config) as Promise +} + +async function connect(event: FakeEvent = makeEvent()) { + const pending = invokeConnect(event) + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + client.emit('ready') + const streamId = await pending + const stream = client.lastShellStream + if (!stream) throw new Error('no shell stream opened') + return { streamId, client, stream, event } +} + +function makeExecChannel(): FakeShellStream { + const chan = new EventEmitter() as FakeShellStream + chan.stderr = new EventEmitter() + chan.write = vi.fn() + chan.end = vi.fn() + chan.setWindow = vi.fn() + return chan +} + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('ssh:connect', () => { + it('resolves with a stream id once the shell is open', async () => { + const { streamId, client } = await connect() + expect(streamId).toMatch(/^[0-9a-f-]{36}$/i) + expect(client.setNoDelay).toHaveBeenCalledWith(true) + expect(client.connectConfig).toMatchObject({ + host: 'example.com', + port: 22, + username: 'deploy', + password: 'pw', + tryKeyboard: true, + }) + }) + + it('forwards stdout and stderr shell data to the renderer', async () => { + const { streamId, stream, event } = await connect() + stream.emit('data', Buffer.from('hello')) + stream.stderr.emit('data', Buffer.from('oops')) + expect(event.sender.send).toHaveBeenCalledWith('ssh:data', streamId, 'hello') + expect(event.sender.send).toHaveBeenCalledWith('ssh:data', streamId, 'oops') + }) + + it('does not send data to a destroyed renderer', async () => { + const { stream, event } = await connect() + event.sender.isDestroyed.mockReturnValue(true) + stream.emit('data', Buffer.from('late')) + expect(event.sender.send).not.toHaveBeenCalled() + }) + + it('notifies the renderer and cleans up when the shell stream closes', async () => { + const { streamId, stream, event } = await connect() + stream.emit('close') + expect(event.sender.send).toHaveBeenCalledWith('ssh:closed', streamId) + + // The stream is gone: further writes are ignored + ipc.listeners.get('ssh:data')?.(event, streamId, 'ls\n') + expect(stream.write).not.toHaveBeenCalled() + }) + + it('rejects with a ConnectionError when opening the shell fails', async () => { + const event = makeEvent() + const pending = invokeConnect(event) + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + client.shellImpl = (_opts, cb) => cb(new Error('no shell for you')) + client.emit('ready') + await expect(pending).rejects.toThrow(ConnectionError) + await expect(pending).rejects.toThrow('no shell for you') + expect(client.end).toHaveBeenCalled() + }) + + it('rejects with a ConnectionError when the client errors before ready', async () => { + const event = makeEvent() + const pending = invokeConnect(event) + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + client.emit('error', new Error('ECONNREFUSED')) + await expect(pending).rejects.toThrow(ConnectionError) + }) + + it('settles only once when error follows ready', async () => { + const { streamId, client, event } = await connect() + client.emit('error', new Error('connection reset')) + expect(event.sender.send).toHaveBeenCalledWith('ssh:closed', streamId) + expect(client.end).toHaveBeenCalled() + + // Stream is unregistered after the late error + ipc.listeners.get('ssh:data')?.(event, streamId, 'x') + expect(client.lastShellStream?.write).not.toHaveBeenCalled() + }) + + it('answers keyboard-interactive prompts with the configured password', async () => { + const event = makeEvent() + const pending = invokeConnect(event) + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + const finish = vi.fn() + client.emit('keyboard-interactive', '', '', '', ['Password:', 'Again:'], 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 () => { + const event = makeEvent() + void invokeConnect(event, { host: 'example.com', port: 22, username: 'deploy' }) + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + const finish = vi.fn() + client.emit('keyboard-interactive', '', '', '', ['Password:'], finish) + expect(finish).toHaveBeenCalledWith([]) + }) + + it('rejects malformed configs', async () => { + const event = makeEvent() + await expect(invokeConnect(event, null)).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'example.com', port: 22, username: '' })).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'example.com', port: 22, username: 'u', password: 42 })).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'example.com', port: 22, username: 'u', privateKey: 42 })).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'example.com', port: 22, username: 'u', privateKey: 'k'.repeat(65 * 1024) })).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'example.com', port: 22, username: 'u', password: 'p'.repeat(2048) })).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'example.com', port: 22, username: 'u', jumpHostId: 42 })).rejects.toThrow(ValidationError) + await expect(invokeConnect(event, { host: 'bad host!', port: 22, username: 'u' })).rejects.toThrow() + await expect(invokeConnect(event, { host: 'example.com', port: 0, username: 'u' })).rejects.toThrow() + }) +}) + +describe('ssh:connect through a jump host', () => { + it('resolves the bastion in main and tunnels the leaf connection through it', async () => { + const upstream = { client: {} as never, dispose: vi.fn() } + const sock = { jump: 'socket' } + vi.mocked(connectSessionClient).mockResolvedValue(upstream) + vi.mocked(openJumpSocket).mockResolvedValue(sock as never) + + const event = makeEvent() + const pending = invokeConnect(event, { ...VALID_CONFIG, jumpHostId: 'bastion-session' }) + // Let the awaited bastion connection settle so the leaf client is created + await new Promise((r) => setImmediate(r)) + + expect(connectSessionClient).toHaveBeenCalledWith('bastion-session') + expect(openJumpSocket).toHaveBeenCalledWith(upstream.client, 'example.com', 22) + + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + client.emit('ready') + const streamId = await pending + expect((client.connectConfig as { sock?: unknown }).sock).toBe(sock) + + // Closing the leaf shell tears down the bastion chain too + client.lastShellStream?.emit('close') + expect(upstream.dispose).toHaveBeenCalled() + expect(event.sender.send).toHaveBeenCalledWith('ssh:closed', streamId) + }) + + it('disposes the bastion when the jump socket cannot be opened', async () => { + const upstream = { client: {} as never, dispose: vi.fn() } + vi.mocked(connectSessionClient).mockResolvedValue(upstream) + vi.mocked(openJumpSocket).mockRejectedValue(new ConnectionError('jump host unreachable')) + + const event = makeEvent() + await expect(invokeConnect(event, { ...VALID_CONFIG, jumpHostId: 'bastion-session' })) + .rejects.toThrow('jump host unreachable') + expect(upstream.dispose).toHaveBeenCalled() + }) + + it('disposes the bastion when the leaf connection fails', async () => { + const upstream = { client: {} as never, dispose: vi.fn() } + vi.mocked(connectSessionClient).mockResolvedValue(upstream) + vi.mocked(openJumpSocket).mockResolvedValue({} as never) + + const event = makeEvent() + const pending = invokeConnect(event, { ...VALID_CONFIG, jumpHostId: 'bastion-session' }) + await new Promise((r) => setImmediate(r)) + const client = fakeSsh.clients.at(-1) + if (!client) throw new Error('no ssh client created') + client.emit('error', new Error('auth failed')) + await expect(pending).rejects.toThrow(ConnectionError) + expect(upstream.dispose).toHaveBeenCalled() + }) +}) + +describe('ssh:data and ssh:resize', () => { + it('writes renderer input to the shell stream', async () => { + const { streamId, stream, event } = await connect() + ipc.listeners.get('ssh:data')?.(event, streamId, 'ls -la\n') + expect(stream.write).toHaveBeenCalledWith('ls -la\n') + }) + + it('silently drops invalid stream ids, oversized payloads, and foreign senders', async () => { + const { streamId, stream, event } = await connect() + const dataListener = ipc.listeners.get('ssh:data') + dataListener?.(event, 'not-a-uuid', 'x') + dataListener?.(event, streamId, 'y'.repeat(65 * 1024)) + dataListener?.(makeEvent(), streamId, 'stolen input') + expect(stream.write).not.toHaveBeenCalled() + }) + + it('resizes the pty via setWindow', async () => { + const { streamId, stream, event } = await connect() + ipc.listeners.get('ssh:resize')?.(event, streamId, 120, 40) + expect(stream.setWindow).toHaveBeenCalledWith(40, 120, 0, 0) + }) + + it('rethrows unexpected internal errors instead of swallowing them', async () => { + const { streamId } = await connect() + const broken = { sender: null } as never + expect(() => ipc.listeners.get('ssh:data')?.(broken, streamId, 'x')).toThrow(TypeError) + expect(() => ipc.listeners.get('ssh:resize')?.(broken, streamId, 80, 24)).toThrow(TypeError) + expect(() => ipc.listeners.get('ssh:metrics-stop')?.(broken, streamId)).toThrow(TypeError) + }) + + it('ignores invalid dimensions', async () => { + const { streamId, stream, event } = await connect() + const resize = ipc.listeners.get('ssh:resize') + resize?.(event, streamId, 0, 40) + resize?.(event, streamId, 120, 1001) + resize?.(event, streamId, 1.5, 40) + expect(stream.setWindow).not.toHaveBeenCalled() + }) +}) + +describe('ssh:disconnect and ownership', () => { + it('ends the stream and the client', async () => { + const { streamId, stream, client, event } = await connect() + ipc.handlers.get('ssh:disconnect')?.(event, streamId) + expect(stream.end).toHaveBeenCalled() + expect(client.end).toHaveBeenCalled() + }) + + it('throws OwnershipError when another window tries to disconnect', async () => { + const { streamId } = await connect() + expect(() => ipc.handlers.get('ssh:disconnect')?.(makeEvent(), streamId)).toThrow(OwnershipError) + }) + + it('getOwnedSshClient returns the client for the owner and validates ids', async () => { + const { streamId, client, event } = await connect() + expect(getOwnedSshClient(event as never, streamId)).toBe(client) + expect(getOwnedSshClient(event as never, '11111111-1111-4111-8111-111111111111')).toBeUndefined() + expect(() => getOwnedSshClient(event as never, 'nope')).toThrow(ValidationError) + expect(() => getOwnedSshClient(makeEvent() as never, streamId)).toThrow(OwnershipError) + }) + + it('disposeSshStreamsForSender disposes every stream owned by that sender', async () => { + const { stream, client, event } = await connect() + const other = await connect() + disposeSshStreamsForSender(event.sender.id) + expect(stream.end).toHaveBeenCalled() + expect(client.end).toHaveBeenCalled() + expect(other.stream.end).not.toHaveBeenCalled() + }) +}) + +describe('ssh metrics polling', () => { + it('fetches metrics immediately and emits parsed results to the renderer', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + expect(client.execCalls).toHaveLength(1) + expect(client.execCalls[0].command).toBe(METRICS_COMMAND) + + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('data', Buffer.from(LINUX_METRICS_OUTPUT)) + chan.stderr.emit('data', Buffer.from('cat: /proc/stat: No such file')) + chan.emit('close') + + expect(event.sender.send).toHaveBeenCalledWith( + 'ssh:metrics', + streamId, + expect.objectContaining({ available: true, memTotal: 4096000 * 1024, load1: 0.42 }) + ) + }) + + it('computes a CPU delta on the second sample', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + + const first = makeExecChannel() + client.execCalls[0].cb(null, first) + first.emit('data', Buffer.from(LINUX_METRICS_OUTPUT)) + first.emit('close') + + vi.advanceTimersByTime(2000) + expect(client.execCalls).toHaveLength(2) + const second = makeExecChannel() + client.execCalls[1].cb(null, second) + // 1000 more jiffies, 200 of them idle → 80% busy + second.emit('data', Buffer.from(LINUX_METRICS_OUTPUT.replace( + 'cpu 100 0 100 700 100 0 0 0 0 0', + 'cpu 500 0 500 850 150 0 0 0 0 0', + ))) + second.emit('close') + + const metricsCalls = event.sender.send.mock.calls.filter((c) => c[0] === 'ssh:metrics') + expect(metricsCalls).toHaveLength(2) + expect(metricsCalls[0][2].cpu).toBe(0) + expect(metricsCalls[1][2].cpu).toBe(80) + }) + + it('skips overlapping fetches while one is in flight, then resumes on the interval', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + expect(client.execCalls).toHaveLength(1) + + // First exec never completes: the 2s and 5s timers must not stack requests + vi.advanceTimersByTime(7000) + expect(client.execCalls).toHaveLength(1) + + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('data', Buffer.from(LINUX_METRICS_OUTPUT)) + chan.emit('close') + + vi.advanceTimersByTime(5000) + expect(client.execCalls).toHaveLength(2) + }) + + it('clears the in-flight flag when exec itself fails', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + client.execCalls[0].cb(new Error('exec failed')) + + vi.advanceTimersByTime(5000) + expect(client.execCalls).toHaveLength(2) + }) + + it('logs and recovers when the metrics channel errors', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('error', new Error('channel torn down')) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('metrics exec')) + + vi.advanceTimersByTime(5000) + expect(client.execCalls).toHaveLength(2) + }) + + it('logs instead of crashing when emitting metrics throws', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + + event.sender.send.mockImplementationOnce(() => { throw new Error('renderer gone') }) + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('data', Buffer.from(LINUX_METRICS_OUTPUT)) + chan.emit('close') + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('metrics parse')) + }) + + it('stops polling on ssh:metrics-stop', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('close') + + ipc.listeners.get('ssh:metrics-stop')?.(event, streamId) + vi.advanceTimersByTime(60_000) + expect(client.execCalls).toHaveLength(1) + }) + + it('ignores metrics-stop for invalid ids and foreign senders', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('close') + + const stop = ipc.listeners.get('ssh:metrics-stop') + stop?.(event, 'not-a-uuid') + stop?.(makeEvent(), streamId) + + // Polling continues because neither call was allowed to stop it + vi.advanceTimersByTime(2000) + expect(client.execCalls).toHaveLength(2) + }) + + it('stops polling when the stream is disconnected', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + const chan = makeExecChannel() + client.execCalls[0].cb(null, chan) + chan.emit('close') + + ipc.handlers.get('ssh:disconnect')?.(event, streamId) + vi.advanceTimersByTime(60_000) + expect(client.execCalls).toHaveLength(1) + }) + + it('does nothing when metrics-start targets an unknown stream', async () => { + const { event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, '11111111-1111-4111-8111-111111111111') + expect(() => ipc.handlers.get('ssh:metrics-start')?.(event, 'bogus')).toThrow(ValidationError) + }) + + it('restarting metrics replaces the previous timers', async () => { + vi.useFakeTimers() + const { streamId, client, event } = await connect() + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + const first = makeExecChannel() + client.execCalls[0].cb(null, first) + first.emit('close') + + // Restart clears the old timers and fetches again immediately + ipc.handlers.get('ssh:metrics-start')?.(event, streamId) + expect(client.execCalls).toHaveLength(2) + const second = makeExecChannel() + client.execCalls[1].cb(null, second) + second.emit('close') + + vi.advanceTimersByTime(2000) + expect(client.execCalls).toHaveLength(3) + }) +}) diff --git a/src/main/ipc/__tests__/sshConfig.test.ts b/src/main/ipc/__tests__/sshConfig.test.ts index 4c83315..7a5d202 100644 --- a/src/main/ipc/__tests__/sshConfig.test.ts +++ b/src/main/ipc/__tests__/sshConfig.test.ts @@ -1,5 +1,23 @@ -import { describe, it, expect } from 'vitest' -import { parseSshConfig } from '../sshConfig' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { ipc, readFileMock } = vi.hoisted(() => ({ + ipc: { handlers: new Map unknown>() }, + readFileMock: vi.fn(), +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn((channel: string, fn: (...args: unknown[]) => unknown) => { + ipc.handlers.set(channel, fn) + }), + on: vi.fn(), + }, +})) +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock, +})) + +import { parseSshConfig, registerSshConfigHandlers } from '../sshConfig' describe('parseSshConfig', () => { it('parses a basic host block', () => { @@ -101,4 +119,65 @@ Host direct expect(parseSshConfig('')).toEqual([]) expect(parseSshConfig('# nothing here\n')).toEqual([]) }) + + it('skips directives without a value', () => { + // A bare keyword ("Host" with no argument) is not a valid directive + const hosts = parseSshConfig('Host\nHost real\n HostName\n Port\n') + expect(hosts).toEqual([ + { alias: 'real', host: 'real', port: 22, username: undefined, keyPath: undefined }, + ]) + }) + + it('ignores unrelated directives inside a host block', () => { + const hosts = parseSshConfig('Host web\n ServerAliveInterval 60\n Compression yes\n') + expect(hosts).toEqual([ + { alias: 'web', host: 'web', port: 22, username: undefined, keyPath: undefined }, + ]) + }) + + it('drops quoted wildcard patterns and keeps quoted concrete aliases', () => { + const hosts = parseSshConfig('Host "web prod" "*"\n HostName 10.0.0.4\n') + // "web prod" splits on whitespace before unquoting, so each token is checked on its own + expect(hosts.every(h => h.host === '10.0.0.4')).toBe(true) + expect(hosts.some(h => h.alias.includes('*'))).toBe(false) + }) +}) + +describe('sshconfig:hosts handler', () => { + registerSshConfigHandlers() + const invoke = () => { + const handler = ipc.handlers.get('sshconfig:hosts') + if (!handler) throw new Error('sshconfig:hosts handler not registered') + return handler() as Promise + } + + beforeEach(() => { + readFileMock.mockReset() + }) + + it('reads ~/.ssh/config and returns parsed hosts', async () => { + readFileMock.mockResolvedValue('Host web\n HostName web.example.com\n Port 2200\n') + await expect(invoke()).resolves.toEqual([ + { alias: 'web', host: 'web.example.com', port: 2200, username: undefined, keyPath: undefined }, + ]) + expect(readFileMock).toHaveBeenCalledWith( + expect.stringMatching(/[/\\]\.ssh[/\\]config$/), + 'utf-8' + ) + }) + + it('returns an empty list when the config file does not exist', async () => { + readFileMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })) + await expect(invoke()).resolves.toEqual([]) + }) + + it('wraps other read errors with a descriptive message', async () => { + readFileMock.mockRejectedValue(Object.assign(new Error('permission denied'), { code: 'EACCES' })) + await expect(invoke()).rejects.toThrow('Cannot read ~/.ssh/config: permission denied') + }) + + it('stringifies non-Error rejection reasons', async () => { + readFileMock.mockRejectedValue('disk on fire') + await expect(invoke()).rejects.toThrow('Cannot read ~/.ssh/config: disk on fire') + }) }) diff --git a/src/main/ipc/__tests__/tunnels.test.ts b/src/main/ipc/__tests__/tunnels.test.ts new file mode 100644 index 0000000..60c61e7 --- /dev/null +++ b/src/main/ipc/__tests__/tunnels.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest' +import { EventEmitter } from 'node:events' + +const windows: Array<{ webContents: { isDestroyed: () => boolean; send: Mock } }> = [] + +vi.mock('electron', () => ({ + ipcMain: { handle: vi.fn(), on: vi.fn() }, + BrowserWindow: { getAllWindows: () => windows }, +})) +vi.mock('electron-store', () => ({ + default: class MockStore { + private data = new Map() + constructor(opts?: { defaults?: Record }) { + for (const [k, v] of Object.entries(opts?.defaults ?? {})) this.data.set(k, v) + } + get(key: string) { return this.data.get(key) } + set(key: string, value: unknown) { this.data.set(key, value) } + }, +})) +vi.mock('node:net', () => ({ + createServer: vi.fn(), + connect: vi.fn(), +})) +vi.mock('../sshClients', () => ({ + connectSessionClient: vi.fn(), +})) +vi.mock('../sessions', () => ({ + getSessionById: vi.fn(() => ({ id: 's1', label: 'server' })), +})) + +import { ipcMain } from 'electron' +import { createServer, connect as netConnect } from 'node:net' +import { connectSessionClient } from '../sshClients' +import { getSessionById } from '../sessions' +import { registerTunnelHandlers, disposeAllTunnels, listTunnels, TunnelDef } from '../tunnels' +import { ValidationError, NotFoundError } from '../errors' +import { SOCKS_REPLY, socksConnectReply, socksGreetingReply } from '../socks' + +registerTunnelHandlers() + +// 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 handler registered for ${channel}`) + return call[1] as Handler +} + +class FakeServer extends EventEmitter { + listen = vi.fn((_port: number, _host: string, cb: () => void) => cb()) + close = vi.fn() +} + +type ConnHandler = (socket: FakeSocket) => void + +interface NetState { + server: FakeServer + connHandler: ConnHandler | undefined +} + +function primeCreateServer(): NetState { + const state: NetState = { server: new FakeServer(), connHandler: undefined } + // Replace (not queue) the implementation so an unused priming can't leak + // into a later test. + ;(createServer as Mock).mockImplementation((h: ConnHandler) => { + state.connHandler = h + return state.server + }) + return state +} + +interface FakeSshClient extends EventEmitter { + forwardOut: Mock + forwardIn: Mock +} + +function fakeConn() { + const client = new EventEmitter() as FakeSshClient + client.forwardOut = vi.fn() + client.forwardIn = vi.fn() + return { client, dispose: vi.fn() } +} + +interface FakeSocket extends EventEmitter { + write: Mock + end: Mock + destroy: Mock + pipe: Mock + remoteAddress?: string + remotePort?: number +} + +function fakeSocket(): FakeSocket { + const s = new EventEmitter() as FakeSocket + s.write = vi.fn() + s.end = vi.fn() + s.destroy = vi.fn() + s.pipe = vi.fn() + return s +} + +function fakeStream(): FakeSocket { + return fakeSocket() +} + +const DYNAMIC_DEF = { type: 'dynamic', sessionId: 's1', listenPort: 1080 } +const LOCAL_DEF = { type: 'local', sessionId: 's1', listenPort: 15432, targetHost: 'db.internal', targetPort: 5432 } +const REMOTE_DEF = { type: 'remote', sessionId: 's1', listenPort: 9000, targetHost: 'localhost', targetPort: 8080 } + +async function saveAndStart(rawDef: Record, conn = fakeConn()) { + const def = (await handler('tunnels:save')({}, rawDef, undefined)) as TunnelDef + const net = primeCreateServer() + ;(connectSessionClient as Mock).mockResolvedValueOnce(conn) + await handler('tunnels:start')({}, def.id) + return { def, conn, net } +} + +beforeEach(() => { + disposeAllTunnels() + windows.length = 0 + ;(connectSessionClient as Mock).mockClear() + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +describe('tunnels:save validation and persistence', () => { + it('rejects malformed definitions', () => { + const save = handler('tunnels:save') + expect(() => save({}, null, undefined)).toThrow(ValidationError) + expect(() => save({}, { ...DYNAMIC_DEF, type: 'socks' }, undefined)).toThrow(ValidationError) + expect(() => save({}, { ...DYNAMIC_DEF, label: 'x'.repeat(65) }, undefined)).toThrow(ValidationError) + expect(() => save({}, { ...DYNAMIC_DEF, listenPort: 0 }, undefined)).toThrow('Invalid listen port') + expect(() => save({}, { ...LOCAL_DEF, targetHost: 'bad host!' }, undefined)).toThrow('illegal characters') + ;(getSessionById as Mock).mockReturnValueOnce(undefined) + expect(() => save({}, DYNAMIC_DEF, undefined)).toThrow(NotFoundError) + }) + + it('creates, lists, updates in place, and deletes tunnels', async () => { + const win = { webContents: { isDestroyed: () => false, send: vi.fn() } } + windows.push(win) + const created = (await handler('tunnels:save')({}, { ...DYNAMIC_DEF, label: 'proxy' }, undefined)) as TunnelDef + expect(created.id).toEqual(expect.any(String)) + expect(win.webContents.send).toHaveBeenCalledWith('tunnel:changed') + + const listed = listTunnels().find((t) => t.id === created.id) + expect(listed).toMatchObject({ label: 'proxy', status: 'stopped', connections: 0 }) + + const updated = (await handler('tunnels:save')({}, { ...DYNAMIC_DEF, listenPort: 1081 }, created.id)) as TunnelDef + expect(updated.id).toBe(created.id) + expect(listTunnels().find((t) => t.id === created.id)?.listenPort).toBe(1081) + + await handler('tunnels:delete')({}, created.id) + expect(listTunnels().find((t) => t.id === created.id)).toBeUndefined() + }) + + it('rejects operations on unknown or malformed tunnel ids', async () => { + expect(() => handler('tunnels:delete')({}, 42)).toThrow(ValidationError) + await expect(handler('tunnels:start')({}, 'missing')).rejects.toBeInstanceOf(NotFoundError) + expect(() => handler('tunnels:stop')({}, 'missing')).toThrow(NotFoundError) + }) +}) + +describe('local forward', () => { + it('forwards accepted sockets through the SSH connection', async () => { + const { def, conn, net } = await saveAndStart(LOCAL_DEF) + expect(net.server.listen).toHaveBeenCalledWith(15432, '127.0.0.1', expect.any(Function)) + expect(listTunnels().find((t) => t.id === def.id)?.status).toBe('active') + + const socket = fakeSocket() + socket.remoteAddress = '127.0.0.1' + socket.remotePort = 50000 + net.connHandler!(socket) + expect(conn.client.forwardOut).toHaveBeenCalledWith('127.0.0.1', 50000, 'db.internal', 5432, expect.any(Function)) + + const stream = fakeStream() + conn.client.forwardOut.mock.calls[0][4](null, stream) + expect(socket.pipe).toHaveBeenCalledWith(stream) + expect(stream.pipe).toHaveBeenCalledWith(socket) + expect(listTunnels().find((t) => t.id === def.id)?.connections).toBe(1) + }) + + it('destroys the socket when the SSH channel cannot open', async () => { + const { conn, net } = await saveAndStart(LOCAL_DEF) + const socket = fakeSocket() + net.connHandler!(socket) + conn.client.forwardOut.mock.calls[0][4](new Error('refused')) + expect(socket.destroy).toHaveBeenCalled() + }) + + it('fails to start when the local port cannot be bound', async () => { + const def = (await handler('tunnels:save')({}, LOCAL_DEF, undefined)) as TunnelDef + const net = primeCreateServer() + net.server.listen.mockImplementation(() => { + net.server.emit('error', new Error('EADDRINUSE')) + }) + const conn = fakeConn() + ;(connectSessionClient as Mock).mockResolvedValueOnce(conn) + await expect(handler('tunnels:start')({}, def.id)).rejects.toThrow('Cannot listen on 127.0.0.1:15432') + expect(conn.dispose).toHaveBeenCalled() + expect(listTunnels().find((t) => t.id === def.id)?.status).toBe('stopped') + }) + + it('does not start the same tunnel twice', async () => { + const { def } = await saveAndStart(LOCAL_DEF) + await handler('tunnels:start')({}, def.id) + expect(connectSessionClient).toHaveBeenCalledTimes(1) + }) +}) + +describe('dynamic (SOCKS5) forward', () => { + function socksClient(net: NetState): FakeSocket { + const socket = fakeSocket() + net.connHandler!(socket) + return socket + } + + it('destroys sockets that do not speak SOCKS', async () => { + const { net } = await saveAndStart(DYNAMIC_DEF) + const socket = socksClient(net) + socket.emit('data', Buffer.from([0x04, 0x01])) // SOCKS4, unsupported + expect(socket.destroy).toHaveBeenCalled() + }) + + it('negotiates no-auth and connects an IPv4 CONNECT request', async () => { + const { conn, net } = await saveAndStart(DYNAMIC_DEF) + const socket = socksClient(net) + + socket.emit('data', Buffer.from([0x05, 0x01, 0x00])) + expect(socket.write).toHaveBeenCalledWith(socksGreetingReply()) + + socket.emit('data', Buffer.from([0x05, 0x01, 0x00, 0x01, 10, 0, 0, 7, 0x00, 0x50])) + expect(conn.client.forwardOut).toHaveBeenCalledWith('127.0.0.1', 0, '10.0.0.7', 80, expect.any(Function)) + + const stream = fakeStream() + conn.client.forwardOut.mock.calls[0][4](null, stream) + expect(socket.write).toHaveBeenCalledWith(socksConnectReply(SOCKS_REPLY.success)) + expect(socket.pipe).toHaveBeenCalledWith(stream) + }) + + it('replies with an error code for unsupported SOCKS commands', async () => { + const { conn, net } = await saveAndStart(DYNAMIC_DEF) + const socket = socksClient(net) + socket.emit('data', Buffer.from([0x05, 0x01, 0x00])) + socket.emit('data', Buffer.from([0x05, 0x02, 0x00, 0x01, 10, 0, 0, 7, 0x00, 0x50])) // BIND + expect(socket.end).toHaveBeenCalledWith(socksConnectReply(SOCKS_REPLY.commandNotSupported)) + expect(conn.client.forwardOut).not.toHaveBeenCalled() + }) + + it('replies connection-refused when the SSH channel cannot open', async () => { + const { conn, net } = await saveAndStart(DYNAMIC_DEF) + const socket = socksClient(net) + socket.emit('data', Buffer.from([0x05, 0x01, 0x00])) + socket.emit('data', Buffer.from([0x05, 0x01, 0x00, 0x01, 10, 0, 0, 7, 0x00, 0x50])) + conn.client.forwardOut.mock.calls[0][4](new Error('no route')) + expect(socket.end).toHaveBeenCalledWith(socksConnectReply(SOCKS_REPLY.connectionRefused)) + }) + + it('destroys sockets on socket errors', async () => { + const { net } = await saveAndStart(DYNAMIC_DEF) + const socket = socksClient(net) + socket.emit('error', new Error('reset')) + expect(socket.destroy).toHaveBeenCalled() + }) +}) + +describe('remote forward', () => { + it('asks the server to listen and pipes accepted connections to the local target', async () => { + const conn = fakeConn() + conn.client.forwardIn.mockImplementation((_h: string, _p: number, cb: (e?: Error) => void) => cb()) + const { def } = await saveAndStart(REMOTE_DEF, conn) + expect(conn.client.forwardIn).toHaveBeenCalledWith('127.0.0.1', 9000, expect.any(Function)) + expect(listTunnels().find((t) => t.id === def.id)?.status).toBe('active') + + const channel = fakeStream() + const local = fakeSocket() + ;(netConnect as Mock).mockReturnValueOnce(local) + conn.client.emit('tcp connection', {}, () => channel) + expect(netConnect).toHaveBeenCalledWith(8080, 'localhost') + + local.emit('connect') + expect(channel.pipe).toHaveBeenCalledWith(local) + expect(local.pipe).toHaveBeenCalledWith(channel) + }) + + it('destroys the channel when the local connection fails', async () => { + const conn = fakeConn() + conn.client.forwardIn.mockImplementation((_h: string, _p: number, cb: (e?: Error) => void) => cb()) + await saveAndStart(REMOTE_DEF, conn) + + const channel = fakeStream() + const local = fakeSocket() + ;(netConnect as Mock).mockReturnValueOnce(local) + conn.client.emit('tcp connection', {}, () => channel) + local.emit('error', new Error('ECONNREFUSED')) + expect(channel.destroy).toHaveBeenCalled() + }) + + it('rejects when the server refuses the remote listen', async () => { + const def = (await handler('tunnels:save')({}, REMOTE_DEF, undefined)) as TunnelDef + const conn = fakeConn() + conn.client.forwardIn.mockImplementation((_h: string, _p: number, cb: (e?: Error) => void) => cb(new Error('denied'))) + ;(connectSessionClient as Mock).mockResolvedValueOnce(conn) + await expect(handler('tunnels:start')({}, def.id)).rejects.toThrow('Server refused to listen on port 9000') + expect(conn.dispose).toHaveBeenCalled() + }) +}) + +describe('lifecycle: stop, SSH drops, disposeAllTunnels', () => { + it('tunnels:stop closes the listener, sockets, and connection', async () => { + const { def, conn, net } = await saveAndStart(DYNAMIC_DEF) + const socket = fakeSocket() + net.connHandler!(socket) + await handler('tunnels:stop')({}, def.id) + expect(net.server.close).toHaveBeenCalled() + expect(socket.destroy).toHaveBeenCalled() + expect(conn.dispose).toHaveBeenCalled() + expect(listTunnels().find((t) => t.id === def.id)?.status).toBe('stopped') + }) + + it('a dropped SSH connection tears the tunnel down', async () => { + const { def, conn, net } = await saveAndStart(DYNAMIC_DEF) + conn.client.emit('close') + expect(net.server.close).toHaveBeenCalled() + expect(listTunnels().find((t) => t.id === def.id)?.status).toBe('stopped') + }) + + it('an SSH error marks the tunnel as errored but keeps it listed', async () => { + const { def, conn } = await saveAndStart(DYNAMIC_DEF) + conn.client.emit('error', new Error('keepalive timeout')) + const listed = listTunnels().find((t) => t.id === def.id) + expect(listed?.status).toBe('error') + expect(listed?.error).toBe('keepalive timeout') + }) + + it('updating an active tunnel stops it first', async () => { + const { def, conn } = await saveAndStart(DYNAMIC_DEF) + await handler('tunnels:save')({}, { ...DYNAMIC_DEF, listenPort: 1082 }, def.id) + expect(conn.dispose).toHaveBeenCalled() + expect(listTunnels().find((t) => t.id === def.id)?.status).toBe('stopped') + }) + + it('disposeAllTunnels stops every active tunnel', async () => { + const a = await saveAndStart(DYNAMIC_DEF) + const b = await saveAndStart(LOCAL_DEF) + disposeAllTunnels() + expect(a.conn.dispose).toHaveBeenCalled() + expect(b.conn.dispose).toHaveBeenCalled() + expect(a.net.server.close).toHaveBeenCalled() + expect(b.net.server.close).toHaveBeenCalled() + expect(listTunnels().every((t) => t.status === 'stopped')).toBe(true) + }) +}) diff --git a/src/main/ipc/database.ts b/src/main/ipc/database.ts index 9a1e42e..441e19b 100644 --- a/src/main/ipc/database.ts +++ b/src/main/ipc/database.ts @@ -1,18 +1,20 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { Pool as PgPool } from 'pg' import mysql from 'mysql2/promise' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' import { validateHost, validatePort } from './security' type DbType = 'postgresql' | 'mysql' | 'mariadb' -type SslMode = 'disable' | 'require' | 'verify-ca' | 'verify-full' | undefined +type SslMode = 'disable' | 'require' | 'verify-ca' | 'verify-full' interface QueryResult { columns: string[]; rows: unknown[]; rowCount: number; duration: number } +type QueryParam = string | number | boolean | null + interface DbConnection { type: DbType - query: (sql: string) => Promise + query: (sql: string, params?: QueryParam[]) => Promise close: () => Promise getTables: () => Promise getTableInfo: (table: string) => Promise<{ columns: { name: string; type: string; nullable: boolean }[] }> @@ -41,6 +43,19 @@ function validateConnId(id: unknown): string { return id } +const MAX_QUERY_PARAMS = 256 + +function validateQueryParams(raw: unknown): QueryParam[] | undefined { + if (raw === undefined || raw === null) return undefined + if (!Array.isArray(raw) || raw.length > MAX_QUERY_PARAMS) throw new ValidationError('Invalid query parameters') + for (const v of raw) { + if (v !== null && typeof v !== 'string' && typeof v !== 'number' && typeof v !== 'boolean') { + throw new ValidationError('Query parameters must be scalar values') + } + } + return raw as QueryParam[] +} + function requireOwnedConn(event: IpcMainInvokeEvent, rawId: unknown): DbConnection { const id = validateConnId(rawId) const entry = connections.get(id) @@ -66,7 +81,10 @@ function validateConnectConfig(raw: unknown): DbConnectConfig { if (typeof c.database !== 'string' || c.database.length === 0 || c.database.length > MAX_DATABASE_LENGTH) { throw new ValidationError('Database name is required') } - const ssl = c.ssl === undefined ? undefined : String(c.ssl) + if (c.ssl !== undefined && typeof c.ssl !== 'string') { + throw new ValidationError('Invalid SSL mode') + } + const ssl = c.ssl as string | undefined if (ssl !== undefined && !['disable', 'require', 'verify-ca', 'verify-full'].includes(ssl)) { throw new ValidationError(`Invalid SSL mode: ${ssl}`) } @@ -77,17 +95,12 @@ function validateConnectConfig(raw: unknown): DbConnectConfig { username: c.username, password: (c.password as string | undefined) || undefined, database: c.database, - ssl: ssl as SslMode, + ssl: ssl as SslMode | undefined, } } -function pgSslOption(mode: SslMode): boolean | { rejectUnauthorized: boolean } | undefined { - if (mode === 'verify-full' || mode === 'verify-ca') return { rejectUnauthorized: true } - if (mode === 'require') return { rejectUnauthorized: false } - return undefined -} - -function mysqlSslOption(mode: SslMode): Record | undefined { +// Shared by pg and mysql2 — both accept { rejectUnauthorized } for their ssl option. +function sslOption(mode: SslMode | undefined): { rejectUnauthorized: boolean } | undefined { if (mode === 'verify-full' || mode === 'verify-ca') return { rejectUnauthorized: true } if (mode === 'require') return { rejectUnauthorized: false } return undefined @@ -100,7 +113,7 @@ async function connectPostgres(config: DbConnectConfig): Promise { user: config.username, password: config.password, database: config.database, - ssl: pgSslOption(config.ssl), + ssl: sslOption(config.ssl), connectionTimeoutMillis: 20_000, idleTimeoutMillis: 30_000, max: 4, @@ -119,9 +132,9 @@ async function connectPostgres(config: DbConnectConfig): Promise { return { type: 'postgresql', - async query(sql: string) { + async query(sql: string, params?: QueryParam[]) { const start = Date.now() - const result = await pool.query(sql) + const result = await pool.query(sql, params) return { columns: result.fields?.map(f => f.name) ?? [], rows: result.rows ?? [], @@ -161,7 +174,7 @@ async function connectMysql(config: DbConnectConfig): Promise { user: config.username, password: config.password, database: config.database, - ssl: mysqlSslOption(config.ssl), + ssl: sslOption(config.ssl), connectionLimit: 4, connectTimeout: 20_000, enableKeepAlive: true, @@ -178,9 +191,9 @@ async function connectMysql(config: DbConnectConfig): Promise { return { type: config.dbType === 'mariadb' ? 'mariadb' : 'mysql', - async query(sql: string) { + async query(sql: string, params?: QueryParam[]) { const start = Date.now() - const [rows, fields] = await pool.query(sql) + const [rows, fields] = await pool.query(sql, params) const resultRows = Array.isArray(rows) ? rows as unknown[] : [] const resultFields = Array.isArray(fields) ? fields : [] return { @@ -243,14 +256,14 @@ export function registerDatabaseHandlers(): void { } }) - ipcMain.handle('db:query', async (event, rawId: unknown, sql: unknown) => { + ipcMain.handle('db:query', async (event, rawId: unknown, sql: unknown, rawParams: unknown) => { if (typeof sql !== 'string' || sql.trim().length === 0) { throw new ValidationError('SQL query is required') } if (Buffer.byteLength(sql, 'utf8') > MAX_SQL_BYTES) { throw new ValidationError(`SQL query exceeds ${MAX_SQL_BYTES} bytes`) } - return requireOwnedConn(event, rawId).query(sql) + return requireOwnedConn(event, rawId).query(sql, validateQueryParams(rawParams)) }) ipcMain.handle('db:tables', async (event, rawId: unknown) => { diff --git a/src/main/ipc/docker.ts b/src/main/ipc/docker.ts index bcd50b8..f1da4c4 100644 --- a/src/main/ipc/docker.ts +++ b/src/main/ipc/docker.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainInvokeEvent, WebContents } from 'electron' import { ClientChannel } from 'ssh2' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { connectSessionClient, ManagedSshConnection } from './sshClients' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' @@ -60,6 +60,30 @@ export function parseJsonLines(output: string): Record[] { return rows } +// Wires a `docker logs -f` channel to the renderer and returns the stream id. +function attachLogStream(sender: WebContents, stream: ClientChannel): string { + const logId = randomUUID() + logStreams.set(logId, { channel: stream, senderId: sender.id }) + + const send = (data: Buffer) => { + if (!sender.isDestroyed()) sender.send('docker:logChunk', logId, data.toString('utf8')) + } + // 'error' is typically followed by 'close'; only the first terminal event + // may notify, so an error message isn't clobbered by a null from 'close'. + let settled = false + const finish = (error: string | null) => { + if (settled) return + settled = true + logStreams.delete(logId) + if (!sender.isDestroyed()) sender.send('docker:logEnd', logId, error) + } + stream.on('data', send) + stream.stderr.on('data', send) + stream.on('close', () => finish(null)) + stream.on('error', (e: unknown) => finish(toMessage(e))) + return logId +} + function requireSession(event: IpcMainInvokeEvent, rawId: unknown): DockerSession { if (typeof rawId !== 'string') throw new ValidationError('Invalid Docker session id') const entry = sessions.get(rawId) @@ -75,15 +99,14 @@ function execCollect(entry: DockerSession, command: string): Promise { let stdout = '' let stderr = '' - let truncated = false const timer = setTimeout(() => { stream.close() reject(new ConnectionError('Remote command timed out')) }, EXEC_TIMEOUT_MS) stream.on('data', (d: Buffer) => { + // Over-limit output is dropped; parseJsonLines skips the partial tail line. if (stdout.length < MAX_OUTPUT_BYTES) stdout += d.toString('utf8') - else truncated = true }) stream.stderr.on('data', (d: Buffer) => { if (stderr.length < 16 * 1024) stderr += d.toString('utf8') @@ -91,7 +114,7 @@ function execCollect(entry: DockerSession, command: string): Promise { stream.on('close', (code: number | null) => { clearTimeout(timer) if (code === 0 || (code === null && stdout)) { - resolve(truncated ? stdout : stdout) + resolve(stdout) } else if (code === 127) { reject(new ConnectionError('Docker CLI not found on this host')) } else { @@ -187,23 +210,7 @@ export function registerDockerHandlers(): void { return new Promise((resolve, reject) => { entry.conn.client.exec(`docker logs --tail ${tail} -f ${container} 2>&1`, (err, stream) => { if (err) return reject(new ConnectionError(toMessage(err))) - const logId = randomUUID() - logStreams.set(logId, { channel: stream, senderId: event.sender.id }) - - const send = (data: Buffer) => { - if (!event.sender.isDestroyed()) event.sender.send('docker:logChunk', logId, data.toString('utf8')) - } - stream.on('data', send) - stream.stderr.on('data', send) - stream.on('close', () => { - logStreams.delete(logId) - if (!event.sender.isDestroyed()) event.sender.send('docker:logEnd', logId, null) - }) - stream.on('error', (e: unknown) => { - logStreams.delete(logId) - if (!event.sender.isDestroyed()) event.sender.send('docker:logEnd', logId, toMessage(e)) - }) - resolve(logId) + resolve(attachLogStream(event.sender, stream)) }) }) }) diff --git a/src/main/ipc/k8s.ts b/src/main/ipc/k8s.ts index ce3ae45..3834842 100644 --- a/src/main/ipc/k8s.ts +++ b/src/main/ipc/k8s.ts @@ -1,11 +1,11 @@ import { app, ipcMain, dialog, BrowserWindow, IpcMainInvokeEvent, IpcMainEvent } from 'electron' import * as k8s from '@kubernetes/client-node' -import * as net from 'net' -import { PassThrough } from 'stream' -import { copyFileSync, mkdirSync, statSync } from 'fs' -import { join, basename, resolve as resolvePath } from 'path' -import { homedir } from 'os' -import { createHash, randomUUID } from 'crypto' +import * as net from 'node:net' +import { PassThrough } from 'node:stream' +import { copyFileSync, mkdirSync, statSync } from 'node:fs' +import { join, basename, resolve as resolvePath } from 'node:path' +import { homedir } from 'node:os' +import { createHash, randomUUID } from 'node:crypto' import { isUnlocked } from './keychain' import { isAllowedKubeconfigPath, registerAllowedKubeconfigDir, validateK8sName, validatePort } from './security' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' @@ -152,6 +152,43 @@ export function disposeK8sSessionsForSender(senderId: number): void { // ── Handler registration ────────────────────────────────────────────────────── +function startPodForward( + kc: k8s.KubeConfig, + namespace: string, + podName: string, + targetPort: number, + localPort: unknown, + senderId: number, + meta: Omit, +): Promise<{ id: string; localPort: number }> { + if (localPort !== undefined && localPort !== null && localPort !== 0) { + validatePort(localPort, 'local port') + } + const sessionId = makeId() + const forward = new k8s.PortForward(kc) + const sockets = new Set() + + return new Promise<{ id: string; localPort: number }>((resolve, reject) => { + const server = net.createServer((socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + socket.on('error', (err) => safeLog(`pf socket ${sessionId}`, err)) + forward.portForward(namespace, podName, [targetPort], socket, null, socket) + .catch((err) => { + safeLog(`pf forward ${sessionId}`, err) + socket.destroy() + }) + }) + + server.on('error', (err) => reject(new ConnectionError(toMessage(err)))) + server.listen(localPort && Number(localPort) > 0 ? Number(localPort) : 0, '127.0.0.1', () => { + const addr = server.address() as net.AddressInfo + pfSessions.set(sessionId, { server, sockets, senderId, meta: { ...meta, localPort: addr.port } }) + resolve({ id: sessionId, localPort: addr.port }) + }) + }) +} + export function registerK8sHandlers(): void { registerAllowedKubeconfigDir(managedKubeconfigDir()) @@ -475,7 +512,7 @@ export function registerK8sHandlers(): void { ipcMain.handle('k8s:events', async (_e, context: unknown, namespace: unknown, kubeconfigPath?: unknown) => { const { core } = makeClient(context, validateOptionalKubeconfig(kubeconfigPath)) const res = await core.listNamespacedEvent(validateNamespace(namespace)) - return res.body.items + return [...res.body.items] .sort((a, b) => { const ta = a.lastTimestamp ? new Date(a.lastTimestamp as unknown as string).getTime() : 0 const tb = b.lastTimestamp ? new Date(b.lastTimestamp as unknown as string).getTime() : 0 @@ -644,7 +681,7 @@ export function registerK8sHandlers(): void { ipcMain.on('k8s:execSend', (event: IpcMainEvent, sessionId: unknown, data: unknown) => { if (typeof sessionId !== 'string' || typeof data !== 'string') return const entry = execSessions.get(sessionId) - if (!entry || entry.senderId !== event.sender.id) return + if (entry?.senderId !== event.sender.id) return if (Buffer.byteLength(data, 'utf8') > 64 * 1024) return entry.stdin.write(data) }) @@ -654,7 +691,7 @@ export function registerK8sHandlers(): void { if (!Number.isInteger(cols) || !Number.isInteger(rows)) return if ((cols as number) < 1 || (cols as number) > 1000 || (rows as number) < 1 || (rows as number) > 1000) return const entry = execSessions.get(sessionId) - if (!entry || entry.senderId !== event.sender.id) return + if (entry?.senderId !== event.sender.id) return if (!entry.ws?.send) return try { const buf = Buffer.alloc(5) @@ -677,43 +714,6 @@ export function registerK8sHandlers(): void { // ── Port forwarding ─────────────────────────────────────────────────────────── - function startPodForward( - kc: k8s.KubeConfig, - namespace: string, - podName: string, - targetPort: number, - localPort: unknown, - senderId: number, - meta: Omit, - ): Promise<{ id: string; localPort: number }> { - if (localPort !== undefined && localPort !== null && localPort !== 0) { - validatePort(localPort, 'local port') - } - const sessionId = makeId() - const forward = new k8s.PortForward(kc) - const sockets = new Set() - - return new Promise<{ id: string; localPort: number }>((resolve, reject) => { - const server = net.createServer((socket) => { - sockets.add(socket) - socket.on('close', () => sockets.delete(socket)) - socket.on('error', (err) => safeLog(`pf socket ${sessionId}`, err)) - forward.portForward(namespace, podName, [targetPort], socket, null, socket) - .catch((err) => { - safeLog(`pf forward ${sessionId}`, err) - socket.destroy() - }) - }) - - server.on('error', (err) => reject(new ConnectionError(toMessage(err)))) - server.listen(localPort && Number(localPort) > 0 ? Number(localPort) : 0, '127.0.0.1', () => { - const addr = server.address() as net.AddressInfo - pfSessions.set(sessionId, { server, sockets, senderId, meta: { ...meta, localPort: addr.port } }) - resolve({ id: sessionId, localPort: addr.port }) - }) - }) - } - ipcMain.handle('k8s:portForwardStart', async (event: IpcMainInvokeEvent, context: unknown, namespace: unknown, podName: unknown, targetPort: unknown, localPort: unknown, kubeconfigPath?: unknown) => { const safePath = validateOptionalKubeconfig(kubeconfigPath) const safeNs = validateNamespace(namespace) diff --git a/src/main/ipc/keychain.ts b/src/main/ipc/keychain.ts index b0e9a5e..13aaf56 100644 --- a/src/main/ipc/keychain.ts +++ b/src/main/ipc/keychain.ts @@ -1,7 +1,7 @@ import { ipcMain, systemPreferences, BrowserWindow } from 'electron' import Store from 'electron-store' import keytar from 'keytar' -import { pbkdf2Sync, randomBytes } from 'crypto' +import { pbkdf2Sync, randomBytes } from 'node:crypto' export type AuthMode = 'none' | 'pin' | 'password' | 'biometrics' diff --git a/src/main/ipc/localTerminal.ts b/src/main/ipc/localTerminal.ts index 7a087cb..51debb8 100644 --- a/src/main/ipc/localTerminal.ts +++ b/src/main/ipc/localTerminal.ts @@ -1,7 +1,7 @@ import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import { spawn, IPty } from 'node-pty' -import { homedir, platform } from 'os' -import { randomUUID } from 'crypto' +import { homedir, platform } from 'node:os' +import { randomUUID } from 'node:crypto' import { NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' // Local shell sessions via node-pty: the user's default shell on diff --git a/src/main/ipc/localfs.ts b/src/main/ipc/localfs.ts index 89d7284..f843a07 100644 --- a/src/main/ipc/localfs.ts +++ b/src/main/ipc/localfs.ts @@ -1,8 +1,8 @@ import { ipcMain } from 'electron' -import { readdirSync, statSync } from 'fs' -import { readFile, writeFile } from 'fs/promises' -import { homedir } from 'os' -import { join } from 'path' +import { readdirSync, statSync } from 'node:fs' +import { readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' import { isInsideHome, isLikelyTextFile } from './security' import { ValidationError } from './errors' diff --git a/src/main/ipc/metrics.ts b/src/main/ipc/metrics.ts index 133fcd3..8550cea 100644 --- a/src/main/ipc/metrics.ts +++ b/src/main/ipc/metrics.ts @@ -60,16 +60,16 @@ export function parseMetricsOutput( } const cpuStat = total > 0 ? { idle, total } : undefined - const memTotal = parseInt(memStr.match(/MemTotal:\s+(\d+)/)?.[1] ?? '0') * 1024 - const memAvail = parseInt(memStr.match(/MemAvailable:\s+(\d+)/)?.[1] ?? '0') * 1024 + const memTotal = Number.parseInt(/MemTotal:\s+(\d+)/.exec(memStr)?.[1] ?? '0') * 1024 + const memAvail = Number.parseInt(/MemAvailable:\s+(\d+)/.exec(memStr)?.[1] ?? '0') * 1024 // df -kP: filesystem, 1k-blocks, used, available, capacity, mount const diskParts = diskStr.trim().split(/\s+/) - const diskTotal = (parseInt(diskParts[1] ?? '0') || 0) * 1024 - const diskUsed = (parseInt(diskParts[2] ?? '0') || 0) * 1024 + const diskTotal = (Number.parseInt(diskParts[1] ?? '0') || 0) * 1024 + const diskUsed = (Number.parseInt(diskParts[2] ?? '0') || 0) * 1024 - const load1 = parseFloat(loadStr.trim().split(/\s+/)[0] ?? '0') || 0 - const uptimeSec = Math.floor(parseFloat(upStr.trim().split(/\s+/)[0] ?? '0')) || 0 + const load1 = Number.parseFloat(loadStr.trim().split(/\s+/)[0] ?? '0') || 0 + const uptimeSec = Math.floor(Number.parseFloat(upStr.trim().split(/\s+/)[0] ?? '0')) || 0 return { metrics: { diff --git a/src/main/ipc/rdp.ts b/src/main/ipc/rdp.ts index 5b028a9..d30292e 100644 --- a/src/main/ipc/rdp.ts +++ b/src/main/ipc/rdp.ts @@ -1,8 +1,8 @@ import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' -import { spawn, ChildProcessWithoutNullStreams } from 'child_process' -import { join } from 'path' -import { existsSync } from 'fs' -import { randomUUID } from 'crypto' +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process' +import { join } from 'node:path' +import { existsSync } from 'node:fs' +import { randomUUID } from 'node:crypto' import { is } from '@electron-toolkit/utils' import { NotFoundError, OwnershipError, ValidationError, ConnectionError, toMessage } from './errors' @@ -102,44 +102,38 @@ function logStray(id: string, entry: RdpSession, stray: Buffer): void { ) } +// Validates the header at the buffer head. Returns null when it is not a +// plausible frame (bad magic, absurd dimensions, or a dataLen mismatch — +// e.g. a false "NXF1" matched inside pixel data) so the caller can resync. +function parseFrameHeader(buffer: Buffer): { width: number; height: number; dataLen: number } | null { + if (buffer.toString('ascii', 0, 4) !== MAGIC) return null + const width = buffer.readUInt32LE(4) + const height = buffer.readUInt32LE(8) + const dataLen = buffer.readUInt32LE(12) + if (width === 0 || height === 0 || width > 7680 || height > 7680) return null + if (dataLen !== width * height * 4 || dataLen > MAX_FRAME_BYTES) return null + return { width, height, dataLen } +} + // Pull every complete frame out of the accumulated buffer and forward it. function drainFrames(id: string, entry: RdpSession): void { for (;;) { if (entry.buffer.length < HEADER_BYTES) return - if (entry.buffer.toString('ascii', 0, 4) !== MAGIC) { + const header = parseFrameHeader(entry.buffer) + if (!header) { // Non-frame bytes on stdout — recover by skipping to the next frame. if (!resyncToMagic(id, entry)) return continue } - const width = entry.buffer.readUInt32LE(4) - const height = entry.buffer.readUInt32LE(8) - const dataLen = entry.buffer.readUInt32LE(12) - - // Validate width/height are reasonable and dataLen matches - if (width === 0 || height === 0 || width > 7680 || height > 7680) { - if (!resyncToMagic(id, entry)) return - continue - } - if (dataLen !== width * height * 4) { - if (!resyncToMagic(id, entry)) return - continue - } - - if (dataLen > MAX_FRAME_BYTES) { - // Almost certainly a false "NXF1" matched inside pixel data — skip past it. - if (!resyncToMagic(id, entry)) return - continue - } - - const total = HEADER_BYTES + dataLen + const total = HEADER_BYTES + header.dataLen if (entry.buffer.length < total) return // wait for more chunks const pixels = entry.buffer.subarray(HEADER_BYTES, total) if (!entry.sender.isDestroyed()) { // Copy out: the backing buffer is about to be sliced/reused. - entry.sender.send('rdp:frame', id, width, height, Buffer.from(pixels)) + entry.sender.send('rdp:frame', id, header.width, header.height, Buffer.from(pixels)) } entry.buffer = entry.buffer.subarray(total) } @@ -155,9 +149,11 @@ export function registerRdpHandlers(): void { if (typeof username !== 'string' || !username) throw new ValidationError('Username is required') if (typeof password !== 'string') throw new ValidationError('Password is required') - const port = typeof config.port === 'number' ? config.port : 3389 - const width = typeof config.width === 'number' ? config.width : 1280 - const height = typeof config.height === 'number' ? config.height : 800 + const clamp = (v: unknown, lo: number, hi: number, dflt: number): number => + typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.round(v))) : dflt + const port = clamp(config.port, 1, 65535, 3389) + const width = clamp(config.width, 640, 7680, 1280) + const height = clamp(config.height, 480, 7680, 800) const bin = sidecarPath() if (!existsSync(bin)) { @@ -172,7 +168,13 @@ export function registerRdpHandlers(): void { { stdio: ['pipe', 'pipe', 'pipe'] }, ) - // Write password to stdin instead of passing as command-line argument + // Write password to stdin instead of passing as command-line argument. + // The error handler matters: if the sidecar dies before reading (bad + // binary, instant crash), the write EPIPEs, and an unhandled stream + // 'error' would take down the whole main process. + proc.stdin.on('error', (err) => { + console.error(`[rdp] stdin write failed: ${toMessage(err)}`) + }) proc.stdin.write(password + '\n') proc.stdin.end() @@ -180,10 +182,12 @@ export function registerRdpHandlers(): void { const entry: RdpSession = { proc, sender: event.sender, buffer: Buffer.alloc(0), resyncs: 0 } sessions.set(id, entry) - // The sidecar prints an authoritative outcome line ("[sidecar] ...") to - // stderr alongside FreeRDP's verbose logs. Keep the last one so a failed - // connect surfaces *why* in the tab instead of a bare exit code. + // The sidecar prints outcome lines ("[sidecar] ...") to stderr alongside + // FreeRDP's verbose logs. Failures use a "[sidecar] error: ..." prefix — + // prefer those for the close reason so an informational line (cert + // fingerprint, "connected") never masks the actual failure. let lastSidecarMsg: string | null = null + let lastSidecarError: string | null = null proc.stdout.on('data', (chunk: Buffer) => { entry.buffer = entry.buffer.length ? Buffer.concat([entry.buffer, chunk]) : chunk @@ -195,8 +199,15 @@ export function registerRdpHandlers(): void { const text = chunk.toString() console.error(`[rdp-sidecar ${id}] ${text.trimEnd()}`) for (const line of text.split('\n')) { - const m = line.match(/\[sidecar\]\s*(.+?)\s*$/) - if (m) lastSidecarMsg = m[1] + const m = /\[sidecar\](.*)/.exec(line) + if (!m) continue + const msg = m[1].trim() + if (!msg) continue + lastSidecarMsg = msg + if (msg.startsWith('error:')) { + const detail = msg.slice('error:'.length).trim() + if (detail) lastSidecarError = detail + } } }) @@ -209,7 +220,7 @@ export function registerRdpHandlers(): void { sessions.delete(id) if (!event.sender.isDestroyed()) { const reason = - code === 0 ? null : lastSidecarMsg ?? `sidecar exited (${code})` + code === 0 ? null : lastSidecarError ?? lastSidecarMsg ?? `sidecar exited (${code})` event.sender.send('rdp:closed', id, reason) } }) diff --git a/src/main/ipc/redis.ts b/src/main/ipc/redis.ts index 9ac7ae2..e0726ed 100644 --- a/src/main/ipc/redis.ts +++ b/src/main/ipc/redis.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' import Redis from 'ioredis' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { isBlockedRedisCommand, validateHost, validatePort } from './security' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' diff --git a/src/main/ipc/runner.ts b/src/main/ipc/runner.ts index 15519eb..373529f 100644 --- a/src/main/ipc/runner.ts +++ b/src/main/ipc/runner.ts @@ -1,5 +1,5 @@ import { ipcMain, IpcMainInvokeEvent, WebContents } from 'electron' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { connectSessionClient, ManagedSshConnection } from './sshClients' import { getSessionById } from './sessions' import { NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' diff --git a/src/main/ipc/security.ts b/src/main/ipc/security.ts index 4728529..ee5910c 100644 --- a/src/main/ipc/security.ts +++ b/src/main/ipc/security.ts @@ -1,6 +1,6 @@ -import { homedir } from 'os' -import { resolve, normalize } from 'path' -import { statSync } from 'fs' +import { homedir } from 'node:os' +import { resolve, normalize } from 'node:path' +import { statSync } from 'node:fs' const MAX_KEY_FILE_SIZE = 64 * 1024 const MAX_KUBECONFIG_FILE_SIZE = 1024 * 1024 @@ -90,7 +90,7 @@ export function isBlockedRedisCommand(command: string): boolean { } export function getBlockedRedisCommands(): string[] { - return [...BLOCKED_REDIS_COMMANDS].sort() + return [...BLOCKED_REDIS_COMMANDS].sort((a, b) => a.localeCompare(b)) } // Extension check is only a cheap pre-filter to avoid streaming large binaries; diff --git a/src/main/ipc/sessionTransfer.ts b/src/main/ipc/sessionTransfer.ts index a9e7d25..4403a78 100644 --- a/src/main/ipc/sessionTransfer.ts +++ b/src/main/ipc/sessionTransfer.ts @@ -31,6 +31,24 @@ function readString(value: unknown): string | undefined { return typeof value === 'string' && value.trim() ? value : undefined } +function applyOptionalFields(session: ImportedSession, r: Record): void { + session.keyPath = readString(r.keyPath) + session.group = readString(r.group) + session.color = readString(r.color) + session.dbType = readString(r.dbType) + session.databaseName = readString(r.databaseName) + session.sslMode = readString(r.sslMode) + session.kubeconfigPath = readString(r.kubeconfigPath) + if (Array.isArray(r.tags)) session.tags = r.tags.filter((t): t is string => typeof t === 'string') + if (typeof r.isFavorite === 'boolean') session.isFavorite = r.isFavorite + if (typeof r.pollingEnabled === 'boolean') session.pollingEnabled = r.pollingEnabled + if (typeof r.connectOnStart === 'boolean') session.connectOnStart = r.connectOnStart + if (typeof r.pollingIntervalSeconds === 'number' && Number.isFinite(r.pollingIntervalSeconds)) { + session.pollingIntervalSeconds = r.pollingIntervalSeconds + } + if (typeof r.redisDb === 'number' && Number.isInteger(r.redisDb)) session.redisDb = r.redisDb +} + function sanitizeConnection(raw: unknown): ImportedSession | null { if (typeof raw !== 'object' || raw === null) return null const r = raw as Record @@ -53,22 +71,8 @@ function sanitizeConnection(raw: unknown): ImportedSession | null { type, } - session.keyPath = readString(r.keyPath) - session.group = readString(r.group) - session.color = readString(r.color) - session.dbType = readString(r.dbType) - session.databaseName = readString(r.databaseName) - session.sslMode = readString(r.sslMode) session.contextName = contextName - session.kubeconfigPath = readString(r.kubeconfigPath) - if (Array.isArray(r.tags)) session.tags = r.tags.filter((t): t is string => typeof t === 'string') - if (typeof r.isFavorite === 'boolean') session.isFavorite = r.isFavorite - if (typeof r.pollingEnabled === 'boolean') session.pollingEnabled = r.pollingEnabled - if (typeof r.connectOnStart === 'boolean') session.connectOnStart = r.connectOnStart - if (typeof r.pollingIntervalSeconds === 'number' && Number.isFinite(r.pollingIntervalSeconds)) { - session.pollingIntervalSeconds = r.pollingIntervalSeconds - } - if (typeof r.redisDb === 'number' && Number.isInteger(r.redisDb)) session.redisDb = r.redisDb + applyOptionalFields(session, r) return session } diff --git a/src/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index a38cdbb..056a3a2 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -1,7 +1,7 @@ import { ipcMain, dialog, BrowserWindow } from 'electron' import Store from 'electron-store' -import { randomUUID } from 'crypto' -import { readFile, writeFile } from 'fs/promises' +import { randomUUID } from 'node:crypto' +import { readFile, writeFile } from 'node:fs/promises' import { saveCredential, getCredential, deleteCredentials, isUnlocked } from './keychain' import { serializeSessions, parseSessionsExport, connectionDedupKey } from './sessionTransfer' diff --git a/src/main/ipc/sftp.ts b/src/main/ipc/sftp.ts index db5a5ea..3e20381 100644 --- a/src/main/ipc/sftp.ts +++ b/src/main/ipc/sftp.ts @@ -1,10 +1,9 @@ import { ipcMain, IpcMainInvokeEvent } from 'electron' import { Client, ClientChannel, SFTPWrapper, Stats } from 'ssh2' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { ConnectionError, NotFoundError, OwnershipError, ValidationError, toMessage } from './errors' -import { getOwnedSshClient } from './ssh' +import { getOwnedSshClient, SSH_CONNECT_DEFAULTS, sshConnectOptions } from './ssh' import { isInsideHome, isLikelyTextFile, validateHost, validatePort } from './security' -import { SSH_CONNECT_DEFAULTS, sshConnectOptions } from './ssh' import { connectSessionClient, openJumpSocket, ManagedSshConnection } from './sshClients' interface SftpClient { @@ -153,7 +152,11 @@ async function openSftp(event: IpcMainInvokeEvent, config: SftpConnectConfig): P const client = new Client() const clientId = randomUUID() let settled = false - const settle = (fn: () => void) => { if (settled) return; settled = true; fn() } + const settle = (fn: () => void) => { + if (settled) return + settled = true + fn() + } client.on('ready', () => { createSftpChannel(client, clientId, true, event.sender.id, upstream).then( @@ -181,6 +184,47 @@ async function openSftp(event: IpcMainInvokeEvent, config: SftpConnectConfig): P }) } +function listDir(sftp: SFTPWrapper, path: string): Promise { + return new Promise((resolve, reject) => { + sftp.readdir(path, (err, list) => { + if (err) return reject(new ConnectionError(toMessage(err))) + resolve(list.map((f) => ({ + name: f.filename, + size: f.attrs.size, + mtime: f.attrs.mtime * 1000, + permissions: f.attrs.mode, + isDirectory: (f.attrs.mode & 0o170000) === 0o040000, + }))) + }) + }) +} + +function readTextFile(sftp: SFTPWrapper, remotePath: string): Promise { + return new Promise((resolve, reject) => { + sftp.stat(remotePath, (statErr, stats: Stats) => { + if (statErr) return reject(new ConnectionError(toMessage(statErr))) + + const filename = remotePath.split('/').pop() ?? '' + if (!isLikelyTextFile(filename, stats.size ?? 0)) { + return reject(new ValidationError('Cannot open binary file in editor. Use download instead.')) + } + + const chunks: Buffer[] = [] + const stream = sftp.createReadStream(remotePath) + stream.on('data', (c: Buffer) => chunks.push(c)) + stream.on('end', () => { + const buf = Buffer.concat(chunks) + const sample = buf.subarray(0, 8192) + if (sample.includes(0)) { + return reject(new ValidationError('File appears to be binary. Use download instead.')) + } + resolve(buf.toString('utf8')) + }) + stream.on('error', (err: unknown) => reject(new ConnectionError(toMessage(err)))) + }) + }) +} + function disposeClient(clientId: string): void { const entry = sftpClients.get(clientId) if (!entry) return @@ -205,48 +249,14 @@ export function registerSftpHandlers(): void { ipcMain.handle('sftp:list', (event, rawClientId: unknown, rawPath: unknown) => { const path = validateRemotePath(rawPath) - return new Promise((resolve, reject) => { - const entry = requireClient(event, rawClientId) - entry.sftp.readdir(path, (err, list) => { - if (err) return reject(new ConnectionError(toMessage(err))) - resolve(list.map((f) => ({ - name: f.filename, - size: f.attrs.size, - mtime: f.attrs.mtime * 1000, - permissions: f.attrs.mode, - isDirectory: (f.attrs.mode & 0o170000) === 0o040000, - }))) - }) - }) + const entry = requireClient(event, rawClientId) + return listDir(entry.sftp, path) }) ipcMain.handle('sftp:readFile', (event, rawClientId: unknown, rawRemotePath: unknown) => { const remotePath = validateRemotePath(rawRemotePath) - return new Promise((resolve, reject) => { - const entry = requireClient(event, rawClientId) - - entry.sftp.stat(remotePath, (statErr, stats: Stats) => { - if (statErr) return reject(new ConnectionError(toMessage(statErr))) - - const filename = remotePath.split('/').pop() ?? '' - if (!isLikelyTextFile(filename, stats.size ?? 0)) { - return reject(new ValidationError('Cannot open binary file in editor. Use download instead.')) - } - - const chunks: Buffer[] = [] - const stream = entry.sftp.createReadStream(remotePath) - stream.on('data', (c: Buffer) => chunks.push(c)) - stream.on('end', () => { - const buf = Buffer.concat(chunks) - const sample = buf.subarray(0, 8192) - if (sample.includes(0)) { - return reject(new ValidationError('File appears to be binary. Use download instead.')) - } - resolve(buf.toString('utf8')) - }) - stream.on('error', (err: unknown) => reject(new ConnectionError(toMessage(err)))) - }) - }) + const entry = requireClient(event, rawClientId) + return readTextFile(entry.sftp, remotePath) }) ipcMain.handle('sftp:writeFile', (event, rawClientId: unknown, rawRemotePath: unknown, rawContent: unknown) => { diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index c1f43fe..67750f3 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -1,6 +1,6 @@ import { ipcMain, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' import { Client, ClientChannel } from 'ssh2' -import { randomUUID } from 'crypto' +import { randomUUID } from 'node:crypto' import { ConnectionError, OwnershipError, ValidationError, toMessage } from './errors' import { validateHost, validatePort } from './security' import { @@ -146,6 +146,41 @@ function stopMetrics(streamId: string): void { prevCpuStats.delete(streamId) } +function fetchAndEmit(streamId: string): void { + const live = streams.get(streamId) + if (!live) { stopMetrics(streamId); return } + const timers = metricsTimers.get(streamId) + if (!timers || timers.inFlight) return + timers.inFlight = true + + live.client.exec(METRICS_COMMAND, (err, s) => { + if (err) { timers.inFlight = false; return } + let out = '' + s.on('data', (d: Buffer) => { out += d.toString() }) + // stderr is intentionally drained to keep the channel from buffering; + // remote `cat`/`grep` may report 'no such file' on non-Linux hosts. + s.stderr.on('data', () => {}) + s.on('close', () => { + const t = metricsTimers.get(streamId) + if (t) t.inFlight = false + try { + const { metrics, cpuStat } = parseMetricsOutput(out, prevCpuStats.get(streamId)) + if (cpuStat) prevCpuStats.set(streamId, cpuStat) + sendMetrics(streamId, metrics) + } catch (parseErr) { + // Best-effort metrics: malformed remote output (non-Linux, BSD, etc.) + // must not crash or stop the shell stream. + console.error(`[ssh] metrics parse ${streamId}: ${toMessage(parseErr)}`) + } + }) + s.on('error', (e: unknown) => { + const t = metricsTimers.get(streamId) + if (t) t.inFlight = false + console.error(`[ssh] metrics exec ${streamId}: ${toMessage(e)}`) + }) + }) +} + function disposeStream(streamId: string): void { stopMetrics(streamId) const entry = streams.get(streamId) @@ -162,6 +197,42 @@ export function disposeSshStreamsForSender(senderId: number): void { } } +// Opens the interactive shell once the client is ready and wires its stream +// events through to the renderer. `settle` guards against double-settling the +// connect promise when 'error' fires after 'ready'. +function openShell( + client: Client, + streamId: string, + sender: WebContents, + upstream: ManagedSshConnection | undefined, + settle: (fn: () => void) => void, + resolve: (id: string) => void, + reject: (err: Error) => void, +): void { + client.setNoDelay(true) + client.shell({ term: 'xterm-256color' }, (err, stream) => { + if (err) { + client.end() + upstream?.dispose() + settle(() => reject(new ConnectionError(toMessage(err)))) + return + } + streams.set(streamId, { client, stream, sender, upstream }) + + stream.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) + stream.stderr.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) + + stream.on('close', () => { + stopMetrics(streamId) + sendClosed(streamId) + streams.delete(streamId) + upstream?.dispose() + }) + + settle(() => resolve(streamId)) + }) +} + export function registerSshHandlers(): void { ipcMain.handle( 'ssh:connect', @@ -186,37 +257,18 @@ export function registerSshHandlers(): void { const client = new Client() const streamId = randomUUID() let settled = false - const settle = (fn: () => void) => { if (settled) return; settled = true; fn() } + const settle = (fn: () => void) => { + if (settled) return + settled = true + fn() + } client.on('keyboard-interactive', (_name, _instructions, _instructionsLang, prompts, finish) => { if (!config.password) { finish([]); return } finish(prompts.map(() => config.password ?? '')) }) - client.on('ready', () => { - client.setNoDelay(true) - client.shell({ term: 'xterm-256color' }, (err, stream) => { - if (err) { - client.end() - upstream?.dispose() - settle(() => reject(new ConnectionError(toMessage(err)))) - return - } - streams.set(streamId, { client, stream, sender: event.sender, upstream }) - - stream.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) - stream.stderr.on('data', (data: Buffer) => sendData(streamId, data.toString('utf8'))) - - stream.on('close', () => { - stopMetrics(streamId) - sendClosed(streamId) - streams.delete(streamId) - upstream?.dispose() - }) - - settle(() => resolve(streamId)) - }) - }) + client.on('ready', () => openShell(client, streamId, event.sender, upstream, settle, resolve, reject)) client.on('error', (err) => { if (!settled) { @@ -290,41 +342,6 @@ export function registerSshHandlers(): void { // ── Metrics ──────────────────────────────────────────────────────────────── - function fetchAndEmit(streamId: string): void { - const live = streams.get(streamId) - if (!live) { stopMetrics(streamId); return } - const timers = metricsTimers.get(streamId) - if (!timers || timers.inFlight) return - timers.inFlight = true - - live.client.exec(METRICS_COMMAND, (err, s) => { - if (err) { timers.inFlight = false; return } - let out = '' - s.on('data', (d: Buffer) => { out += d.toString() }) - // stderr is intentionally drained to keep the channel from buffering; - // remote `cat`/`grep` may report 'no such file' on non-Linux hosts. - s.stderr.on('data', () => {}) - s.on('close', () => { - const t = metricsTimers.get(streamId) - if (t) t.inFlight = false - try { - const { metrics, cpuStat } = parseMetricsOutput(out, prevCpuStats.get(streamId)) - if (cpuStat) prevCpuStats.set(streamId, cpuStat) - sendMetrics(streamId, metrics) - } catch (parseErr) { - // Best-effort metrics: malformed remote output (non-Linux, BSD, etc.) - // must not crash or stop the shell stream. - console.error(`[ssh] metrics parse ${streamId}: ${toMessage(parseErr)}`) - } - }) - s.on('error', (e: unknown) => { - const t = metricsTimers.get(streamId) - if (t) t.inFlight = false - console.error(`[ssh] metrics exec ${streamId}: ${toMessage(e)}`) - }) - }) - } - ipcMain.handle('ssh:metrics-start', (event: IpcMainInvokeEvent, rawStreamId: unknown) => { const streamId = validateStreamId(rawStreamId) const entry = getOwnedStream(event, streamId) diff --git a/src/main/ipc/sshClients.ts b/src/main/ipc/sshClients.ts index bd2cb69..041fee6 100644 --- a/src/main/ipc/sshClients.ts +++ b/src/main/ipc/sshClients.ts @@ -1,5 +1,5 @@ import { Client, Algorithms, ClientChannel, ConnectConfig } from 'ssh2' -import { readFileSync } from 'fs' +import { readFileSync } from 'node:fs' import { getSessionById, Session } from './sessions' import { getCredential, isUnlocked } from './keychain' import { isAllowedKeyPath } from './security' diff --git a/src/main/ipc/sshConfig.ts b/src/main/ipc/sshConfig.ts index 784db28..b665f11 100644 --- a/src/main/ipc/sshConfig.ts +++ b/src/main/ipc/sshConfig.ts @@ -1,7 +1,7 @@ import { ipcMain } from 'electron' -import { homedir } from 'os' -import { join } from 'path' -import { readFile } from 'fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { readFile } from 'node:fs/promises' export interface SshConfigHost { alias: string @@ -31,7 +31,7 @@ function unquote(value: string): string { // Splits a config line into its keyword and argument. ssh_config accepts both // "Key Value" and "Key=Value" forms. function splitDirective(line: string): { key: string; value: string } | null { - const match = line.match(/^(\S+?)(?:\s+|\s*=\s*)(.+)$/) + const match = /^([^\s=]+)(?:\s*=\s*|\s+)(.+)$/.exec(line) if (!match) return null return { key: match[1].toLowerCase(), value: match[2].trim() } } @@ -49,6 +49,41 @@ function finalizeBlock(block: HostBlock, out: SshConfigHost[]): void { } } +// Wildcard and negated Host patterns describe defaults, not servers. +function parseHostAliases(value: string): string[] { + return value + .split(/\s+/) + .map(unquote) + .filter(pattern => pattern && !/[*?!]/.test(pattern)) +} + +function applyDirective(block: HostBlock, key: string, value: string): void { + switch (key) { + case 'hostname': + block.hostname = unquote(value) + break + case 'port': { + const port = Number(unquote(value)) + if (Number.isInteger(port) && port >= 1 && port <= 65535) block.port = port + break + } + case 'user': + block.username = unquote(value) + break + case 'identityfile': + // ssh tries identity files in order; the first listed is the primary + block.keyPath ??= unquote(value) + break + case 'proxyjump': { + // Multi-hop chains (a,b,c) reduce to the first hop here; deeper + // chains resolve recursively if that hop is itself imported. + const firstHop = unquote(value).split(',')[0]?.trim() + if (firstHop && firstHop.toLowerCase() !== 'none') block.proxyJump = firstHop + break + } + } +} + /** * Extracts concrete host entries from ssh_config content. Wildcard and * negated Host patterns are skipped (they describe defaults, not servers), @@ -70,11 +105,7 @@ export function parseSshConfig(content: string): SshConfigHost[] { if (key === 'host') { if (block) finalizeBlock(block, hosts) inMatchBlock = false - const aliases = value - .split(/\s+/) - .map(unquote) - .filter(pattern => pattern && !/[*?!]/.test(pattern)) - block = { aliases } + block = { aliases: parseHostAliases(value) } continue } @@ -85,32 +116,7 @@ export function parseSshConfig(content: string): SshConfigHost[] { continue } - if (inMatchBlock || !block) continue - - switch (key) { - case 'hostname': - block.hostname = unquote(value) - break - case 'port': { - const port = Number(unquote(value)) - if (Number.isInteger(port) && port >= 1 && port <= 65535) block.port = port - break - } - case 'user': - block.username = unquote(value) - break - case 'identityfile': - // ssh tries identity files in order; the first listed is the primary - block.keyPath ??= unquote(value) - break - case 'proxyjump': { - // Multi-hop chains (a,b,c) reduce to the first hop here; deeper - // chains resolve recursively if that hop is itself imported. - const firstHop = unquote(value).split(',')[0]?.trim() - if (firstHop && firstHop.toLowerCase() !== 'none') block.proxyJump = firstHop - break - } - } + if (!inMatchBlock && block) applyDirective(block, key, value) } if (block) finalizeBlock(block, hosts) diff --git a/src/main/ipc/tunnels.ts b/src/main/ipc/tunnels.ts index 847e0b1..b7a22e3 100644 --- a/src/main/ipc/tunnels.ts +++ b/src/main/ipc/tunnels.ts @@ -1,8 +1,8 @@ import { ipcMain, BrowserWindow } from 'electron' import Store from 'electron-store' -import { createServer, connect as netConnect, Server, Socket } from 'net' -import { Duplex } from 'stream' -import { randomUUID } from 'crypto' +import { createServer, connect as netConnect, Server, Socket } from 'node:net' +import { Duplex } from 'node:stream' +import { randomUUID } from 'node:crypto' import { connectSessionClient, ManagedSshConnection } from './sshClients' import { getSessionById } from './sessions' import { NotFoundError, ValidationError, toMessage } from './errors' @@ -136,30 +136,43 @@ async function startLocalForward(def: TunnelDef, entry: ActiveTunnel): Promise { + timedOut = true + socket.end(socksConnectReply(SOCKS_REPLY.connectionRefused)) + }, 30_000) + entry.conn.client.forwardOut( + socket.remoteAddress ?? '127.0.0.1', + socket.remotePort ?? 0, + parsed.host, + parsed.port, + (err, stream) => { + clearTimeout(timer) + if (timedOut) { stream?.destroy(); return } + if (err) { socket.end(socksConnectReply(SOCKS_REPLY.connectionRefused)); return } + socket.write(socksConnectReply(SOCKS_REPLY.success)) + pipeBoth(socket, stream) + }, + ) +} + async function startDynamicForward(def: TunnelDef, entry: ActiveTunnel): Promise { const server = createServer((socket) => { trackSocket(entry, socket) socket.once('data', (greeting: Buffer) => { if (!isSocksGreeting(greeting)) { socket.destroy(); return } socket.write(socksGreetingReply()) - socket.once('data', (request: Buffer) => { - const parsed = parseSocksConnectRequest(request) - if ('errorCode' in parsed) { - socket.end(socksConnectReply(parsed.errorCode)) - return - } - entry.conn.client.forwardOut( - socket.remoteAddress ?? '127.0.0.1', - socket.remotePort ?? 0, - parsed.host, - parsed.port, - (err, stream) => { - if (err) { socket.end(socksConnectReply(SOCKS_REPLY.connectionRefused)); return } - socket.write(socksConnectReply(SOCKS_REPLY.success)) - pipeBoth(socket, stream) - }, - ) - }) + socket.once('data', (request: Buffer) => handleSocksConnect(entry, socket, request)) }) socket.on('error', () => socket.destroy()) }) @@ -237,7 +250,14 @@ export function listTunnels(): Array Promise disconnect: (id: string) => Promise - query: (id: string, sql: string) => Promise<{ columns: string[]; rows: any[]; rowCount: number; duration: number }> + query: (id: string, sql: string, params?: (string | number | boolean | null)[]) => Promise<{ columns: string[]; rows: any[]; rowCount: number; duration: number }> tables: (id: string) => Promise tableInfo: (id: string, table: string) => Promise<{ columns: { name: string; type: string; nullable: boolean }[] }> } diff --git a/src/preload/index.ts b/src/preload/index.ts index b1fbd83..0bf2c8d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -100,7 +100,8 @@ contextBridge.exposeInMainWorld('api', { connect: (config: { dbType: string; host: string; port: number; username: string; password?: string; database: string; ssl?: string }) => ipcRenderer.invoke('db:connect', config), disconnect: (id: string) => ipcRenderer.invoke('db:disconnect', id), - query: (id: string, sql: string) => ipcRenderer.invoke('db:query', id, sql), + query: (id: string, sql: string, params?: (string | number | boolean | null)[]) => + ipcRenderer.invoke('db:query', id, sql, params), tables: (id: string) => ipcRenderer.invoke('db:tables', id), tableInfo: (id: string, table: string) => ipcRenderer.invoke('db:tableInfo', id, table), }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 098cbec..78823ac 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -107,7 +107,7 @@ export default function App() { // (close tab) are owned by the application menu — see src/main/menu.ts. if (meta && /^[1-9]$/.test(e.key)) { e.preventDefault() - const tab = visibleTabs[parseInt(e.key) - 1] + const tab = visibleTabs[Number.parseInt(e.key) - 1] if (tab) setActiveTab(tab.id) } } diff --git a/src/renderer/src/__tests__/harness.tsx b/src/renderer/src/__tests__/harness.tsx new file mode 100644 index 0000000..11578a1 --- /dev/null +++ b/src/renderer/src/__tests__/harness.tsx @@ -0,0 +1,234 @@ +/** + * Shared harness for renderer component tests (jsdom). + * + * Usage — test files must opt into jsdom since the global vitest env is node: + * // @vitest-environment jsdom + * import { installWindowApi, seedStore, makeSession, makeTab } from '../../__tests__/harness' + * + * installWindowApi() builds a full vi.fn() mock of the preload bridge with + * benign defaults; pass overrides for the calls a test cares about, and use + * the returned object to assert on calls or swap resolved values. + */ +import { vi } from 'vitest' +import type { Session, Tab } from '../store' +import { useAppStore } from '../store' + +const unsub = () => () => {} + +export function buildWindowApi() { + return { + platform: 'darwin', + sessions: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue({ id: 's1' }), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + getCredentials: vi.fn().mockResolvedValue({ password: 'pw' }), + count: vi.fn().mockResolvedValue(0), + clearAll: vi.fn().mockResolvedValue(undefined), + export: vi.fn().mockResolvedValue({ ok: true }), + import: vi.fn().mockResolvedValue({ ok: true }), + }, + sshConfig: { hosts: vi.fn().mockResolvedValue([]) }, + tunnels: { + list: vi.fn().mockResolvedValue([]), + save: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + onChanged: vi.fn().mockImplementation(unsub), + }, + auth: { + getMode: vi.fn().mockResolvedValue('none'), + isAvailable: vi.fn().mockResolvedValue(true), + isUnlocked: vi.fn().mockResolvedValue(true), + unlock: vi.fn().mockResolvedValue(true), + lock: vi.fn().mockResolvedValue(undefined), + setup: vi.fn().mockResolvedValue(true), + onLocked: vi.fn().mockImplementation(unsub), + }, + ssh: { + connect: vi.fn().mockResolvedValue('stream-1'), + send: vi.fn(), + resize: vi.fn(), + disconnect: vi.fn().mockResolvedValue(undefined), + onData: vi.fn().mockImplementation(unsub), + onClose: vi.fn().mockImplementation(unsub), + startMetrics: vi.fn().mockResolvedValue(undefined), + stopMetrics: vi.fn(), + onMetrics: vi.fn().mockImplementation(unsub), + }, + sftp: { + connect: vi.fn().mockResolvedValue('sftp-1'), + list: vi.fn().mockResolvedValue([]), + readFile: vi.fn().mockResolvedValue(''), + writeFile: vi.fn().mockResolvedValue(undefined), + download: vi.fn().mockResolvedValue(undefined), + upload: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined), + stat: vi.fn().mockResolvedValue({ size: 0, mtime: 0 }), + disconnect: vi.fn().mockResolvedValue(undefined), + }, + database: { + connect: vi.fn().mockResolvedValue('db-1'), + disconnect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue({ columns: [], rows: [], rowCount: 0, duration: 1 }), + tables: vi.fn().mockResolvedValue([]), + tableInfo: vi.fn().mockResolvedValue([]), + }, + localfs: { + home: vi.fn().mockResolvedValue('/home/user'), + list: vi.fn().mockResolvedValue([]), + readTextFile: vi.fn().mockResolvedValue(''), + writeTextFile: vi.fn().mockResolvedValue(undefined), + }, + fs: { readFile: vi.fn().mockResolvedValue('') }, + k8s: { + contexts: vi.fn().mockResolvedValue([]), + contextsDetailed: vi.fn().mockResolvedValue([]), + importKubeconfig: vi.fn().mockResolvedValue([]), + showFilePicker: vi.fn().mockResolvedValue(null), + namespaces: vi.fn().mockResolvedValue(['default']), + pods: vi.fn().mockResolvedValue([]), + deletePod: vi.fn().mockResolvedValue(undefined), + deployments: vi.fn().mockResolvedValue([]), + scaleDeployment: vi.fn().mockResolvedValue(undefined), + restartDeployment: vi.fn().mockResolvedValue(undefined), + statefulsets: vi.fn().mockResolvedValue([]), + daemonsets: vi.fn().mockResolvedValue([]), + replicasets: vi.fn().mockResolvedValue([]), + jobs: vi.fn().mockResolvedValue([]), + cronjobs: vi.fn().mockResolvedValue([]), + services: vi.fn().mockResolvedValue([]), + ingresses: vi.fn().mockResolvedValue([]), + configmaps: vi.fn().mockResolvedValue([]), + secrets: vi.fn().mockResolvedValue([]), + secretDetail: vi.fn().mockResolvedValue({}), + nodes: vi.fn().mockResolvedValue([]), + events: vi.fn().mockResolvedValue([]), + resourceDetail: vi.fn().mockResolvedValue('{}'), + logsGet: vi.fn().mockResolvedValue(''), + logsStream: vi.fn().mockResolvedValue('log-1'), + logsStop: vi.fn().mockResolvedValue(undefined), + onLogChunk: vi.fn().mockImplementation(unsub), + onLogEnd: vi.fn().mockImplementation(unsub), + execStart: vi.fn().mockResolvedValue('exec-1'), + execSend: vi.fn(), + execResize: vi.fn(), + execStop: vi.fn().mockResolvedValue(undefined), + onExecData: vi.fn().mockImplementation(unsub), + onExecClose: vi.fn().mockImplementation(unsub), + portForwardStart: vi.fn().mockResolvedValue({ id: 'pf-1' }), + servicePortForwardStart: vi.fn().mockResolvedValue({ id: 'pf-2' }), + portForwardStop: vi.fn().mockResolvedValue(undefined), + portForwardList: vi.fn().mockResolvedValue([]), + }, + localpty: { + start: vi.fn().mockResolvedValue('pty-1'), + write: vi.fn(), + resize: vi.fn(), + kill: vi.fn().mockResolvedValue(undefined), + onData: vi.fn().mockImplementation(unsub), + onExit: vi.fn().mockImplementation(unsub), + }, + runner: { + run: vi.fn().mockResolvedValue('run-1'), + cancel: vi.fn().mockResolvedValue(undefined), + onOutput: vi.fn().mockImplementation(unsub), + onDone: vi.fn().mockImplementation(unsub), + }, + docker: { + connect: vi.fn().mockResolvedValue('docker-1'), + disconnect: vi.fn().mockResolvedValue(undefined), + containers: vi.fn().mockResolvedValue([]), + stats: vi.fn().mockResolvedValue([]), + images: vi.fn().mockResolvedValue([]), + info: vi.fn().mockResolvedValue({}), + action: vi.fn().mockResolvedValue(undefined), + logsStart: vi.fn().mockResolvedValue('dlog-1'), + logsStop: vi.fn().mockResolvedValue(undefined), + onLogChunk: vi.fn().mockImplementation(unsub), + onLogEnd: vi.fn().mockImplementation(unsub), + }, + settings: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + reset: vi.fn().mockResolvedValue(undefined), + }, + redis: { + connect: vi.fn().mockResolvedValue('redis-1'), + disconnect: vi.fn().mockResolvedValue(undefined), + info: vi.fn().mockResolvedValue({}), + keys: vi.fn().mockResolvedValue([]), + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue(undefined), + del: vi.fn().mockResolvedValue(undefined), + command: vi.fn().mockResolvedValue(''), + }, + rdp: { + connect: vi.fn().mockResolvedValue('rdp-1'), + disconnect: vi.fn().mockResolvedValue(undefined), + onFrame: vi.fn().mockImplementation(unsub), + onClose: vi.fn().mockImplementation(unsub), + }, + tabs: { onCycle: vi.fn().mockImplementation(unsub) }, + menu: { on: vi.fn().mockImplementation(unsub) }, + updater: { + version: vi.fn().mockResolvedValue('0.0.0-test'), + check: vi.fn().mockResolvedValue(undefined), + download: vi.fn().mockResolvedValue(undefined), + quitAndInstall: vi.fn().mockResolvedValue(undefined), + onStatus: vi.fn().mockImplementation(unsub), + }, + } +} + +export type WindowApiMock = ReturnType + +/** Deep-merge overrides into a fresh api mock and install it on window. */ +export function installWindowApi(overrides: Record = {}): WindowApiMock { + const api = buildWindowApi() as any + for (const [ns, fns] of Object.entries(overrides)) { + api[ns] = typeof fns === 'object' && fns !== null && !Array.isArray(fns) ? { ...api[ns], ...fns } : fns + } + ;(window as any).api = api + return api +} + +let seq = 0 + +export function makeSession(overrides: Partial = {}): Session { + seq += 1 + return { + id: `session-${seq}`, + label: `Server ${seq}`, + host: `host${seq}.example.com`, + port: 22, + username: 'root', + authType: 'password', + createdAt: 1700000000000, + type: 'ssh', + ...overrides, + } +} + +export function makeTab(overrides: Partial = {}): Tab { + seq += 1 + return { + id: `tab-${seq}`, + label: `Tab ${seq}`, + view: 'terminal' as Tab['view'], + status: 'idle', + filesOpen: false, + ...overrides, + } +} + +/** Reset the zustand store and apply partial state for a test. */ +export function seedStore(state: Partial>) { + useAppStore.setState(state as any) +} diff --git a/src/renderer/src/components/CommandPalette/CommandPalette.tsx b/src/renderer/src/components/CommandPalette/CommandPalette.tsx index 6e32440..9f57bb8 100644 --- a/src/renderer/src/components/CommandPalette/CommandPalette.tsx +++ b/src/renderer/src/components/CommandPalette/CommandPalette.tsx @@ -193,12 +193,10 @@ export default function CommandPalette({ onClose }: Props) { return (
{ if (e.target === e.currentTarget) onClose() }} + onKeyDown={(e) => { if (e.key === 'Escape') onClose() }} > -
e.stopPropagation()} - > +
{/* Search input */}
@@ -328,7 +326,7 @@ export default function CommandPalette({ onClose }: Props) { ) } -function SectionLabel({ children }: { children: React.ReactNode }) { +function SectionLabel({ children }: Readonly<{ children: React.ReactNode }>) { return (
@@ -441,7 +439,7 @@ const CommandRow = forwardRef< )) -function Pip({ connected, connecting }: { connected: boolean; connecting: boolean }) { +function Pip({ connected, connecting }: Readonly<{ connected: boolean; connecting: boolean }>) { if (connecting) { return ( @@ -456,7 +454,7 @@ function Pip({ connected, connecting }: { connected: boolean; connecting: boolea return } -function GroupChip({ name }: { name: string }) { +function GroupChip({ name }: Readonly<{ name: string }>) { const groupColors = useAppStore(s => s.groupColors) const color = groupColor(name, groupColors) return ( @@ -476,12 +474,12 @@ function GroupChip({ name }: { name: string }) { function HintKey({ icon, secondIcon, text, label, -}: { +}: Readonly<{ icon?: React.ReactNode secondIcon?: React.ReactNode text?: string label: string -}) { +}>) { return (
diff --git a/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx b/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx index 766ffb5..73ca8dd 100644 --- a/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx +++ b/src/renderer/src/components/ConnectionManager/AddConnectionModal.tsx @@ -10,7 +10,7 @@ import { ipcErrorMessage } from '../../lib/format' interface K8sContextEntry { name: string server: string - source: 'default' | string // 'default' or file path + source: string // 'default' or a kubeconfig file path } type ConnectionType = 'ssh' | 'sftp' | 'database' | 'kubernetes' | 'redis' | 'rdp' @@ -138,7 +138,7 @@ export default function AddConnectionModal({ onClose }: Props) { switch (type) { case 'ssh': return '22' case 'sftp': return '22' - case 'database': return form.dbType === 'postgresql' ? '5432' : form.dbType === 'mysql' ? '3306' : '5432' + case 'database': return form.dbType === 'mysql' || form.dbType === 'mariadb' ? '3306' : '5432' case 'redis': return '6379' case 'rdp': return '3389' default: return '443' @@ -193,12 +193,35 @@ export default function AddConnectionModal({ onClose }: Props) { const parsedPort = (): number => { const raw = form.port.trim() - if (!raw) return parseInt(getDefaultPort(selectedType)) - return parseInt(raw) + if (!raw) return Number.parseInt(getDefaultPort(selectedType)) + return Number.parseInt(raw) } // One source of truth for "is this form submittable" — Test and Save must // never disagree about what a valid connection looks like. + const validateTypeFields = (): string | null => { + switch (selectedType) { + case 'ssh': + case 'sftp': + if (!form.username.trim()) return 'Username is required' + if (form.authType === 'key' && !form.keyPath.trim()) return 'Private key path is required' + return null + case 'database': + if (!form.username.trim()) return 'Username is required' + if (!form.databaseName.trim()) return 'Database name is required' + return null + case 'redis': { + const db = Number.parseInt(form.redisDb) + if (!Number.isInteger(db) || db < 0 || db > 15) return 'DB index must be 0–15' + return null + } + case 'rdp': + return form.username.trim() ? null : 'Username is required' + default: + return null + } + } + const validateConfigForm = (): string | null => { if (selectedType === 'kubernetes') { return k8sSelected ? null : 'Select a context to continue' @@ -206,22 +229,7 @@ export default function AddConnectionModal({ onClose }: Props) { if (!form.host.trim()) return 'Host is required' const port = parsedPort() if (!Number.isInteger(port) || port < 1 || port > 65535) return 'Port must be between 1 and 65535' - if (selectedType === 'ssh' || selectedType === 'sftp') { - if (!form.username.trim()) return 'Username is required' - if (form.authType === 'key' && !form.keyPath.trim()) return 'Private key path is required' - } - if (selectedType === 'database') { - if (!form.username.trim()) return 'Username is required' - if (!form.databaseName.trim()) return 'Database name is required' - } - if (selectedType === 'redis') { - const db = parseInt(form.redisDb) - if (!Number.isInteger(db) || db < 0 || db > 15) return 'DB index must be 0–15' - } - if (selectedType === 'rdp') { - if (!form.username.trim()) return 'Username is required' - } - return null + return validateTypeFields() } // When editing without retyping the password, fall back to the stored one @@ -279,7 +287,7 @@ export default function AddConnectionModal({ onClose }: Props) { await window.api.sftp.disconnect(clientId) } } else if (selectedType === 'redis') { - const id = await window.api.redis.connect({ host, port, password, db: parseInt(form.redisDb) }) + const id = await window.api.redis.connect({ host, port, password, db: Number.parseInt(form.redisDb) }) await window.api.redis.disconnect(id) } else if (selectedType === 'database') { const id = await window.api.database.connect({ @@ -303,6 +311,57 @@ export default function AddConnectionModal({ onClose }: Props) { } } + const parsedTags = (): string[] => + form.tags ? form.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : [] + + const k8sSessionData = (): any => ({ + type: 'kubernetes', + label: form.label.trim() || k8sSelected!.name, + host: k8sSelected!.server || k8sSelected!.name, + port: 0, + username: '', + authType: 'password', + color: form.color, + tags: parsedTags(), + contextName: k8sSelected!.name, + kubeconfigPath: k8sSelected!.source !== 'default' ? k8sSelected!.source : undefined, + }) + + const sessionData = (): any => { + const includePassword = !editingConnectionId || passwordDirty + return { + type: selectedType, + label: form.label.trim() || undefined, + host: form.host.trim(), + port: parsedPort(), + username: form.username.trim() || undefined, + authType: form.authType, + password: includePassword && form.authType === 'password' ? form.password : undefined, + keyPath: form.authType === 'key' ? form.keyPath.trim() : undefined, + jumpHostId: (selectedType === 'ssh' || selectedType === 'sftp') && form.jumpHostId ? form.jumpHostId : undefined, + group: form.group || undefined, + color: form.color, + tags: parsedTags(), + pollingEnabled: selectedType === 'ssh' ? form.pollingEnabled : undefined, + pollingIntervalSeconds: selectedType === 'ssh' ? Number.parseInt(form.pollingIntervalSeconds) : undefined, + connectOnStart: selectedType === 'ssh' ? form.connectOnStart : undefined, + dbType: selectedType === 'database' ? form.dbType : undefined, + databaseName: selectedType === 'database' ? form.databaseName : undefined, + sslMode: selectedType === 'database' ? form.sslMode : undefined, + redisDb: selectedType === 'redis' ? Number.parseInt(form.redisDb) : undefined, + } + } + + const persistSession = async (data: any) => { + if (editingConnectionId) { + const updated = await window.api.sessions.update(editingConnectionId, data) + useAppStore.getState().updateSession(editingConnectionId, updated) + } else { + const session = await window.api.sessions.create(data) + addSession(session) + } + } + const handleSave = async (e: React.FormEvent) => { e.preventDefault() @@ -312,70 +371,9 @@ export default function AddConnectionModal({ onClose }: Props) { return setError(invalid) } - if (selectedType === 'kubernetes') { - if (!k8sSelected) return setError('Select a context to continue') - setSaving(true) - try { - const data: any = { - type: 'kubernetes', - label: form.label.trim() || k8sSelected.name, - host: k8sSelected.server || k8sSelected.name, - port: 0, - username: '', - authType: 'password', - color: form.color, - tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [], - contextName: k8sSelected.name, - kubeconfigPath: k8sSelected.source !== 'default' ? k8sSelected.source : undefined, - } - if (editingConnectionId) { - const updated = await window.api.sessions.update(editingConnectionId, data) - useAppStore.getState().updateSession(editingConnectionId, updated) - } else { - const session = await window.api.sessions.create(data) - addSession(session) - } - handleClose() - } catch (err: any) { - setError(ipcErrorMessage(err, 'Failed to save')) - } finally { - setSaving(false) - } - return - } - setSaving(true) try { - const includePassword = !editingConnectionId || passwordDirty - const data: any = { - type: selectedType, - label: form.label.trim() || undefined, - host: form.host.trim(), - port: parsedPort(), - username: form.username.trim() || undefined, - authType: form.authType, - password: includePassword && form.authType === 'password' ? form.password : undefined, - keyPath: form.authType === 'key' ? form.keyPath.trim() : undefined, - jumpHostId: (selectedType === 'ssh' || selectedType === 'sftp') && form.jumpHostId ? form.jumpHostId : undefined, - group: form.group || undefined, - color: form.color, - tags: form.tags ? form.tags.split(',').map(t => t.trim()).filter(Boolean) : [], - pollingEnabled: selectedType === 'ssh' ? form.pollingEnabled : undefined, - pollingIntervalSeconds: selectedType === 'ssh' ? parseInt(form.pollingIntervalSeconds) : undefined, - connectOnStart: selectedType === 'ssh' ? form.connectOnStart : undefined, - dbType: selectedType === 'database' ? form.dbType : undefined, - databaseName: selectedType === 'database' ? form.databaseName : undefined, - sslMode: selectedType === 'database' ? form.sslMode : undefined, - redisDb: selectedType === 'redis' ? parseInt(form.redisDb) : undefined, - } - - if (editingConnectionId) { - const updated = await window.api.sessions.update(editingConnectionId, data) - useAppStore.getState().updateSession(editingConnectionId, updated) - } else { - const session = await window.api.sessions.create(data) - addSession(session) - } + await persistSession(selectedType === 'kubernetes' ? k8sSessionData() : sessionData()) handleClose() } catch (err: any) { setTestResult(null) @@ -389,7 +387,8 @@ export default function AddConnectionModal({ onClose }: Props) {
{ if (e.target === e.currentTarget) handleClose() }} + onKeyDown={e => { if (e.key === 'Escape') handleClose() }} >
e.stopPropagation()} > {/* Modal header */}
@@ -439,7 +437,7 @@ export default function AddConnectionModal({ onClose }: Props) {
@@ -543,7 +541,7 @@ export default function AddConnectionModal({ onClose }: Props) { onMouseLeave={e => { e.currentTarget.style.background = '#3B5CCC' }} > - {saving ? 'Saving…' : editingConnectionId ? 'Save Changes' : 'Save Connection'} + {saveButtonLabel(saving, !!editingConnectionId)}
@@ -555,10 +553,10 @@ export default function AddConnectionModal({ onClose }: Props) { } /* ── Type selector ───────────────────────────────────────────────────────── */ -function TypeSelector({ selected, onSelect }: { +function TypeSelector({ selected, onSelect }: Readonly<{ selected: ConnectionType onSelect: (t: ConnectionType) => void -}) { +}>) { // RDP needs the bundled FreeRDP sidecar, which currently ships on macOS only. // Hide the type elsewhere so we never offer a connection that can't run. const options = TYPE_OPTIONS.filter(o => o.type !== 'rdp' || window.api.platform === 'darwin') @@ -598,176 +596,368 @@ function TypeSelector({ selected, onSelect }: { } /* ── Config form ─────────────────────────────────────────────────────────── */ -function ConfigForm({ type, form, set, error, testResult, isEditing, hasExistingPassword, k8sContexts, k8sLoading, k8sSelected, onK8sSelect, onK8sImport, onK8sRefresh }: { - type: ConnectionType +function saveButtonLabel(saving: boolean, editing: boolean): string { + if (saving) return 'Saving…' + return editing ? 'Save Changes' : 'Save Connection' +} + +function storedPasswordPlaceholder(isEditing: boolean, hasExistingPassword: boolean, fallback: string): string { + return isEditing && hasExistingPassword ? '•••••••• (leave blank to keep)' : fallback +} + +function PasswordInput({ form, set, placeholder }: Readonly<{ form: any; set: (f: string, v: any) => void; placeholder: string }>) { + return ( +
+ set('password', e.target.value)} + /> + +
+ ) +} + +function SshFields({ form, set, isEditing, hasExistingPassword, jumpHostCandidates }: Readonly<{ form: any set: (f: string, v: any) => void - error: string - testResult: 'success' | 'error' | null isEditing: boolean hasExistingPassword: boolean + jumpHostCandidates: { id: string; label?: string; host: string }[] +}>) { + return ( + <> + + set('username', e.target.value)} + /> + + +
+ +
+ + +
+
+ + {form.authType === 'password' && ( + + + + )} + + {form.authType === 'key' && ( + + set('keyPath', e.target.value)} + /> + + )} + + {jumpHostCandidates.length > 0 && ( + + set('jumpHostId', e.target.value)}> + + {jumpHostCandidates.map(s => ( + + ))} + + + )} + + ) +} + +function DatabaseFields({ form, set, isEditing, hasExistingPassword }: Readonly<{ + form: any + set: (f: string, v: any) => void + isEditing: boolean + hasExistingPassword: boolean +}>) { + return ( + <> + + set('dbType', e.target.value)}> + + + + + + + set('databaseName', e.target.value)} + /> + + + set('username', e.target.value)} + /> + + + set('password', e.target.value)} + /> + + + set('sslMode', e.target.value)}> + + + + + + + + ) +} + +function K8sContextList({ k8sContexts, k8sLoading, k8sSelected, dropActive, onK8sSelect }: Readonly<{ + k8sContexts: K8sContextEntry[] + k8sLoading: boolean + k8sSelected: K8sContextEntry | null + dropActive: boolean + onK8sSelect: (ctx: K8sContextEntry) => void +}>) { + if (k8sLoading && k8sContexts.length === 0) { + return ( +
+ + Reading kubeconfig… +
+ ) + } + if (k8sContexts.length === 0) { + return ( +
+ {dropActive ? ( + <> + +

Drop to import

+ + ) : ( + <> +

No contexts found

+

+ Click "Import file" or drag a kubeconfig here +

+ + )} +
+ ) + } + const defaultContexts = k8sContexts.filter(c => c.source === 'default') + const importedFiles = [...new Set(k8sContexts.filter(c => c.source !== 'default').map(c => c.source))] + return ( + <> + {dropActive && ( +
+ + Drop to import file +
+ )} + {defaultContexts.length > 0 && ( + + )} + {importedFiles.map(filePath => ( + c.source === filePath)} + selected={k8sSelected} + onSelect={onK8sSelect} + /> + ))} + + ) +} + +function K8sContextForm({ form, set, error, k8sContexts, k8sLoading, k8sSelected, onK8sSelect, onK8sImport, onK8sRefresh }: Readonly<{ + form: any + set: (f: string, v: any) => void + error: string k8sContexts: K8sContextEntry[] k8sLoading: boolean k8sSelected: K8sContextEntry | null onK8sSelect: (ctx: K8sContextEntry) => void onK8sImport: (filePath?: string) => void onK8sRefresh: () => void -}) { +}>) { const [dropActive, setDropActive] = useState(false) - const sessions = useAppStore(s => s.sessions) - const editingConnectionId = useAppStore(s => s.editingConnectionId) - const jumpHostCandidates = sessions.filter(s => (s.type ?? 'ssh') === 'ssh' && s.id !== editingConnectionId) - const projectNames = [...new Set(sessions.map(s => s.group).filter(Boolean))] as string[] - if (type === 'kubernetes') { - const defaultContexts = k8sContexts.filter(c => c.source === 'default') - const importedFiles = [...new Set(k8sContexts.filter(c => c.source !== 'default').map(c => c.source))] + return ( +
{ e.preventDefault(); setDropActive(true) }} + onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDropActive(false) }} + onDrop={e => { + e.preventDefault() + setDropActive(false) + const file = e.dataTransfer.files[0] + if (!file) return + const filePath = (file as any).path as string | undefined + if (filePath) onK8sImport(filePath) + }} + style={dropActive ? { outline: '2px dashed #3B5CCC', outlineOffset: -2, borderRadius: 6 } : undefined} + > + {/* Header */} +
+
+

Select a context

+

+ Auto-discovered from ~/.kube/config +

+
+
+ + +
+
- return ( + {/* Context list / drop zone */}
{ e.preventDefault(); setDropActive(true) }} - onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDropActive(false) }} - onDrop={async e => { - e.preventDefault() - setDropActive(false) - const file = e.dataTransfer.files[0] - if (!file) return - const filePath = (file as any).path as string | undefined - if (filePath) onK8sImport(filePath) - }} - style={dropActive ? { outline: '2px dashed #3B5CCC', outlineOffset: -2, borderRadius: 6 } : undefined} + className="rounded-md overflow-hidden" + style={{ border: dropActive ? '1px dashed #3B5CCC' : '1px solid var(--nox-border)', maxHeight: 280, overflowY: 'auto' }} > - {/* Header */} -
-
-

Select a context

-

- Auto-discovered from ~/.kube/config -

-
-
- - -
+ +
+ + {error && ( +
+ +

{error}

+ )} - {/* Context list / drop zone */} -
- {k8sLoading && k8sContexts.length === 0 ? ( -
- - Reading kubeconfig… -
- ) : k8sContexts.length === 0 ? ( -
- {dropActive ? ( - <> - -

Drop to import

- - ) : ( - <> -

No contexts found

-

- Click "Import file" or drag a kubeconfig here -

- - )} -
- ) : ( - <> - {dropActive && ( -
- - Drop to import file -
- )} - {defaultContexts.length > 0 && ( - - )} - {importedFiles.map(filePath => ( - c.source === filePath)} - selected={k8sSelected} - onSelect={onK8sSelect} + {/* Optional label + color for selected context */} + {k8sSelected && ( +
+ + set('label', e.target.value)} + /> + + +
+ {COLORS.map(c => ( +
+
+ )} +
+ ) +} - {error && ( -
- -

{error}

-
- )} +function ConfigForm({ type, form, set, error, testResult, isEditing, hasExistingPassword, k8sContexts, k8sLoading, k8sSelected, onK8sSelect, onK8sImport, onK8sRefresh }: Readonly<{ + type: ConnectionType + form: any + set: (f: string, v: any) => void + error: string + testResult: 'success' | 'error' | null + isEditing: boolean + hasExistingPassword: boolean + k8sContexts: K8sContextEntry[] + k8sLoading: boolean + k8sSelected: K8sContextEntry | null + onK8sSelect: (ctx: K8sContextEntry) => void + onK8sImport: (filePath?: string) => void + onK8sRefresh: () => void +}>) { + const sessions = useAppStore(s => s.sessions) + const editingConnectionId = useAppStore(s => s.editingConnectionId) + const jumpHostCandidates = sessions.filter(s => (s.type ?? 'ssh') === 'ssh' && s.id !== editingConnectionId) + const projectNames = [...new Set(sessions.map(s => s.group).filter(Boolean))] as string[] - {/* Optional label + color for selected context */} - {k8sSelected && ( -
- - set('label', e.target.value)} - /> - - -
- {COLORS.map(c => ( -
-
-
- )} -
+ if (type === 'kubernetes') { + return ( + ) } @@ -805,129 +995,11 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting {/* Type-specific fields */} {(type === 'ssh' || type === 'sftp') && ( - <> - - set('username', e.target.value)} - /> - - -
- -
- - -
-
- - {form.authType === 'password' && ( - -
- set('password', e.target.value)} - /> - -
-
- )} - - {form.authType === 'key' && ( - - set('keyPath', e.target.value)} - /> - - )} - - {jumpHostCandidates.length > 0 && ( - - set('jumpHostId', e.target.value)}> - - {jumpHostCandidates.map(s => ( - - ))} - - - )} - + )} {type === 'database' && ( - <> - - set('dbType', e.target.value)}> - - - - - - - set('databaseName', e.target.value)} - /> - - - set('username', e.target.value)} - /> - - - set('password', e.target.value)} - /> - - - set('sslMode', e.target.value)}> - - - - - - - + )} {type === 'rdp' && ( @@ -940,24 +1012,7 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting /> -
- set('password', e.target.value)} - /> - -
+
)} @@ -967,7 +1022,7 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting set('password', e.target.value)} /> @@ -1077,13 +1132,13 @@ function ConfigForm({ type, form, set, error, testResult, isEditing, hasExisting ) } -function ContextGroup({ label, labelTitle, contexts, selected, onSelect }: { +function ContextGroup({ label, labelTitle, contexts, selected, onSelect }: Readonly<{ label: string labelTitle?: string contexts: K8sContextEntry[] selected: K8sContextEntry | null onSelect: (ctx: K8sContextEntry) => void -}) { +}>) { return (
@@ -1128,13 +1183,15 @@ function ContextGroup({ label, labelTitle, contexts, selected, onSelect }: { ) } -function MiniToggleRow({ on, onToggle, label, description }: { +function MiniToggleRow({ on, onToggle, label, description }: Readonly<{ on: boolean; onToggle: () => void; label: string; description: string -}) { +}>) { return (
-
@@ -1142,7 +1199,7 @@ function MiniToggleRow({ on, onToggle, label, description }: { className="w-3.5 h-3.5 bg-white rounded-full absolute top-[1px] transition-all shadow-sm" style={{ left: on ? 'calc(100% - 14px - 2px)' : 2 }} /> -
+
{label}

{description}

@@ -1151,7 +1208,7 @@ function MiniToggleRow({ on, onToggle, label, description }: { ) } -function FormField({ label, children }: { label: string; children: React.ReactNode }) { +function FormField({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { return (
+ ) } +// Like metricColor, but keeps the session accent below the warning thresholds. +function thresholdColor(value: number, fallback: string): string { + if (value >= 80) return '#EF4444' + if (value >= 60) return '#F59E0B' + return fallback +} + +function MetricsPlaceholder({ isConnected, metricCapable, color }: Readonly<{ isConnected: boolean; metricCapable: boolean; color: string }>) { + if (isConnected && metricCapable) { + return ( +
+ + +
+ ) + } + return +} + +// One-line status for the compact card, in priority order. +function compactStatus(live: boolean | undefined, isConnected: boolean, metricCapable: boolean, cpuMem: string, reachLabel: string): string { + if (live) return cpuMem + if (!isConnected) return reachLabel + return metricCapable ? 'waiting for metrics' : 'Connected' +} + export function CompactServerCard({ session, metrics, isConnected, onConnect, onContextMenu }: ServerViewProps) { const color = session.color ?? '#3B5CCC' const reach = reachabilityInfo(isConnected) @@ -174,11 +195,7 @@ export function CompactServerCard({ session, metrics, isConnected, onConnect, on
- {live - ? `cpu ${metrics!.cpu}% · mem ${memPct}%` - : isConnected - ? (metricCapable ? 'waiting for metrics' : 'Connected') - : reach.label} + {compactStatus(live, isConnected, metricCapable, `cpu ${metrics?.cpu}% · mem ${memPct}%`, reach.label)}
@@ -193,10 +210,11 @@ export function ServerListRow({ session, metrics, isConnected, onConnect, onCont const diskPct = live && metrics?.diskTotal ? Math.round(((metrics.diskUsed ?? 0) / metrics.diskTotal) * 100) : null return ( -
{ e.currentTarget.style.background = 'var(--nox-hover)' }} onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }} @@ -223,26 +241,23 @@ export function ServerListRow({ session, metrics, isConnected, onConnect, onCont
{!isConnected && ( - + )}
-
+ ) } -function StatusBox({ metricCapable, isConnected }: { metricCapable: boolean; isConnected: boolean }) { - const text = isConnected - ? 'Connected — click to open' - : metricCapable - ? 'Connect to start live metrics' - : 'Click to open' +function StatusBox({ metricCapable, isConnected }: Readonly<{ metricCapable: boolean; isConnected: boolean }>) { + let text = 'Click to open' + if (isConnected) text = 'Connected — click to open' + else if (metricCapable) text = 'Connect to start live metrics' return (
) { return ( {value !== null ? ( @@ -270,8 +285,8 @@ function MetricCell({ label, value, pct }: { label: string; value: string | null ) } -function MetricBar({ label, value, color, sublabel }: { label: string; value: number; color: string; sublabel?: string }) { - const barColor = value >= 80 ? '#EF4444' : value >= 60 ? '#F59E0B' : color +function MetricBar({ label, value, color, sublabel }: Readonly<{ label: string; value: number; color: string; sublabel?: string }>) { + const barColor = thresholdColor(value, color) return (
@@ -292,7 +307,7 @@ function MetricBar({ label, value, color, sublabel }: { label: string; value: nu ) } -function ConnectionTypeIcon({ type }: { type?: string }) { +function ConnectionTypeIcon({ type }: Readonly<{ type?: string }>) { const s = { className: 'w-3.5 h-3.5 flex-shrink-0' } switch (type) { case 'database': return diff --git a/src/renderer/src/components/Dashboard/__tests__/Dashboard.test.tsx b/src/renderer/src/components/Dashboard/__tests__/Dashboard.test.tsx new file mode 100644 index 0000000..8d0e31e --- /dev/null +++ b/src/renderer/src/components/Dashboard/__tests__/Dashboard.test.tsx @@ -0,0 +1,83 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import Dashboard from '../Dashboard' +import { installWindowApi, seedStore, makeSession, makeTab } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +describe('Dashboard', () => { + beforeEach(() => { + installWindowApi() + seedStore({ + sessions: [], tabs: [], activeTabId: null, notifications: [], + serverMetrics: {}, projectGroupOrder: [], groupColors: {}, + showAddConnection: false, + }) + }) + + it('shows the empty state and opens the add-connection modal', () => { + render() + expect(screen.getByText('No connections yet')).toBeTruthy() + fireEvent.click(screen.getByText('Add Connection')) + expect(useAppStore.getState().showAddConnection).toBe(true) + }) + + it('sorts unsaved groups alphabetically with Ungrouped last', () => { + seedStore({ + sessions: [ + makeSession({ id: 'z1', label: 'Zed', group: 'Zeta' }), + makeSession({ id: 'u1', label: 'Loose' }), // no group → Ungrouped + makeSession({ id: 'a1', label: 'Ay', group: 'Alpha' }), + ], + }) + const { container } = render() + const text = container.textContent ?? '' + const alpha = text.indexOf('Alpha') + const zeta = text.indexOf('Zeta') + const ungrouped = text.indexOf('Ungrouped') + expect(alpha).toBeGreaterThan(-1) + expect(alpha).toBeLessThan(zeta) + expect(zeta).toBeLessThan(ungrouped) + }) + + it('respects the saved project group order before falling back to sort', () => { + seedStore({ + projectGroupOrder: ['Zeta', 'Alpha'], + sessions: [ + makeSession({ id: 'a1', label: 'Ay', group: 'Alpha' }), + makeSession({ id: 'z1', label: 'Zed', group: 'Zeta' }), + ], + }) + const { container } = render() + const text = container.textContent ?? '' + expect(text.indexOf('Zeta')).toBeLessThan(text.indexOf('Alpha')) + }) + + it('renders average group CPU with the shared metric color', () => { + seedStore({ + sessions: [makeSession({ id: 's1', label: 'Hot Box', group: 'Prod' })], + tabs: [makeTab({ sessionId: 's1', status: 'connected' })], + serverMetrics: { + s1: { cpu: 85, memUsed: 4e9, memTotal: 8e9, available: true, lastUpdated: Date.now() }, + }, + }) + render() + const cpu = screen.getByText('85% CPU') + expect(cpu).toBeTruthy() + // 85% is in the red band + expect((cpu as HTMLElement).style.color).toBe('rgb(239, 68, 68)') + }) + + it('renders a healthy group with green CPU', () => { + seedStore({ + sessions: [makeSession({ id: 's2', label: 'Cool Box', group: 'Prod' })], + tabs: [makeTab({ sessionId: 's2', status: 'connected' })], + serverMetrics: { + s2: { cpu: 12, memUsed: 1e9, memTotal: 8e9, available: true, lastUpdated: Date.now() }, + }, + }) + render() + const cpu = screen.getByText('12% CPU') + expect((cpu as HTMLElement).style.color).toBe('rgb(16, 185, 129)') + }) +}) diff --git a/src/renderer/src/components/Dashboard/__tests__/ServerViews.test.tsx b/src/renderer/src/components/Dashboard/__tests__/ServerViews.test.tsx new file mode 100644 index 0000000..336e80d --- /dev/null +++ b/src/renderer/src/components/Dashboard/__tests__/ServerViews.test.tsx @@ -0,0 +1,254 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { HealthCard, CompactServerCard, ServerListRow, reachabilityInfo } from '../ServerViews' +import { makeSession } from '../../../__tests__/harness' +import type { ServerMetrics } from '../../../store' + +function liveMetrics(overrides: Partial = {}): ServerMetrics { + return { + cpu: 42, + memUsed: 4 * 1024 ** 3, + memTotal: 8 * 1024 ** 3, + diskUsed: 50 * 1024 ** 3, + diskTotal: 100 * 1024 ** 3, + load1: 1.23, + uptimeSec: 86400 * 3, + available: true, + lastUpdated: Date.now(), + cpuHistory: [10, 20, 42], + ...overrides, + } +} + +const noop = () => {} + +const dragProps = { + isDropTarget: false, + onDragStart: noop, + onDragOver: noop, + onDrop: noop, + onDragEnd: noop, +} + +describe('reachabilityInfo', () => { + it('maps connection state to label/color', () => { + expect(reachabilityInfo(true)).toEqual({ label: 'Connected', color: '#10B981' }) + expect(reachabilityInfo(false).label).toBe('Disconnected') + }) +}) + +describe('HealthCard', () => { + it('renders live metrics with disk, load and uptime', () => { + const session = makeSession({ label: 'Prod 1' }) + render( + + ) + expect(screen.getByText('Prod 1')).toBeTruthy() + expect(screen.getByText('Connected')).toBeTruthy() + expect(screen.getByText('CPU')).toBeTruthy() + expect(screen.getByText('MEM')).toBeTruthy() + expect(screen.getByText('DISK')).toBeTruthy() + expect(screen.getByText(/load 1\.23/)).toBeTruthy() + expect(screen.getByText(/^up /)).toBeTruthy() + // Connected cards do not offer a Connect button + expect(screen.queryByText('Connect')).toBeNull() + }) + + it('colors metric bars by warning thresholds', () => { + const session = makeSession() + const { container } = render( + + ) + const bars = Array.from(container.querySelectorAll('.h-full.rounded-full')) as HTMLElement[] + // cpu 85 → red, mem 65% → amber, disk 10% → session accent + expect(bars[0].style.background).toBe('rgb(239, 68, 68)') + expect(bars[1].style.background).toBe('rgb(245, 158, 11)') + expect(bars[2].style.background).toBe('rgb(59, 92, 204)') + }) + + it('fires onConnect when the card is clicked', () => { + const onConnect = vi.fn() + const session = makeSession({ label: 'KeyCard' }) + render( + + ) + const card = screen.getByText('KeyCard').closest('button') as HTMLElement + fireEvent.click(card) + expect(onConnect).toHaveBeenCalledTimes(1) + }) + + it('shows a Connect affordance when disconnected that connects on click', () => { + const onConnect = vi.fn() + render( + + ) + fireEvent.click(screen.getByText('Connect')) + expect(onConnect).toHaveBeenCalledTimes(1) + }) + + it('shows the waiting placeholder when connected without metrics yet', () => { + render( + + ) + expect(screen.getByText('waiting for metrics')).toBeTruthy() + }) + + it('shows connect hint for disconnected metric-capable hosts', () => { + render( + + ) + expect(screen.getByText('Connect to start live metrics')).toBeTruthy() + }) + + it('shows click-to-open status for non-metric hosts', () => { + const { rerender } = render( + + ) + expect(screen.getByText('Click to open')).toBeTruthy() + rerender( + + ) + expect(screen.getByText('Connected — click to open')).toBeTruthy() + }) + + it('handles drag lifecycle and drop-target styling', () => { + const onDragStart = vi.fn() + const onDragOver = vi.fn() + const onDrop = vi.fn() + const onDragEnd = vi.fn() + const session = makeSession({ label: 'DragCard', color: '#AA00FF' }) + render( + + ) + const card = screen.getByText('DragCard').closest('button') as HTMLElement + expect(card.style.border).toContain('dashed') + fireEvent.dragStart(card) + fireEvent.dragOver(card) + fireEvent.drop(card) + fireEvent.dragEnd(card) + expect(onDragStart).toHaveBeenCalledTimes(1) + expect(onDragOver).toHaveBeenCalledTimes(1) + expect(onDrop).toHaveBeenCalledTimes(1) + expect(onDragEnd).toHaveBeenCalledTimes(1) + }) + + it('calls onContextMenu on right-click', () => { + const onContextMenu = vi.fn() + render( + + ) + fireEvent.contextMenu(screen.getByText('CtxCard').closest('button') as HTMLElement) + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) +}) + +describe('CompactServerCard', () => { + it('shows cpu/mem summary when live', () => { + render( + + ) + expect(screen.getByText('cpu 12% · mem 25%')).toBeTruthy() + }) + + it('shows Disconnected when offline', () => { + render( + + ) + expect(screen.getByText('Disconnected')).toBeTruthy() + }) + + it('shows waiting for metrics when connected without data, and Connected for non-metric types', () => { + const { rerender } = render( + + ) + expect(screen.getByText('waiting for metrics')).toBeTruthy() + rerender( + + ) + expect(screen.getByText('Connected')).toBeTruthy() + }) + + it('fires onConnect on click', () => { + const onConnect = vi.fn() + render() + fireEvent.click(screen.getByRole('button')) + expect(onConnect).toHaveBeenCalledTimes(1) + }) +}) + +describe('ServerListRow', () => { + it('renders live metric cells, uptime and reachability', () => { + const session = makeSession({ label: 'RowHost', username: 'admin', host: 'row.example.com' }) + render( + + ) + expect(screen.getByText('RowHost')).toBeTruthy() + expect(screen.getByText(/admin@/)).toBeTruthy() + expect(screen.getByText('Connected')).toBeTruthy() + expect(screen.getByText('55%')).toBeTruthy() + expect(screen.getByText('50%')).toBeTruthy() // disk pct + expect(screen.getByText(/^up /)).toBeTruthy() + }) + + it('renders em-dash placeholders when metrics are missing', () => { + render( + + ) + expect(screen.getAllByText('—')).toHaveLength(3) + expect(screen.getByText('Disconnected')).toBeTruthy() + }) + + it('fires onConnect when the row is clicked', () => { + const onConnect = vi.fn() + const session = makeSession({ label: 'RowKeys' }) + render() + const row = screen.getByText('RowKeys').closest('button') as HTMLElement + fireEvent.click(row) + expect(onConnect).toHaveBeenCalledTimes(1) + }) + + it('offers a Connect action when disconnected', () => { + const onConnect = vi.fn() + render() + fireEvent.click(screen.getByTitle('Connect')) + expect(onConnect).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/Database/DatabaseExplorer.tsx b/src/renderer/src/components/Database/DatabaseExplorer.tsx index 7de8c02..3279790 100644 --- a/src/renderer/src/components/Database/DatabaseExplorer.tsx +++ b/src/renderer/src/components/Database/DatabaseExplorer.tsx @@ -22,7 +22,7 @@ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/ function isJsonString(s: string): boolean { if (s.length < 2) return false - return (s[0] === '{' && s[s.length - 1] === '}') || (s[0] === '[' && s[s.length - 1] === ']') + return (s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']')) } /* ── Explain plan parsing ─────────────────────────────────────────────── */ @@ -81,7 +81,7 @@ function computeRowDiff(prev: QueryResult | null, next: QueryResult): Set) { const sessions = useAppStore(s => s.sessions) const updateTab = useAppStore(s => s.updateTab) const session = sessions.find(s => s.id === tab.sessionId) @@ -217,20 +217,42 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { function exportCsv() { if (!results) return const h = results.columns.join(',') - const rows = results.rows.map(r => results.columns.map(c => { const v = r[c]; if (v == null) return ''; const s = String(v); return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replace(/"/g, '""')}"` : s }).join(',')).join('\n') + const rows = results.rows.map(r => results.columns.map(c => { + const v = r[c] + if (v == null) return '' + const s = String(v) + return s.includes(',') || s.includes('"') || s.includes('\n') ? `"${s.replaceAll('"', '""')}"` : s + }).join(',')).join('\n') const blob = new Blob([`${h}\n${rows}`], { type: 'text/csv' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${browsingTable || 'query'}-results.csv`; a.click(); URL.revokeObjectURL(a.href); showToast('Exported') } + function pickQuery(q: string) { + setSql(q) + setActivePanel('results') + editorRef.current?.focus() + } + + function startCellEdit(row: number, col: string, val: unknown) { + if (!browsingTable) return + setEditingCell({ row, col }) + setEditValue(toEditable(val)) + setTimeout(() => editInputRef.current?.focus(), 0) + } + async function commitEdit() { if (!clientId || !editingCell || !results || !browsingTable) { setEditingCell(null); return } const row = results.rows[editingCell.row]; const col = editingCell.col if (String(row[col] ?? '') === editValue) { setEditingCell(null); return } const pk = results.columns.includes('id') ? 'id' : results.columns[0] const pkVal = row[pk]; if (pkVal == null) { showToast('No primary key to identify row'); setEditingCell(null); return } - const newVal = editValue === '' ? 'NULL' : `'${editValue.replace(/'/g, "''")}'` - const pkLit = typeof pkVal === 'number' ? pkVal : `'${String(pkVal).replace(/'/g, "''")}'` + const dbType = session?.dbType || 'postgresql' + const q = (name: string) => quoteIdent(name, dbType) try { - await window.api.database.query(clientId, `UPDATE "${browsingTable}" SET "${col}" = ${newVal} WHERE "${pk}" = ${pkLit}`) + await window.api.database.query( + clientId, + `UPDATE ${q(browsingTable)} SET ${q(col)} = ${bindPlaceholder(dbType, 1)} WHERE ${q(pk)} = ${bindPlaceholder(dbType, 2)}`, + [editValue === '' ? null : editValue, typeof pkVal === 'number' ? pkVal : String(pkVal)], + ) const updated = [...results.rows]; updated[editingCell.row] = { ...row, [col]: editValue === '' ? null : editValue } setResults({ ...results, rows: updated }); showToast('Updated') } catch (err: any) { showToast(`Update failed: ${err?.message}`) } @@ -275,7 +297,10 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { function startResize(e: React.MouseEvent) { e.preventDefault(); resizingRef.current = true; const startY = e.clientY; const startH = editorHeight - const onMove = (ev: MouseEvent) => { if (!resizingRef.current) return; setEditorHeight(Math.max(40, Math.min(400, startH + (ev.clientY - startY)))) } + const onMove = (ev: MouseEvent) => { + if (!resizingRef.current) return + setEditorHeight(Math.max(40, Math.min(400, startH + (ev.clientY - startY)))) + } const onUp = () => { resizingRef.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp) } window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp) } @@ -286,7 +311,9 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { const { col, dir } = resultSort return [...results.rows].sort((a, b) => { const av = a[col], bv = b[col] - if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1 + if (av == null && bv == null) return 0 + if (av == null) return 1 + if (bv == null) return -1 const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv)) return dir === 'asc' ? cmp : -cmp }) @@ -296,92 +323,36 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { function toggleJsonExpand(key: string) { setExpandedJson(s => { const n = new Set(s); n.has(key) ? n.delete(key) : n.add(key); return n }) } - const filteredTables = tableFilter ? tables.filter(t => t.toLowerCase().includes(tableFilter.toLowerCase())) : tables - const dbType = session?.dbType === 'mysql' ? 'MySQL' : session?.dbType === 'mariadb' ? 'MariaDB' : 'PostgreSQL' - const detailRow = selectedRow !== null && results ? sortedRows[selectedRow] : null - const resultsTableWidth = results ? 48 + results.columns.length * 160 : 0 - const resultsGridColumns = results ? `48px repeat(${results.columns.length}, 160px)` : undefined + const filteredTables = filterTables(tables, tableFilter) + const dbType = DB_TYPE_LABELS[session?.dbType ?? ''] ?? 'PostgreSQL' + const detailRow = getDetailRow(sortedRows, selectedRow) if (connecting) return

Connecting to {session?.databaseName || session?.host}

if (error && !clientId) return

{error}

return (
- {/* Schema sidebar */} -
-
- -

{session?.databaseName || 'Database'}

- -
-
-
- - setTableFilter(e.target.value)} placeholder="Filter tables…" className="flex-1 bg-transparent text-[10px] font-mono focus:outline-none" style={{ color: 'var(--nox-text)' }} /> - {tableFilter && } -
-
-
Tables ({filteredTables.length})
-
- {filteredTables.map(table => ( -
- - {activeTable === table && tableColumns[table] && ( -
{tableColumns[table].map(col => ( -
- - {col.name} - {col.type} -
- ))}
- )} -
- ))} -
-
-
{dbType} · {session?.host}:{session?.port}
-
-
+ refreshTables()} + /> {/* Main area */}
- {/* Toolbar */} -
- - -
-
- - {watchActive && {watchCountdown}s} - {!watchActive && ( - - )} -
- {navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter -
- -
- -
+ runQuery()} onExplain={runExplain} onStartWatch={startWatch} onStopWatch={stopWatch} + onWatchSecChange={setWatchSec} onSave={saveCurrentQuery} + onClear={() => { setSql(''); setResults(null); setQueryError(null); setBrowsingTable(null); setActiveTable(null); setExplainTree(null) }} + /> {/* SQL editor */}
@@ -389,21 +360,11 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) {
- {/* Tabs */} -
- setActivePanel('results')} badge={results ? results.rowCount : undefined}>Results - setActivePanel('explain')} badge={explainTree ? 1 : undefined}>Explain - setActivePanel('history')} badge={history.length || undefined}>History - setActivePanel('saved')} badge={savedQueries.length || undefined}>Saved -
- {results && activePanel === 'results' && <> - {results.columns.length} cols · {results.duration}ms - - - setDetailOpen(d => !d)} active={detailOpen}>{detailOpen ? : } -
- } -
+ setDetailOpen(d => !d)} + /> {/* Panel content */}
@@ -420,43 +381,13 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) {
)} {results && ( -
-
-
-
-
#
- {results.columns.map(col => ( - - ))} -
- {sortedRows.map((row, i) => ( -
setSelectedRow(i === selectedRow ? null : i)} className="grid cursor-default transition-colors" style={{ gridTemplateColumns: resultsGridColumns, 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 = '' }}> -
{i + 1}
- {results.columns.map(col => { - const val = row[col]; const isNull = val == null - const isEditing = editingCell?.row === i && editingCell?.col === col - const cellKey = `${i}-${col}` - return ( -
{ e.stopPropagation(); if (browsingTable) { setEditingCell({ row: i, col }); setEditValue(isNull ? '' : String(val)); setTimeout(() => editInputRef.current?.focus(), 0) } }}> - {isEditing ? ( - setEditValue(e.target.value)} - onBlur={commitEdit} onKeyDown={e => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') setEditingCell(null) }} - className="bg-transparent text-[11px] font-mono px-0 py-0 focus:outline-none w-full" style={{ color: 'var(--nox-text)', borderBottom: '1px solid #3B5CCC' }} /> - ) : ( - - )} -
- ) - })} -
- ))} -
- {sortedRows.length === 0 &&

No rows

} -
-
+ setEditingCell(null)} startCellEdit={startCellEdit} + changedCells={changedCells} expandedJson={expandedJson} onToggleJson={toggleJsonExpand} + /> )} {!results && !queryError && !running && (

Click a table to browse, or write a query

@@ -466,25 +397,7 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) { {/* Row detail panel */} {detailOpen && detailRow && results && ( -
-
- Row {selectedRow! + 1} - -
-
- {results.columns.map(col => { - const val = detailRow[col]; const isNull = val == null - const str = isNull ? null : typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val) - return ( -
-

{col}

- {isNull ?

NULL

- :
{str}
} -
- ) - })} -
-
+ setDetailOpen(false)} /> )} } @@ -499,71 +412,334 @@ export default function DatabaseExplorer({ tab }: { tab: Tab }) {
)} - {/* ── History ──────────────────────────────────────────── */} - {activePanel === 'history' && ( -
- {history.length === 0 ?

No history

- : history.map((h, i) => ( - - ))} -
- )} + {activePanel === 'history' && } + + {activePanel === 'saved' && } +
+
+ + {toast &&
{toast}
} +
+ ) +} + +const DB_TYPE_LABELS: Record = { mysql: 'MySQL', mariadb: 'MariaDB', postgresql: 'PostgreSQL' } + +function ExplorerToolbar({ running, explainRunning, hasSql, activePanel, watchActive, watchSec, watchCountdown, onRun, onExplain, onStartWatch, onStopWatch, onWatchSecChange, onSave, onClear }: Readonly<{ + running: boolean; explainRunning: boolean; hasSql: boolean; activePanel: ActivePanel + watchActive: boolean; watchSec: number; watchCountdown: number + onRun: () => void; onExplain: () => void; onStartWatch: () => void; onStopWatch: () => void + onWatchSecChange: (sec: number) => void; onSave: () => void; onClear: () => void +}>) { + return ( +
+ + +
+ + {navigator.platform?.includes('Mac') ? '⌘' : 'Ctrl'}+Enter +
+ +
+ +
+ ) +} + +function WatchControls({ watchActive, watchSec, watchCountdown, running, hasSql, onStart, onStop, onSecChange }: Readonly<{ + watchActive: boolean; watchSec: number; watchCountdown: number; running: boolean; hasSql: boolean + onStart: () => void; onStop: () => void; onSecChange: (sec: number) => void +}>) { + return ( +
+ + {watchActive && {watchCountdown}s} + {!watchActive && ( + + )} +
+ ) +} + +function ResultsTabsBar({ activePanel, onSelect, results, hasExplain, historyCount, savedCount, detailOpen, onCopy, onExport, onToggleDetail }: Readonly<{ + activePanel: ActivePanel; onSelect: (p: ActivePanel) => void; results: QueryResult | null; hasExplain: boolean + historyCount: number; savedCount: number; detailOpen: boolean + onCopy: () => void; onExport: () => void; onToggleDetail: () => void +}>) { + return ( +
+ onSelect('results')} badge={results ? results.rowCount : undefined}>Results + onSelect('explain')} badge={hasExplain ? 1 : undefined}>Explain + onSelect('history')} badge={historyCount || undefined}>History + onSelect('saved')} badge={savedCount || undefined}>Saved +
+ {results && activePanel === 'results' && <> + {results.columns.length} cols · {results.duration}ms + + + {detailOpen ? : } +
+ } +
+ ) +} - {/* ── Saved ────────────────────────────────────────────── */} - {activePanel === 'saved' && ( -
- {savedQueries.length === 0 ?

Save queries with the Pin button

- : savedQueries.map((q, i) => ( - +function ResultsGrid({ results, sortedRows, resultSort, onToggleSort, selectedRow, onSelectRow, editingCell, editValue, setEditValue, editInputRef, commitEdit, cancelEdit, startCellEdit, changedCells, expandedJson, onToggleJson }: Readonly<{ + results: QueryResult; sortedRows: any[]; resultSort: ResultSort; onToggleSort: (col: string) => void + selectedRow: number | null; onSelectRow: (i: number | null) => void + editingCell: { row: number; col: string } | null; editValue: string; setEditValue: (v: string) => void + editInputRef: React.Ref; commitEdit: () => void; cancelEdit: () => void + startCellEdit: (row: number, col: string, val: unknown) => void + changedCells: Set; expandedJson: Set; onToggleJson: (k: string) => void +}>) { + const tableWidth = 48 + results.columns.length * 160 + const gridColumns = `48px repeat(${results.columns.length}, 160px)` + // Result rows have no inherent identity; key on the pk-ish column value, + // disambiguating duplicates with an occurrence counter (not the array index). + const pkCol = results.columns.includes('id') ? 'id' : results.columns[0] + const seen = new Map() + const rowKeys = sortedRows.map(row => { + const base = toEditable(row[pkCol]) + const n = (seen.get(base) ?? 0) + 1 + seen.set(base, n) + return n > 1 ? `${base}#${n}` : base + }) + return ( +
+
+
+
+
#
+ {results.columns.map(col => ( + + ))} +
+ {/* 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. */} + {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

} +
+
+ ) +} + +function SchemaSidebar({ dbLabel, footer, tables, tableFilter, setTableFilter, activeTable, tableColumns, onSelect, onRefresh }: Readonly<{ + dbLabel: string; footer: string; tables: string[]; tableFilter: string; setTableFilter: (v: string) => void + activeTable: string | null; tableColumns: Record; onSelect: (t: string) => void; onRefresh: () => void +}>) { + return ( +
+
+ +

{dbLabel}

+ +
+
+
+ + setTableFilter(e.target.value)} placeholder="Filter tables…" className="flex-1 bg-transparent text-[10px] font-mono focus:outline-none" style={{ color: 'var(--nox-text)' }} /> + {tableFilter && }
+
Tables ({tables.length})
+
+ {tables.map(table => ( +
+ + {activeTable === table && tableColumns[table] && ( +
{tableColumns[table].map(col => ( +
+ + {col.name} + {col.type} +
+ ))}
+ )} +
+ ))} +
+
+
{footer}
+
+
+ ) +} - {toast &&
{toast}
} +function ResultCell({ row, rowIndex, col, editing, changed, editValue, setEditValue, editInputRef, commitEdit, cancelEdit, startCellEdit, expandedJson, onToggleJson }: Readonly<{ + row: any; rowIndex: number; col: string; editing: boolean; changed: boolean + editValue: string; setEditValue: (v: string) => void; editInputRef: React.Ref + commitEdit: () => void; cancelEdit: () => void; startCellEdit: (row: number, col: string, val: unknown) => void + expandedJson: Set; onToggleJson: (k: string) => void +}>) { + const val = row[col] + const isNull = val == null + return ( +
{ e.stopPropagation(); startCellEdit(rowIndex, col, val) }}> + {editing ? ( + setEditValue(e.target.value)} + onBlur={commitEdit} onKeyDown={e => { + if (e.key === 'Enter') commitEdit() + if (e.key === 'Escape') cancelEdit() + }} + className="bg-transparent text-[11px] font-mono px-0 py-0 focus:outline-none w-full" style={{ color: 'var(--nox-text)', borderBottom: '1px solid #3B5CCC' }} /> + ) : ( + + )} +
+ ) +} + +function RowDetailPanel({ columns, row, rowNumber, onClose }: Readonly<{ + columns: string[]; row: any; rowNumber: number; onClose: () => void +}>) { + return ( +
+
+ Row {rowNumber} + +
+
+ {columns.map(col => { + const val = row[col] + if (val == null) { + return ( +
+

{col}

+

NULL

+
+ ) + } + const str = typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val) + return ( +
+

{col}

+
{str}
+
+ ) + })} +
+
+ ) +} + +function HistoryPanel({ history, onPick }: Readonly<{ + history: { sql: string; ts: number; duration?: number; rows?: number }[]; onPick: (sql: string) => void +}>) { + if (history.length === 0) { + return

No history

+ } + return ( +
+ {history.map((h, i) => ( + + ))} +
+ ) +} + +function SavedPanel({ savedQueries, onPick }: Readonly<{ savedQueries: SavedQuery[]; onPick: (sql: string) => void }>) { + if (savedQueries.length === 0) { + return

Save queries with the Pin button

+ } + return ( +
+ {savedQueries.map((q) => ( + + ))}
) } /* ── Smart cell renderer ───────────────────────────────────────────────── */ -function SmartCell({ value, cellKey, expandedJson, onToggleJson }: { +// Returns the parsed object for object values / JSON-looking strings, else null. +function tryParseJsonCell(value: any, str: string): any { + if (typeof value === 'object') return value + if (!isJsonString(str)) return null + try { return JSON.parse(str) } catch { return null } +} + +// JSON object/array — expandable inline +function JsonCell({ parsed, expanded, onToggle }: Readonly<{ parsed: any; expanded: boolean; onToggle: () => void }>) { + return ( + + + {expanded && ( +
{JSON.stringify(parsed, null, 2)}
+ )} +
+ ) +} + +function SmartCell({ value, cellKey, expandedJson, onToggleJson }: Readonly<{ value: any; cellKey: string; expandedJson: Set; onToggleJson: (k: string) => void -}) { +}>) { if (value == null) return NULL const str = typeof value === 'object' ? JSON.stringify(value) : String(value) - // JSON object/array — expandable inline - if (typeof value === 'object' || isJsonString(str)) { - const expanded = expandedJson.has(cellKey) - const parsed = typeof value === 'object' ? value : (() => { try { return JSON.parse(str) } catch { return null } })() - if (parsed) { - return ( - - - {expanded && ( -
{JSON.stringify(parsed, null, 2)}
- )} -
- ) - } + const parsed = tryParseJsonCell(value, str) + if (parsed) { + return onToggleJson(cellKey)} /> } // URL — clickable link @@ -589,7 +765,7 @@ function SmartCell({ value, cellKey, expandedJson, onToggleJson }: { // ISO timestamp — show relative time tooltip if (ISO_DATE_RE.test(str)) { const d = new Date(str) - if (!isNaN(d.getTime())) { + if (!Number.isNaN(d.getTime())) { return ( {d.toLocaleString()} ({relativeTime(d)}) @@ -613,10 +789,16 @@ function SmartCell({ value, cellKey, expandedJson, onToggleJson }: { /* ── Explain tree visualizer ───────────────────────────────────────────── */ -function ExplainTreeView({ node, maxCost, depth }: { node: ExplainNode; maxCost: number; depth: number }) { +function explainCostColor(costPct: number): string { + if (costPct > 70) return '#EF4444' + if (costPct > 40) return '#F59E0B' + return '#10B981' +} + +function ExplainTreeView({ node, maxCost, depth }: Readonly<{ node: ExplainNode; maxCost: number; depth: number }>) { const [expanded, setExpanded] = useState(true) const costPct = maxCost > 0 ? Math.max(2, (node.cost / maxCost) * 100) : 0 - const costColor = costPct > 70 ? '#EF4444' : costPct > 40 ? '#F59E0B' : '#10B981' + const costColor = explainCostColor(costPct) return (
0 ? 24 : 0 }}> @@ -652,15 +834,41 @@ function ExplainTreeView({ node, maxCost, depth }: { node: ExplainNode; maxCost:
{expanded && node.children.map((child, i) => ( - + ))}
) } +// Identifier quoting per dialect — values themselves always travel as bind +// parameters, never interpolated into the SQL string. +function quoteIdent(name: string, dbType: string): string { + if (dbType === 'mysql' || dbType === 'mariadb') return '`' + name.replaceAll('`', '``') + '`' + return '"' + name.replaceAll('"', '""') + '"' +} + +function bindPlaceholder(dbType: string, n: number): string { + return dbType === 'mysql' || dbType === 'mariadb' ? '?' : `$${n}` +} + /* ── Helpers ────────────────────────────────────────────────────────────── */ -function PanelTab({ active, onClick, badge, children }: { active: boolean; onClick: () => void; badge?: number; children: React.ReactNode }) { +function toEditable(v: unknown): string { + if (v == null) return '' + if (typeof v === 'object') return JSON.stringify(v) + return String(v) +} + +function filterTables(tables: string[], filter: string): string[] { + if (!filter) return tables + return tables.filter(t => t.toLowerCase().includes(filter.toLowerCase())) +} + +function getDetailRow(rows: any[], selectedRow: number | null): any { + return selectedRow === null ? null : rows[selectedRow] ?? null +} + +function PanelTab({ active, onClick, badge, children }: Readonly<{ active: boolean; onClick: () => void; badge?: number; children: React.ReactNode }>) { return ( } diff --git a/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx b/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx new file mode 100644 index 0000000..3959a1e --- /dev/null +++ b/src/renderer/src/components/Database/__tests__/DatabaseExplorer.test.tsx @@ -0,0 +1,664 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react' +import DatabaseExplorer from '../DatabaseExplorer' +import { installWindowApi, seedStore, makeSession, makeTab, WindowApiMock } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +const usersResult = { + columns: ['id', 'name', 'active', 'meta'], + rows: [ + { id: 1, name: 'alice', active: true, meta: { role: 'admin' } }, + { id: 2, name: 'bob', active: false, meta: null }, + ], + rowCount: 2, + duration: 12, +} + +const tableInfoResult = { + columns: [ + { name: 'id', type: 'integer', nullable: false }, + { name: 'name', type: 'varchar', nullable: true }, + { name: 'created', type: 'timestamp', nullable: true }, + { name: 'active', type: 'boolean', nullable: false }, + { name: 'meta', type: 'jsonb', nullable: true }, + { name: 'uid', type: 'uuid', nullable: true }, + { name: 'blob', type: 'bytea', nullable: true }, + ], +} + +function setup(sessionOverrides: Record = {}, dbOverrides: Record = {}) { + const session = makeSession({ + type: 'database', + dbType: 'postgresql', + databaseName: 'appdb', + host: 'db.example.com', + port: 5432, + username: 'admin', + sslMode: 'disable', + ...sessionOverrides, + } as any) + const tab = makeTab({ sessionId: session.id, view: 'database' as any, label: 'appdb' }) + const api = installWindowApi({ + database: { + connect: vi.fn().mockResolvedValue('db-1'), + disconnect: vi.fn().mockResolvedValue(undefined), + query: vi.fn().mockResolvedValue(usersResult), + tables: vi.fn().mockResolvedValue(['users', 'orders', 'audit_log']), + tableInfo: vi.fn().mockResolvedValue(tableInfoResult), + ...dbOverrides, + }, + }) + seedStore({ sessions: [session], tabs: [tab] } as any) + return { session, tab, api } +} + +async function renderConnected(sessionOverrides: Record = {}, dbOverrides: Record = {}) { + const ctx = setup(sessionOverrides, dbOverrides) + const utils = render() + await screen.findByText('Tables (3)') + return { ...ctx, ...utils } +} + +const editor = () => screen.getByPlaceholderText('SELECT * FROM …') as HTMLTextAreaElement + +async function runSql(api: WindowApiMock, sql: string) { + fireEvent.change(editor(), { target: { value: sql } }) + fireEvent.click(screen.getByText('Run')) + await waitFor(() => expect(api.database.query).toHaveBeenCalled()) +} + +beforeEach(() => { + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }) + ;(URL as any).createObjectURL = vi.fn(() => 'blob:test') + ;(URL as any).revokeObjectURL = vi.fn() +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +describe('DatabaseExplorer — connect flow', () => { + it('shows connecting state then the schema sidebar after connect', async () => { + const { api, session } = setup() + render() + expect(screen.getByText(/Connecting to appdb/)).toBeTruthy() + await screen.findByText('Tables (3)') + expect(api.sessions.getCredentials).toHaveBeenCalledWith(session.id) + expect(api.database.connect).toHaveBeenCalledWith({ + dbType: 'postgresql', + host: 'db.example.com', + port: 5432, + username: 'admin', + password: 'pw', + database: 'appdb', + ssl: 'disable', + }) + expect(screen.getByText('users')).toBeTruthy() + expect(screen.getByText('orders')).toBeTruthy() + expect(screen.getByText(/PostgreSQL · db.example.com:5432/)).toBeTruthy() + expect(useAppStore.getState().tabs[0].status).toBe('connected') + }) + + it('shows the error screen when connect fails and retries on click', async () => { + const { tab, api } = setup({}, { + connect: vi.fn().mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue('db-2'), + }) + render() + await screen.findByText('ECONNREFUSED') + expect(useAppStore.getState().tabs[0].status).toBe('error') + fireEvent.click(screen.getByText('Retry')) + await screen.findByText('Tables (3)') + expect(api.database.connect).toHaveBeenCalledTimes(2) + }) + + it('maps a locked-vault credential error to a friendly message', async () => { + const { tab } = setup() + ;(window as any).api.sessions.getCredentials = vi.fn().mockRejectedValue(new Error('vault is locked')) + render() + expect(await screen.findByText('App is locked — unlock noxed to reconnect')).toBeTruthy() + }) + + it('disconnects on unmount and logs when disconnect fails', async () => { + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { api, unmount } = await renderConnected() + api.database.disconnect.mockRejectedValueOnce(new Error('gone')) + unmount() + await waitFor(() => expect(api.database.disconnect).toHaveBeenCalledWith('db-1')) + await waitFor(() => expect(errSpy).toHaveBeenCalled()) + }) +}) + +describe('DatabaseExplorer — sidebar and tables', () => { + it('filters tables and clears the filter', async () => { + await renderConnected() + const filter = screen.getByPlaceholderText('Filter tables…') + fireEvent.change(filter, { target: { value: 'ORD' } }) + expect(screen.getByText('orders')).toBeTruthy() + expect(screen.queryByText('users')).toBeNull() + const clearBtn = filter.parentElement!.querySelector('button')! + fireEvent.click(clearBtn) + expect(screen.getByText('users')).toBeTruthy() + }) + + it('refreshes tables and toasts when refresh fails', async () => { + const { api } = await renderConnected() + api.database.tables.mockResolvedValueOnce(['users', 'orders', 'audit_log', 'new_table']) + const header = screen.getByText('appdb').parentElement! + fireEvent.click(header.querySelector('button')!) + await screen.findByText('Tables (4)') + api.database.tables.mockRejectedValueOnce(new Error('schema read failed')) + fireEvent.click(header.querySelector('button')!) + expect(await screen.findByText('schema read failed')).toBeTruthy() + }) + + it('browses a table on click, loads columns with type badges, and collapses on second click', async () => { + const { api } = await renderConnected() + fireEvent.click(screen.getByText('users')) + await waitFor(() => + expect(api.database.query).toHaveBeenCalledWith('db-1', 'SELECT * FROM "users" LIMIT 100') + ) + expect(api.database.tableInfo).toHaveBeenCalledWith('db-1', 'users') + await screen.findByText('integer') + expect(screen.getByText('varchar')).toBeTruthy() + expect(screen.getByText('timestamp')).toBeTruthy() + expect(screen.getByText('boolean')).toBeTruthy() + expect(screen.getByText('jsonb')).toBeTruthy() + expect(screen.getByText('uuid')).toBeTruthy() + expect(screen.getByText('bytea')).toBeTruthy() + // Grid rendered + await screen.findByText('alice') + expect((editor() as HTMLTextAreaElement).value).toBe('SELECT * FROM "users" LIMIT 100') + // Collapse + fireEvent.click(screen.getByText('users')) + expect(screen.queryByText('integer')).toBeNull() + }) + + it('toasts when loading table columns fails', async () => { + await renderConnected({}, { tableInfo: vi.fn().mockRejectedValue(new Error('no perms')) }) + fireEvent.click(screen.getByText('orders')) + expect(await screen.findByText('no perms')).toBeTruthy() + }) +}) + +describe('DatabaseExplorer — toolbar and query running', () => { + it('disables Run without SQL, runs a query, and records history', async () => { + const { api } = await renderConnected() + const runBtn = screen.getByText('Run').closest('button')! + expect(runBtn.hasAttribute('disabled')).toBe(true) + await runSql(api, 'SELECT * FROM users') + await screen.findByText('alice') + expect(screen.getByText(/4 cols · 12ms/)).toBeTruthy() + // history entry + fireEvent.click(screen.getByText('History')) + // the textarea still holds the same SQL, so target the history
 entry
+    const historyEntry = screen.getAllByText('SELECT * FROM users').find(el => el.tagName === 'PRE')!
+    expect(historyEntry).toBeTruthy()
+    expect(screen.getByText('12ms')).toBeTruthy()
+    expect(screen.getByText('2 rows')).toBeTruthy()
+    // picking a history entry restores the SQL and returns to results
+    fireEvent.click(historyEntry)
+    expect(editor().value).toBe('SELECT * FROM users')
+    await screen.findByText('alice')
+  })
+
+  it('runs the query on Ctrl+Enter', async () => {
+    const { api } = await renderConnected()
+    fireEvent.change(editor(), { target: { value: 'SELECT 1' } })
+    fireEvent.keyDown(editor(), { key: 'Enter', ctrlKey: true })
+    await waitFor(() => expect(api.database.query).toHaveBeenCalledWith('db-1', 'SELECT 1'))
+  })
+
+  it('shows the query error panel when a query fails', async () => {
+    const { api } = await renderConnected({}, {
+      query: vi.fn().mockRejectedValue(new Error('syntax error at or near "FRM"')),
+    })
+    await runSql(api, 'SELECT * FRM users')
+    await screen.findByText('syntax error at or near "FRM"')
+  })
+
+  it('shows No rows for an empty result', async () => {
+    const { api } = await renderConnected({}, {
+      query: vi.fn().mockResolvedValue({ columns: ['id'], rows: [], rowCount: 0, duration: 3 }),
+    })
+    await runSql(api, 'SELECT * FROM empty')
+    await screen.findByText('No rows')
+  })
+
+  it('saves a query via prompt and lists it in the Saved panel', async () => {
+    const { api } = await renderConnected()
+    const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('All users')
+    await runSql(api, 'SELECT * FROM users')
+    fireEvent.click(screen.getByText('Save'))
+    await screen.findByText('Query saved')
+    // cancelled prompt saves nothing
+    promptSpy.mockReturnValueOnce(null)
+    fireEvent.click(screen.getByText('Save'))
+    fireEvent.click(screen.getByText('Saved'))
+    expect(screen.getByText('All users')).toBeTruthy()
+    // picking a saved query restores its SQL
+    fireEvent.click(screen.getByText('All users'))
+    expect(editor().value).toBe('SELECT * FROM users')
+  })
+
+  it('shows the empty Saved panel hint', async () => {
+    await renderConnected()
+    fireEvent.click(screen.getByText('Saved'))
+    expect(screen.getByText('Save queries with the Pin button')).toBeTruthy()
+    fireEvent.click(screen.getByText('History'))
+    expect(screen.getByText('No history')).toBeTruthy()
+  })
+
+  it('clears editor and results with Clear', async () => {
+    const { api } = await renderConnected()
+    await runSql(api, 'SELECT * FROM users')
+    await screen.findByText('alice')
+    fireEvent.click(screen.getByText('Clear'))
+    expect(editor().value).toBe('')
+    expect(screen.queryByText('alice')).toBeNull()
+    expect(screen.getByText('Click a table to browse, or write a query')).toBeTruthy()
+  })
+
+  it('truncates long SQL in the history panel', async () => {
+    const { api } = await renderConnected()
+    const longSql = 'SELECT ' + 'x'.repeat(250)
+    await runSql(api, longSql)
+    fireEvent.click(screen.getByText('History'))
+    expect(screen.getByText((t) => t.endsWith('…') && t.startsWith('SELECT '))).toBeTruthy()
+  })
+})
+
+describe('DatabaseExplorer — results grid', () => {
+  const sortResult = {
+    columns: ['id', 'name'],
+    rows: [
+      { id: 2, name: 'bob' },
+      { id: 1, name: 'alice' },
+      { id: 3, name: null },
+    ],
+    rowCount: 3,
+    duration: 5,
+  }
+
+  function orderOf(container: HTMLElement, a: string, b: string) {
+    const t = container.textContent!
+    return t.indexOf(a) < t.indexOf(b)
+  }
+
+  it('sorts by column ascending, descending, and numerically', async () => {
+    const { api, container } = await renderConnected({}, {
+      query: vi.fn().mockResolvedValue(sortResult),
+    })
+    await runSql(api, 'SELECT * FROM t')
+    await screen.findByText('alice')
+    // unsorted: bob first
+    expect(orderOf(container, 'bob', 'alice')).toBe(true)
+    // sort by name asc
+    fireEvent.click(screen.getByText('name'))
+    expect(orderOf(container, 'alice', 'bob')).toBe(true)
+    // sort by name desc
+    fireEvent.click(screen.getByText('name'))
+    expect(orderOf(container, 'bob', 'alice')).toBe(true)
+    // numeric sort by id asc
+    fireEvent.click(screen.getByText('id'))
+    expect(orderOf(container, 'alice', 'bob')).toBe(true)
+  })
+
+  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
+    fireEvent.click(aliceRow)
+    fireEvent.click(screen.getByTitle('Row detail'))
+    const detail = screen.getByText('Row 1').parentElement!.parentElement as HTMLElement
+    // objects render as one JSON.stringify'd 
 text node
+    expect(within(detail).getByText(/"role": "admin"/)).toBeTruthy()
+    // deselect via click hides the panel content (getDetailRow returns null)
+    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
+    fireEvent.keyDown(bobRow, { key: 'Enter' })
+    await screen.findByText('Row 2')
+    // deselect via Space key
+    fireEvent.keyDown(bobRow, { key: ' ' })
+    expect(screen.queryByText('Row 2')).toBeNull()
+    // close button
+    fireEvent.keyDown(aliceRow, { key: 'Enter' })
+    const panel = await screen.findByText('Row 1')
+    fireEvent.click(panel.parentElement!.querySelector('button')!)
+    expect(screen.queryByText('Row 1')).toBeNull()
+  })
+
+  it('renders smart cells: URL, hex color, ISO date, boolean, JSON string and expandable object', async () => {
+    const rich = {
+      columns: ['id', 'site', 'color', 'created', 'active', 'meta', 'note', 'empty'],
+      rows: [{
+        id: 1,
+        site: 'https://example.com/page',
+        color: '#ff0000',
+        created: '2024-03-05T10:00:00Z',
+        active: true,
+        meta: { role: 'admin' },
+        note: '[1,2]',
+        empty: null,
+      }],
+      rowCount: 1,
+      duration: 2,
+    }
+    const { api } = await renderConnected({}, { query: vi.fn().mockResolvedValue(rich) })
+    await runSql(api, 'SELECT * FROM rich')
+    // URL link
+    const link = await screen.findByText('https://example.com/page')
+    expect(link.getAttribute('href')).toBe('https://example.com/page')
+    // hex color swatch
+    expect(screen.getByText('#ff0000')).toBeTruthy()
+    // boolean
+    expect(screen.getByText('true')).toBeTruthy()
+    // NULL cell
+    expect(screen.getByText('NULL')).toBeTruthy()
+    // ISO date renders relative time
+    expect(screen.getByText(/\(.*ago\)/)).toBeTruthy()
+    // JSON object cell: badge with key count, expand and collapse
+    const objBadge = screen.getByText('{1}').closest('button')!
+    fireEvent.click(objBadge)
+    expect(screen.getByText(/"role": "admin"/)).toBeTruthy()
+    fireEvent.click(objBadge)
+    expect(screen.queryByText(/"role": "admin"/)).toBeNull()
+    // JSON string cell: array badge
+    expect(screen.getByText('[2]')).toBeTruthy()
+  })
+
+  it('copies results as TSV and exports CSV with escaping', async () => {
+    const res = {
+      columns: ['id', 'name'],
+      rows: [
+        { id: 1, name: 'has,comma' },
+        { id: 2, name: null },
+        { id: 3, name: 'has"quote' },
+      ],
+      rowCount: 3,
+      duration: 1,
+    }
+    const { api } = await renderConnected({}, { query: vi.fn().mockResolvedValue(res) })
+    await runSql(api, 'SELECT * FROM t')
+    await screen.findByText('has,comma')
+    fireEvent.click(screen.getByTitle('Copy'))
+    expect((navigator.clipboard.writeText as any)).toHaveBeenCalledWith(
+      'id\tname\n1\thas,comma\n2\t\n3\thas"quote'
+    )
+    await screen.findByText('Copied')
+    fireEvent.click(screen.getByTitle('CSV'))
+    expect((URL as any).createObjectURL).toHaveBeenCalled()
+    const blob: Blob = (URL as any).createObjectURL.mock.calls[0][0]
+    const text = await blob.text()
+    expect(text).toBe('id,name\n1,"has,comma"\n2,\n3,"has""quote"')
+    await screen.findByText('Exported')
+  })
+
+  it('resizes the editor with the drag handle', async () => {
+    const { container } = await renderConnected()
+    const handle = container.querySelector('.cursor-row-resize') as HTMLElement
+    const editorWrap = editor().parentElement as HTMLElement
+    expect(editorWrap.style.height).toBe('120px')
+    fireEvent.mouseDown(handle, { clientY: 100 })
+    fireEvent.mouseMove(window, { clientY: 180 })
+    expect(editorWrap.style.height).toBe('200px')
+    fireEvent.mouseUp(window)
+    fireEvent.mouseMove(window, { clientY: 400 })
+    expect(editorWrap.style.height).toBe('200px')
+  })
+})
+
+describe('DatabaseExplorer — cell editing', () => {
+  async function browseUsers(dbOverrides: Record = {}) {
+    const ctx = await renderConnected({}, dbOverrides)
+    fireEvent.click(screen.getByText('users'))
+    await screen.findByText('alice')
+    return ctx
+  }
+
+  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')!
+    fireEvent.doubleClick(metaBadge)
+    const input = await screen.findByDisplayValue('{"role":"admin"}')
+    fireEvent.change(input, { target: { value: '{"role":"user"}' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() =>
+      expect(api.database.query).toHaveBeenCalledWith(
+        'db-1',
+        'UPDATE "users" SET "meta" = $1 WHERE "id" = $2',
+        ['{"role":"user"}', 1],
+      )
+    )
+    await screen.findByText('Updated')
+  })
+
+  it('sets NULL when the edit value is emptied, binding a string pk', async () => {
+    const res = {
+      columns: ['code', 'label'],
+      rows: [{ code: "o'k", label: 'old' }],
+      rowCount: 1,
+      duration: 1,
+    }
+    const query = vi.fn().mockResolvedValue(res)
+    const { api } = await renderConnected({}, { query })
+    fireEvent.click(screen.getByText('users'))
+    const cell = await screen.findByText('old')
+    fireEvent.doubleClick(cell)
+    const input = await screen.findByDisplayValue('old')
+    fireEvent.change(input, { target: { value: '' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    await waitFor(() =>
+      expect(api.database.query).toHaveBeenCalledWith(
+        'db-1',
+        'UPDATE "users" SET "label" = $1 WHERE "code" = $2',
+        [null, "o'k"],
+      )
+    )
+    await screen.findByText('NULL')
+  })
+
+  it('skips the UPDATE when the value is unchanged and cancels on Escape', async () => {
+    const { api } = await browseUsers()
+    const callsBefore = api.database.query.mock.calls.length
+    const cell = screen.getByText('alice')
+    fireEvent.doubleClick(cell)
+    const input = await screen.findByDisplayValue('alice')
+    fireEvent.keyDown(input, { key: 'Enter' })
+    expect(api.database.query.mock.calls).toHaveLength(callsBefore)
+    // escape path
+    fireEvent.doubleClick(screen.getByText('bob'))
+    const input2 = await screen.findByDisplayValue('bob')
+    fireEvent.keyDown(input2, { key: 'Escape' })
+    await waitFor(() => expect(screen.queryByDisplayValue('bob')).toBeNull())
+    expect(api.database.query.mock.calls).toHaveLength(callsBefore)
+  })
+
+  it('toasts when no primary key identifies the row', async () => {
+    const res = {
+      columns: ['id', 'name'],
+      rows: [{ id: null, name: 'orphan' }],
+      rowCount: 1,
+      duration: 1,
+    }
+    await renderConnected({}, { query: vi.fn().mockResolvedValue(res) })
+    fireEvent.click(screen.getByText('users'))
+    const cell = await screen.findByText('orphan')
+    fireEvent.doubleClick(cell)
+    const input = await screen.findByDisplayValue('orphan')
+    fireEvent.change(input, { target: { value: 'renamed' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    expect(await screen.findByText('No primary key to identify row')).toBeTruthy()
+  })
+
+  it('toasts when the UPDATE fails', async () => {
+    const query = vi.fn().mockImplementation((_id: string, q: string) =>
+      q.startsWith('UPDATE')
+        ? Promise.reject(new Error('permission denied'))
+        : Promise.resolve(usersResult)
+    )
+    await browseUsers({ query })
+    fireEvent.doubleClick(screen.getByText('alice'))
+    const input = await screen.findByDisplayValue('alice')
+    fireEvent.change(input, { target: { value: 'alicia' } })
+    fireEvent.keyDown(input, { key: 'Enter' })
+    expect(await screen.findByText('Update failed: permission denied')).toBeTruthy()
+  })
+
+  it('does not open the editor when results did not come from browsing a table', async () => {
+    const { api } = await renderConnected()
+    await runSql(api, 'SELECT * FROM users')
+    const cell = await screen.findByText('alice')
+    fireEvent.doubleClick(cell)
+    expect(screen.queryByDisplayValue('alice')).toBeNull()
+  })
+})
+
+describe('DatabaseExplorer — explain', () => {
+  const pgPlan = {
+    'Node Type': 'Seq Scan',
+    'Relation Name': 'users',
+    'Total Cost': 100,
+    'Plan Rows': 50,
+    'Plan Width': 8,
+    'Actual Total Time': 1.23,
+    'Actual Rows': 48,
+    Plans: [
+      { 'Node Type': 'Index Scan', 'Total Cost': 30, 'Plan Rows': 10 },
+      { 'Node Type': 'Sort', 'Total Cost': 80, 'Plan Rows': 20 },
+    ],
+  }
+
+  it('shows the empty explain panel hint', async () => {
+    await renderConnected()
+    fireEvent.click(screen.getAllByText('Explain')[1])
+    expect(screen.getByText(/to visualize the execution plan/)).toBeTruthy()
+  })
+
+  it('runs EXPLAIN for postgres and renders the plan tree', async () => {
+    const query = vi.fn().mockImplementation((_id: string, q: string) =>
+      q.startsWith('EXPLAIN')
+        ? Promise.resolve({ columns: ['QUERY PLAN'], rows: [{ 'QUERY PLAN': [{ Plan: pgPlan }] }], rowCount: 1, duration: 4 })
+        : Promise.resolve(usersResult)
+    )
+    const { api } = await renderConnected({}, { query })
+    fireEvent.change(editor(), { target: { value: 'SELECT * FROM users' } })
+    fireEvent.click(screen.getByTitle('Visualize query execution plan'))
+    await screen.findByText('Seq Scan')
+    expect(api.database.query).toHaveBeenCalledWith(
+      'db-1',
+      'EXPLAIN (FORMAT JSON, ANALYZE, BUFFERS) SELECT * FROM users'
+    )
+    expect(screen.getByText('on users')).toBeTruthy()
+    expect(screen.getByText('cost 100.0')).toBeTruthy()
+    expect(screen.getByText('est. 50 rows')).toBeTruthy()
+    expect(screen.getByText('width 8')).toBeTruthy()
+    expect(screen.getByText('actual 1.23ms')).toBeTruthy()
+    expect(screen.getByText('actual 48 rows')).toBeTruthy()
+    expect(screen.getByText('Index Scan')).toBeTruthy()
+    expect(screen.getByText('Sort')).toBeTruthy()
+    // collapse root node hides children
+    const rootRow = screen.getByText('Seq Scan').closest('.group') as HTMLElement
+    fireEvent.click(rootRow.querySelector('button')!)
+    expect(screen.queryByText('Index Scan')).toBeNull()
+  })
+
+  it('runs EXPLAIN FORMAT=JSON for mysql sessions', async () => {
+    const mysqlPlan = { nodeType: 'table_scan', totalCost: 5, planRows: 3 }
+    const query = vi.fn().mockImplementation((_id: string, q: string) =>
+      q.startsWith('EXPLAIN')
+        ? Promise.resolve({ columns: ['EXPLAIN'], rows: [{ EXPLAIN: JSON.stringify(mysqlPlan) }], rowCount: 1, duration: 2 })
+        : Promise.resolve(usersResult)
+    )
+    const { api } = await renderConnected({ dbType: 'mysql' }, { query })
+    expect(screen.getByText(/MySQL ·/)).toBeTruthy()
+    fireEvent.change(editor(), { target: { value: 'SELECT 1' } })
+    fireEvent.click(screen.getByTitle('Visualize query execution plan'))
+    await screen.findByText('table_scan')
+    expect(api.database.query).toHaveBeenCalledWith('db-1', 'EXPLAIN FORMAT=JSON SELECT 1')
+    expect(screen.getByText('cost 5.0')).toBeTruthy()
+  })
+
+  it('toasts when explain fails', async () => {
+    const query = vi.fn().mockRejectedValue(new Error('cannot explain'))
+    await renderConnected({}, { query })
+    fireEvent.change(editor(), { target: { value: 'SELECT 1' } })
+    fireEvent.click(screen.getByTitle('Visualize query execution plan'))
+    expect(await screen.findByText('Explain failed: cannot explain')).toBeTruthy()
+  })
+})
+
+describe('DatabaseExplorer — watch mode', () => {
+  it('polls the query on an interval, highlights changed and added cells, then clears', async () => {
+    let call = 0
+    const base = { columns: ['id', 'name'], rowCount: 1, duration: 1 }
+    const query = vi.fn().mockImplementation(() => {
+      call += 1
+      if (call <= 1) return Promise.resolve({ ...base, rows: [{ id: 1, name: 'alice' }] })
+      if (call === 2) return Promise.resolve({ ...base, rows: [{ id: 1, name: 'alicia' }] })
+      return Promise.resolve({ ...base, rows: [{ id: 1, name: 'alicia' }, { id: 2, name: 'bob' }], rowCount: 2 })
+    })
+    const { api } = await renderConnected({}, { query })
+    await runSql(api, 'SELECT * FROM users')
+    await screen.findByText('alice')
+
+    vi.useFakeTimers()
+    // pick a 2s interval
+    fireEvent.change(screen.getByDisplayValue('5s'), { target: { value: '2' } })
+    fireEvent.click(screen.getByText('Watch'))
+    expect(screen.getByText('2s')).toBeTruthy()
+
+    // countdown ticks down
+    await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
+    expect(screen.getByText('1s')).toBeTruthy()
+
+    // first tick: changed cell highlighted
+    await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
+    const changed = screen.getByText('alicia').closest('div') 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
+    expect(added.style.background).toContain('245')
+
+    // stop watching
+    fireEvent.click(screen.getByText('Stop'))
+    expect(screen.getByText('Watch')).toBeTruthy()
+    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')
+  })
+
+  it('keeps polling silently through query errors and clears timers on unmount', async () => {
+    let calls = 0
+    const query = vi.fn().mockImplementation(() => {
+      calls += 1
+      return calls === 2 ? Promise.reject(new Error('boom')) : Promise.resolve(usersResult)
+    })
+    const { api, unmount } = await renderConnected({}, { query })
+    await runSql(api, 'SELECT * FROM users')
+    await screen.findByText('alice')
+
+    vi.useFakeTimers()
+    fireEvent.click(screen.getByText('Watch'))
+    await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
+    // errored tick is silent — results still shown
+    expect(screen.getByText('alice')).toBeTruthy()
+    await act(async () => { await vi.advanceTimersByTimeAsync(5000) })
+    expect(api.database.query.mock.calls.length).toBeGreaterThanOrEqual(3)
+    // unmount while watch is active clears intervals
+    unmount()
+    const after = api.database.query.mock.calls.length
+    await act(async () => { await vi.advanceTimersByTimeAsync(10000) })
+    expect(api.database.query.mock.calls).toHaveLength(after)
+  })
+})
diff --git a/src/renderer/src/components/Docker/DockerDashboard.tsx b/src/renderer/src/components/Docker/DockerDashboard.tsx
index 6073b9b..96b5819 100644
--- a/src/renderer/src/components/Docker/DockerDashboard.tsx
+++ b/src/renderer/src/components/Docker/DockerDashboard.tsx
@@ -4,6 +4,7 @@ import {
   Boxes, AlertTriangle, ChevronRight, ChevronDown, HardDrive,
 } from 'lucide-react'
 import { useAppStore, Tab } from '../../store'
+import { containerStateColor } from '../../lib/colors'
 import DockerLogsModal from './DockerLogsModal'
 
 interface ContainerRow {
@@ -259,16 +260,16 @@ export default function DockerDashboard({ tab }: Props) {
   )
 }
 
-function ContainerTableRow({ container, stat, last, busy, onAction, onLogs }: {
+function ContainerTableRow({ container, stat, last, busy, onAction, onLogs }: Readonly<{
   container: ContainerRow
   stat?: StatsRow
   last: boolean
   busy: boolean
   onAction: (action: 'start' | 'stop' | 'restart' | 'rm') => void
   onLogs: () => void
-}) {
+}>) {
   const isRunning = container.State === 'running'
-  const stateColor = isRunning ? '#10B981' : container.State === 'exited' ? 'var(--nox-text-3)' : '#F59E0B'
+  const stateColor = containerStateColor(container.State)
 
   return (
      void
   danger?: boolean
   children: React.ReactNode
-}) {
+}>) {
   return (
     
- -
-
+ + setPattern(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') onQuery() }} + placeholder="Pattern (e.g. user:*)" + className="flex-1 bg-transparent outline-none font-['Inter'] text-[11.5px]" + style={{ color: 'var(--nox-text)' }} + /> +
+ +
+
-
- {keys.length === 0 ? ( -
-

- {loadingKeys ? 'Loading…' : 'No keys found'} -

-
- ) : ( - keys.map(({ key }) => ( -
selectKey(key)} - className="group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors" - style={{ - background: selectedKey === key ? '#DC382D18' : 'transparent', - borderLeft: selectedKey === key ? '2px solid #DC382D' : '2px solid transparent', - }} - onMouseEnter={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} - onMouseLeave={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'transparent' }} - > - - {key} - - -
- )) - )} -
+
+ {keys.length === 0 ? ( +
+

+ {loadingKeys ? 'Loading…' : 'No keys found'} +

+ ) : ( + keys.map(({ key }) => ( +
{ if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} + onMouseLeave={e => { if (selectedKey !== key) (e.currentTarget as HTMLElement).style.background = 'transparent' }} + > + + +
+ )) + )} +
+
+ ) +} - {/* Value viewer */} -
- {!selectedKey ? ( -
-

- Select a key to view its value -

-
- ) : ( -
-
void +}>) { + if (!selectedKey) { + return ( +
+
+

+ Select a key to view its value +

+
+
+ ) + } + return ( +
+
+
+
+ {selectedKey} + {keyValue && ( + <> + -
- {selectedKey} - {keyValue && ( - <> - - {keyValue.type} - - - TTL: {keyValue.ttl === -1 ? 'No expiry' : `${keyValue.ttl}s`} - - - )} -
- -
-
- {loadingValue ? ( -
- - Loading… -
- ) : keyValue ? ( - - ) : null} -
-
+ {keyValue.type} + + + TTL: {keyValue.ttl === -1 ? 'No expiry' : `${keyValue.ttl}s`} + + )}
+
- ) : ( - /* CLI tab */ -
-
- {cmdHistory.length === 0 ? ( -

Type a Redis command below (e.g. INFO, DBSIZE, SET key value)

- ) : ( - cmdHistory.map((h, i) => ( -
-
- {'>'} {h.cmd} -
-
{h.result}
-
- )) - )} -
-
- {'>'} - setCmdInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleCommand() }} - placeholder="Enter command…" - className="flex-1 bg-transparent outline-none font-mono text-[12px]" - style={{ color: '#E6EDF3' }} - /> -
+
+ {loadingValue && ( +
+ + Loading… +
+ )} + {!loadingValue && keyValue && }
- )} +
+
+ ) +} + +function CliPane({ cmdHistory, cmdInput, setCmdInput, onSubmit, inputRef }: Readonly<{ + cmdHistory: { cmd: string; result: string }[] + cmdInput: string + setCmdInput: (v: string) => void + onSubmit: () => void + inputRef: React.Ref +}>) { + return ( +
+
+ {cmdHistory.length === 0 ? ( +

Type a Redis command below (e.g. INFO, DBSIZE, SET key value)

+ ) : ( + cmdHistory.map((h, i) => ( +
+
+ {'>'} {h.cmd} +
+
{h.result}
+
+ )) + )} +
+
+ {'>'} + setCmdInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') onSubmit() }} + placeholder="Enter command…" + className="flex-1 bg-transparent outline-none font-mono text-[12px]" + style={{ color: '#E6EDF3' }} + /> +
) } -function ValueViewer({ value }: { value: KeyValue }) { +function ValueViewer({ value }: Readonly<{ value: KeyValue }>) { if (value.type === 'string') { return (
= { + 'user:1': { type: 'string', value: 'hello world', ttl: -1 }, + 'user:profile': { type: 'hash', value: { name: 'sean', role: 'admin' }, ttl: 3600 }, + 'queue:jobs': { type: 'list', value: ['job-a', 'job-b'], ttl: -1 }, + 'scores': { type: 'zset', value: [{ member: 'a', score: 1 }], ttl: -1 }, +} + +function setup(overrides: Record = {}) { + const session = makeSession({ label: 'Cache', type: 'redis', port: 6379, redisDb: 2 }) + const tab = makeTab({ view: 'redis', sessionId: session.id }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + const api = installWindowApi({ + redis: { + connect: vi.fn().mockResolvedValue('redis-1'), + keys: vi.fn().mockResolvedValue(Object.keys(KEY_VALUES)), + get: vi.fn().mockImplementation(async (_id: string, key: string) => KEY_VALUES[key]), + del: vi.fn().mockResolvedValue(undefined), + command: vi.fn().mockResolvedValue(42), + disconnect: vi.fn().mockResolvedValue(undefined), + }, + ...overrides, + }) + const utils = render() + return { session, tab, api, ...utils } +} + +describe('RedisExplorer', () => { + beforeEach(() => { + seedStore({ sessions: [], tabs: [], activeTabId: null }) + }) + + it('connects with stored credentials and lists sorted keys', async () => { + const { api, session, tab } = setup() + expect(await screen.findByText('4 keys')).toBeTruthy() + expect(api.sessions.getCredentials).toHaveBeenCalledWith(tab.sessionId) + expect(api.redis.connect).toHaveBeenCalledWith({ + host: session.host, + port: 6379, + password: 'pw', + db: 2, + }) + expect(screen.getByText('Redis · DB 2')).toBeTruthy() + expect(screen.getByText('Cache')).toBeTruthy() + expect(screen.getByText('user:1')).toBeTruthy() + expect(screen.getByText('Select a key to view its value')).toBeTruthy() + }) + + it('selects a string key on click and shows type, TTL and value', async () => { + const { api } = setup() + fireEvent.click(await screen.findByText('user:1')) + expect(await screen.findByText('hello world')).toBeTruthy() + expect(api.redis.get).toHaveBeenCalledWith('redis-1', 'user:1') + expect(screen.getByText('string')).toBeTruthy() + expect(screen.getByText('TTL: No expiry')).toBeTruthy() + // Close the value pane + const header = screen.getAllByText('user:1').find(el => el.classList.contains('font-medium'))! + fireEvent.click(header.closest('div')!.parentElement!.querySelector('button:last-of-type') as HTMLElement) + expect(await screen.findByText('Select a key to view its value')).toBeTruthy() + }) + + it('selects hash and list keys through the key row buttons', async () => { + const { api } = setup() + const row = (await screen.findByText('user:profile')).closest('button') as HTMLElement + fireEvent.click(row) + expect(await screen.findByText('sean')).toBeTruthy() + expect(screen.getByText('TTL: 3600s')).toBeTruthy() + expect(screen.getByText('name')).toBeTruthy() + + const listRow = screen.getByText('queue:jobs').closest('button') as HTMLElement + fireEvent.click(listRow) + expect(await screen.findByText('job-a')).toBeTruthy() + expect(screen.getByText('job-b')).toBeTruthy() + expect(api.redis.get).toHaveBeenCalledTimes(2) + }) + + it('renders unknown types as JSON', async () => { + setup() + fireEvent.click(await screen.findByText('scores')) + expect(await screen.findByText('zset')).toBeTruthy() + expect(screen.getByText(/"member": "a"/)).toBeTruthy() + }) + + it('deletes a key and clears the value pane if it was selected', async () => { + const { api } = setup() + fireEvent.click(await screen.findByText('user:1')) + await screen.findByText('hello world') + // The key button and its sibling delete button live in the same row container + const keyButton = screen.getAllByText('user:1')[0].closest('button') as HTMLElement + fireEvent.click(keyButton.nextElementSibling as HTMLElement) + await waitFor(() => expect(api.redis.del).toHaveBeenCalledWith('redis-1', 'user:1')) + expect(screen.queryByText('hello world')).toBeNull() + expect(await screen.findByText('3 keys')).toBeTruthy() + }) + + it('re-queries keys with the search pattern on Enter', async () => { + const { api } = setup() + const input = await screen.findByPlaceholderText('Pattern (e.g. user:*)') + api.redis.keys.mockResolvedValueOnce(['user:1', 'user:profile']) + fireEvent.change(input, { target: { value: 'user:*' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + await waitFor(() => expect(api.redis.keys).toHaveBeenLastCalledWith('redis-1', 'user:*')) + expect(await screen.findByText('2 keys')).toBeTruthy() + expect(screen.queryByText('queue:jobs')).toBeNull() + }) + + it('runs CLI commands and records results and errors', async () => { + const { api } = setup() + await screen.findByText('4 keys') + fireEvent.click(screen.getByText('CLI')) + expect(screen.getByText(/Type a Redis command below/)).toBeTruthy() + + const input = screen.getByPlaceholderText('Enter command…') + fireEvent.change(input, { target: { value: 'DBSIZE' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(await screen.findByText('DBSIZE')).toBeTruthy() + expect(screen.getByText('42')).toBeTruthy() + expect(api.redis.command).toHaveBeenCalledWith('redis-1', 'DBSIZE') + + api.redis.command.mockRejectedValueOnce(new Error('unknown command')) + fireEvent.change(input, { target: { value: 'BOGUS' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(await screen.findByText('ERR: unknown command')).toBeTruthy() + + // Back to the browser tab + fireEvent.click(screen.getByText('Key Browser')) + expect(screen.getByText('user:1')).toBeTruthy() + }) + + it('renders the retry screen when the initial connection fails, then recovers', async () => { + const session = makeSession({ label: 'Broken', type: 'redis' }) + const tab = makeTab({ view: 'redis', sessionId: session.id }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + const api = installWindowApi({ + redis: { + connect: vi.fn().mockRejectedValueOnce(new Error('ECONNREFUSED')).mockResolvedValue('redis-2'), + keys: vi.fn().mockResolvedValue(['a']), + get: vi.fn().mockResolvedValue({ type: 'string', value: 'x', ttl: -1 }), + }, + }) + render() + expect(await screen.findByText('ECONNREFUSED')).toBeTruthy() + fireEvent.click(screen.getByText('Retry')) + expect(await screen.findByText('1 keys')).toBeTruthy() + expect(api.redis.connect).toHaveBeenCalledTimes(2) + }) + + it('maps locked-keychain credential errors to a friendly message', async () => { + const session = makeSession({ type: 'redis' }) + const tab = makeTab({ view: 'redis', sessionId: session.id }) + seedStore({ sessions: [session], tabs: [tab], activeTabId: tab.id }) + installWindowApi({ + sessions: { + getCredentials: vi.fn().mockRejectedValue(new Error('credential store is locked')), + }, + }) + render() + expect(await screen.findByText('App is locked — unlock noxed to reconnect')).toBeTruthy() + }) + + it('disconnects the client on unmount', async () => { + const { api, unmount } = setup() + await screen.findByText('4 keys') + unmount() + await waitFor(() => expect(api.redis.disconnect).toHaveBeenCalledWith('redis-1')) + }) +}) diff --git a/src/renderer/src/components/Runner/RunnerView.tsx b/src/renderer/src/components/Runner/RunnerView.tsx index 8faaa22..8c98461 100644 --- a/src/renderer/src/components/Runner/RunnerView.tsx +++ b/src/renderer/src/components/Runner/RunnerView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { Check, ChevronDown, ChevronRight, Loader2, Play, Square, TerminalSquare } from 'lucide-react' import { useAppStore, Session } from '../../store' @@ -19,35 +19,42 @@ export default function RunnerView() { const [selected, setSelected] = useState>(new Set()) const [command, setCommand] = useState('') const [results, setResults] = useState>(new Map()) - const [running, setRunning] = useState(false) + const running = useMemo(() => [...results.values()].some(v => v.state === 'running'), [results]) const runIdRef = useRef(null) + const applyOutput = (sessionId: string, data: string) => { + setResults(prev => { + const next = new Map(prev) + const r = next.get(sessionId) + if (r) next.set(sessionId, { ...r, output: r.output + data }) + return next + }) + } + + const applyDone = (sessionId: string, exitCode: number | null, error: string | null) => { + setResults(prev => { + const next = new Map(prev) + const r = next.get(sessionId) + if (r) { + next.set(sessionId, { + ...r, + state: error || exitCode !== 0 ? 'failed' : 'done', + exitCode, + error, + }) + } + return next + }) + } + useEffect(() => { const offOutput = window.api.runner.onOutput((runId, sessionId, data) => { if (runId !== runIdRef.current) return - setResults(prev => { - const next = new Map(prev) - const r = next.get(sessionId) - if (r) next.set(sessionId, { ...r, output: r.output + data }) - return next - }) + applyOutput(sessionId, data) }) const offDone = window.api.runner.onDone((runId, sessionId, exitCode, error) => { if (runId !== runIdRef.current) return - setResults(prev => { - const next = new Map(prev) - const r = next.get(sessionId) - if (r) { - next.set(sessionId, { - ...r, - state: error || exitCode !== 0 ? 'failed' : 'done', - exitCode, - error, - }) - } - setRunning([...next.values()].some(v => v.state === 'running')) - return next - }) + applyDone(sessionId, exitCode, error) }) return () => { offOutput() @@ -73,11 +80,9 @@ export default function RunnerView() { if (!command.trim() || selected.size === 0 || running) return const ids = [...selected] setResults(new Map(ids.map(id => [id, { state: 'running' as HostState, output: '', exitCode: null, error: null }]))) - setRunning(true) try { runIdRef.current = await window.api.runner.run(ids, command) } catch (err: any) { - setRunning(false) setResults(new Map()) addNotification({ type: 'error', message: err?.message ?? 'Run failed' }) } @@ -93,7 +98,6 @@ export default function RunnerView() { } return next }) - setRunning(false) } const sessionById = (id: string) => sessions.find(s => s.id === id) @@ -127,7 +131,9 @@ export default function RunnerView() { onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = 'transparent' }} > -
toggle(s.id)} > {checked && } -
+ {s.label || s.host} @@ -218,15 +224,23 @@ export default function RunnerView() { ) } -function HostResultCard({ session, result }: { session?: Session; result: HostResult }) { +function resultBadge(result: HostResult): { text: string; color: string } { + if (result.state === 'running') return { text: 'running', color: '#3B5CCC' } + if (result.state === 'done') return { text: 'exit 0', color: '#10B981' } + return { text: result.error ? 'error' : `exit ${result.exitCode}`, color: '#EF4444' } +} + +function resultText(result: HostResult): string { + if (result.error) return result.error + if (result.output) return result.output + return result.state === 'running' ? '…' : '(no output)' +} + +function HostResultCard({ session, result }: Readonly<{ session?: Session; result: HostResult }>) { const [open, setOpen] = useState(true) const label = session ? (session.label || session.host) : 'unknown host' - const badge = result.state === 'running' - ? { text: 'running', color: '#3B5CCC' } - : result.state === 'done' - ? { text: 'exit 0', color: '#10B981' } - : { text: result.error ? 'error' : `exit ${result.exitCode}`, color: '#EF4444' } + const badge = resultBadge(result) return (
@@ -252,7 +266,7 @@ function HostResultCard({ session, result }: { session?: Session; result: HostRe className="m-0 px-4 py-3 font-mono text-[11.5px] leading-relaxed whitespace-pre-wrap break-all max-h-72 overflow-y-auto select-text" style={{ background: 'var(--nox-bg)', color: 'var(--nox-text-2)', borderTop: '1px solid var(--nox-border)' }} > - {result.error ? result.error : result.output || (result.state === 'running' ? '…' : '(no output)')} + {resultText(result)} )}
diff --git a/src/renderer/src/components/Runner/__tests__/RunnerView.test.tsx b/src/renderer/src/components/Runner/__tests__/RunnerView.test.tsx new file mode 100644 index 0000000..339cc8f --- /dev/null +++ b/src/renderer/src/components/Runner/__tests__/RunnerView.test.tsx @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react' +import RunnerView from '../RunnerView' +import { installWindowApi, seedStore, makeSession, type WindowApiMock } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +type OutputCb = (runId: string, sessionId: string, data: string) => void +type DoneCb = (runId: string, sessionId: string, exitCode: number | null, error: string | null) => void + +function setup(runResult: 'ok' | 'fail' = 'ok') { + const host1 = makeSession({ label: 'web-1', type: 'ssh' }) + const host2 = makeSession({ label: 'web-2', type: 'ssh' }) + const redis = makeSession({ label: 'cache', type: 'redis' }) + seedStore({ sessions: [host1, host2, redis], notifications: [] }) + + let outputCb: OutputCb = () => {} + let doneCb: DoneCb = () => {} + const api = installWindowApi({ + runner: { + run: + runResult === 'ok' + ? vi.fn().mockResolvedValue('run-1') + : vi.fn().mockRejectedValue(new Error('spawn failed')), + cancel: vi.fn().mockResolvedValue(undefined), + onOutput: vi.fn().mockImplementation((cb: OutputCb) => { + outputCb = cb + return () => {} + }), + onDone: vi.fn().mockImplementation((cb: DoneCb) => { + doneCb = cb + return () => {} + }), + }, + }) + render() + return { host1, host2, redis, api, emitOutput: (...a: Parameters) => outputCb(...a), emitDone: (...a: Parameters) => doneCb(...a) } +} + +function checkboxFor(label: string): HTMLElement { + return screen.getByText(label).closest('label')!.querySelector('button') as HTMLElement +} + +async function startRun(host1: ReturnType, api: WindowApiMock, command = 'uptime') { + fireEvent.click(checkboxFor(host1.label)) + fireEvent.change(screen.getByPlaceholderText(/runs on every selected host/), { target: { value: command } }) + fireEvent.click(screen.getByText('Run')) + await waitFor(() => expect(api.runner.run).toHaveBeenCalledWith([host1.id], command)) +} + +describe('RunnerView', () => { + beforeEach(() => { + seedStore({ sessions: [], notifications: [] }) + }) + + it('lists only SSH hosts and shows the empty state', () => { + setup() + expect(screen.getByText('Hosts (0/2)')).toBeTruthy() + expect(screen.getByText('web-1')).toBeTruthy() + expect(screen.getByText('web-2')).toBeTruthy() + expect(screen.queryByText('cache')).toBeNull() + expect(screen.getByText('Run a command across your fleet')).toBeTruthy() + }) + + it('toggles hosts via the checkbox button and reflects state in aria-pressed', () => { + setup() + const box = checkboxFor('web-1') + expect(box.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(box) + expect(screen.getByText('Hosts (1/2)')).toBeTruthy() + expect(box.getAttribute('aria-pressed')).toBe('true') + fireEvent.click(box) + expect(screen.getByText('Hosts (0/2)')).toBeTruthy() + expect(box.getAttribute('aria-pressed')).toBe('false') + }) + + it('selects all / none via the header toggle', () => { + setup() + fireEvent.click(screen.getByText('All')) + expect(screen.getByText('Hosts (2/2)')).toBeTruthy() + fireEvent.click(screen.getByText('None')) + expect(screen.getByText('Hosts (0/2)')).toBeTruthy() + }) + + it('runs a command and streams output/exit codes into result cards', async () => { + const { host1, host2, api, emitOutput, emitDone } = setup() + fireEvent.click(screen.getByText('All')) + fireEvent.change(screen.getByPlaceholderText(/runs on every selected host/), { target: { value: 'uptime' } }) + fireEvent.click(screen.getByText('Run')) + + await waitFor(() => expect(api.runner.run).toHaveBeenCalledWith([host1.id, host2.id], 'uptime')) + expect(screen.getAllByText('running')).toHaveLength(2) + + // Output for a stale run id is ignored + act(() => emitOutput('stale-run', host1.id, 'IGNORED')) + expect(screen.queryByText(/IGNORED/)).toBeNull() + + act(() => { + emitOutput('run-1', host1.id, 'load average: 0.1') + emitOutput('run-1', host1.id, '\nmore') + }) + expect(screen.getByText(/load average: 0\.1/)).toBeTruthy() + + act(() => emitDone('run-1', host1.id, 0, null)) + expect(screen.getByText('exit 0')).toBeTruthy() + expect(screen.getByText('Cancel')).toBeTruthy() // still running host2 + + act(() => emitDone('run-1', host2.id, 3, null)) + expect(screen.getByText('exit 3')).toBeTruthy() + // Everything finished — Run button returns + expect(screen.getByText('Run')).toBeTruthy() + + // Stale done is ignored too + act(() => emitDone('other', host1.id, 1, null)) + expect(screen.getByText('exit 0')).toBeTruthy() + }) + + it('marks a host as error when done reports an error message', async () => { + const { host1, api, emitDone } = setup() + await startRun(host1, api) + act(() => emitDone('run-1', host1.id, null, 'ssh: connection refused')) + expect(screen.getByText('error')).toBeTruthy() + expect(screen.getByText('ssh: connection refused')).toBeTruthy() + }) + + it('supports Cmd+Enter to run and collapsing a result card', async () => { + const { host1, api, emitOutput } = setup() + fireEvent.click(checkboxFor('web-1')) + const input = screen.getByPlaceholderText(/runs on every selected host/) + fireEvent.change(input, { target: { value: 'whoami' } }) + fireEvent.keyDown(input, { key: 'Enter', metaKey: true }) + await waitFor(() => expect(api.runner.run).toHaveBeenCalledWith([host1.id], 'whoami')) + + act(() => emitOutput('run-1', host1.id, 'root')) + expect(screen.getByText('root')).toBeTruthy() + // Collapse the card header — the pre disappears. The sidebar label is not + // inside a
) } /* ── Breadcrumb ───────────────────────────────────────────────────────────── */ -function Breadcrumb({ segments, onNavigate }: { +function Breadcrumb({ segments, onNavigate }: Readonly<{ segments: string[] onNavigate: (idx: number) => void -}) { +}>) { if (segments.length === 0) { return ( / @@ -368,11 +366,11 @@ function Breadcrumb({ segments, onNavigate }: { } /* ── Header button ────────────────────────────────────────────────────────── */ -function HeaderButton({ title, onClick, children }: { +function HeaderButton({ title, onClick, children }: Readonly<{ title: string onClick: () => void children: React.ReactNode -}) { +}>) { return ( {onDelete && }} + + ) +} + +function Th({ label, sk, pane, onSort, align }: Readonly<{ label: string; sk: SortKey; pane: PaneState; onSort: (k: SortKey) => void; align?: 'right' }>) { const active = pane.sortKey === sk return onSort(sk)}>{label}{active && } } -function Btn({ title, onClick, disabled, active, children }: { title: string; onClick: () => void; disabled?: boolean; active?: boolean; children: React.ReactNode }) { +function Btn({ title, onClick, disabled, active, children }: Readonly<{ title: string; onClick: () => void; disabled?: boolean; active?: boolean; children: React.ReactNode }>) { return } diff --git a/src/renderer/src/components/SFTP/__tests__/FilesDrawer.test.tsx b/src/renderer/src/components/SFTP/__tests__/FilesDrawer.test.tsx new file mode 100644 index 0000000..ed3137e --- /dev/null +++ b/src/renderer/src/components/SFTP/__tests__/FilesDrawer.test.tsx @@ -0,0 +1,57 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import FilesDrawer from '../FilesDrawer' +import { installWindowApi, seedStore, makeSession, makeTab, WindowApiMock } from '../../../__tests__/harness' + +const entries = [ + { name: 'zeta.txt', size: 2048, mtime: 0, isDirectory: false }, + { name: 'etc', size: 0, mtime: 0, isDirectory: true }, + { name: 'alpha.txt', size: 10, mtime: 0, isDirectory: false }, +] + +let api: WindowApiMock + +describe('FilesDrawer', () => { + beforeEach(() => { + api = installWindowApi({ sftp: { list: vi.fn().mockResolvedValue(entries) } }) + seedStore({ + sessions: [makeSession({ id: 's1', host: 'files.example.com' })], + tabs: [], notifications: [], + }) + }) + + function renderDrawer() { + const tab = makeTab({ sessionId: 's1', streamId: 'stream-1' }) + return render() + } + + it('lists directory entries with directories first, then files alphabetically', async () => { + const { container } = renderDrawer() + await waitFor(() => expect(screen.getByText('alpha.txt')).toBeTruthy()) + + const text = container.textContent ?? '' + const dir = text.indexOf('etc') + const a = text.indexOf('alpha.txt') + const z = text.indexOf('zeta.txt') + expect(dir).toBeLessThan(a) + expect(a).toBeLessThan(z) + + // Footer shows the entry count + expect(screen.getByText('3 items')).toBeTruthy() + }) + + it('navigates into directories', async () => { + renderDrawer() + await waitFor(() => expect(screen.getByText('etc')).toBeTruthy()) + + fireEvent.click(screen.getByTitle('Open')) + await waitFor(() => expect(api.sftp.list).toHaveBeenCalledWith('sftp-1', '/etc')) + }) + + it('shows the connection error state when SFTP fails', async () => { + api.sftp.connect.mockRejectedValueOnce(new Error('auth failed')) + renderDrawer() + await waitFor(() => expect(screen.getByText('auth failed')).toBeTruthy()) + }) +}) diff --git a/src/renderer/src/components/SFTP/__tests__/SftpBrowser.test.tsx b/src/renderer/src/components/SFTP/__tests__/SftpBrowser.test.tsx new file mode 100644 index 0000000..69a5efd --- /dev/null +++ b/src/renderer/src/components/SFTP/__tests__/SftpBrowser.test.tsx @@ -0,0 +1,338 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import SftpBrowser from '../SftpBrowser' +import { installWindowApi, seedStore, makeSession, makeTab } from '../../../__tests__/harness' + +interface Entry { name: string; size: number; mtime: number; permissions: number; isDirectory: boolean } +const f = (name: string, size = 0, mtime = 0, isDirectory = false): Entry => + ({ name, size, mtime, permissions: 0o644, isDirectory }) + +const LOCAL_ENTRIES = [f('docs', 0, 0, true), f('notes.txt', 300, 1000), f('big.log', 900, 500), f('.env', 10, 1)] +const REMOTE_ENTRIES = [f('etc', 0, 0, true), f('alpha.txt', 50, 900), f('zeta.txt', 5, 100), f('.bashrc', 1, 1)] + +function setup(overrides: Record = {}) { + const api = installWindowApi({ + localfs: { + home: vi.fn().mockResolvedValue('/home/user'), + list: vi.fn().mockResolvedValue(LOCAL_ENTRIES), + readTextFile: vi.fn().mockResolvedValue('local file contents'), + }, + sftp: { + connect: vi.fn().mockResolvedValue('sftp-1'), + list: vi.fn().mockResolvedValue(REMOTE_ENTRIES), + readFile: vi.fn().mockResolvedValue('remote file contents'), + }, + ...overrides, + }) + const session = makeSession({ type: 'sftp' }) + seedStore({ sessions: [session], tabs: [], activeTabId: null }) + // streamId → connectSftp piggybacks on the live SSH stream, no credential lookup + const tab = makeTab({ view: 'sftp', sessionId: session.id, streamId: 'stream-1' }) + const utils = render() + return { api, session, tab, ...utils } +} + +/** First-column names of a pane's table (0 = local, 1 = remote). */ +function paneNames(container: HTMLElement, idx: 0 | 1): string[] { + const table = container.querySelectorAll('table')[idx] + return [...table.querySelectorAll('tbody tr td:first-child')].map(td => td.textContent ?? '') +} + +async function ready() { + await waitFor(() => expect(screen.getByText('alpha.txt')).toBeTruthy()) +} + +beforeEach(() => { + cleanup() +}) + +describe('SftpBrowser — connect and listing', () => { + it('connects and renders both panes with directories first and hidden files filtered', async () => { + const { container } = setup() + await ready() + const remote = paneNames(container, 1) + expect(remote[0]).toContain('etc') + expect(remote.join()).toContain('alpha.txt') + expect(remote.join()).toContain('zeta.txt') + expect(remote.join()).not.toContain('.bashrc') + const local = paneNames(container, 0) + expect(local.some(n => n.includes('docs'))).toBe(true) + expect(local.join()).not.toContain('.env') + // status bar counts exclude hidden files + expect(screen.getByText('3 items')).toBeTruthy() + expect(screen.getByText('3 items · /')).toBeTruthy() + }) + + it('shows hidden files when the eye toggle is flipped', async () => { + setup() + await ready() + expect(screen.queryByText('.bashrc')).toBeNull() + fireEvent.click(screen.getAllByTitle('Hidden files')[1]) // remote pane + expect(screen.getByText('.bashrc')).toBeTruthy() + fireEvent.click(screen.getAllByTitle('Hidden files')[0]) // local pane + expect(screen.getByText('.env')).toBeTruthy() + }) + + it('shows a retry screen when the connection fails, and retries', async () => { + const connect = vi.fn().mockRejectedValueOnce(new Error('auth failed')).mockResolvedValue('sftp-1') + setup({ sftp: { connect, list: vi.fn().mockResolvedValue(REMOTE_ENTRIES) } }) + await waitFor(() => expect(screen.getByText('auth failed')).toBeTruthy()) + fireEvent.click(screen.getByText('Retry')) + await waitFor(() => expect(screen.getByText('alpha.txt')).toBeTruthy()) + expect(connect).toHaveBeenCalledTimes(2) + }) + + it('surfaces a remote listing error inside the pane', async () => { + setup({ + sftp: { + connect: vi.fn().mockResolvedValue('sftp-1'), + list: vi.fn().mockRejectedValue(new Error('permission denied')), + }, + }) + await waitFor(() => expect(screen.getByText('permission denied')).toBeTruthy()) + }) +}) + +describe('SftpBrowser — sorting', () => { + it('sorts by size ascending, then descending on second click', async () => { + const { container } = setup() + await ready() + const sizeHeader = screen.getAllByText('Size')[1] // remote table + fireEvent.click(sizeHeader) + let names = paneNames(container, 1) + expect(names.findIndex(n => n.includes('zeta.txt'))).toBeLessThan(names.findIndex(n => n.includes('alpha.txt'))) + // directories always sort first + expect(names[0]).toContain('etc') + fireEvent.click(sizeHeader) + names = paneNames(container, 1) + expect(names.findIndex(n => n.includes('alpha.txt'))).toBeLessThan(names.findIndex(n => n.includes('zeta.txt'))) + }) + + it('sorts by modified time', async () => { + const { container } = setup() + await ready() + fireEvent.click(screen.getAllByText('Modified')[1]) + const names = paneNames(container, 1) + // zeta mtime 100 < alpha mtime 900 + expect(names.findIndex(n => n.includes('zeta.txt'))).toBeLessThan(names.findIndex(n => n.includes('alpha.txt'))) + }) + + it('sorts by name descending after toggling the name column', async () => { + const { container } = setup() + await ready() + fireEvent.click(screen.getAllByText('Name')[1]) // already name asc → flips to desc + const names = paneNames(container, 1) + expect(names.findIndex(n => n.includes('zeta.txt'))).toBeLessThan(names.findIndex(n => n.includes('alpha.txt'))) + }) +}) + +describe('SftpBrowser — navigation', () => { + it('descends into a directory on double click and back up via ..', async () => { + const { api } = setup() + await ready() + fireEvent.doubleClick(screen.getByText('etc')) + await waitFor(() => expect(api.sftp.list).toHaveBeenCalledWith('sftp-1', '/etc')) + // ".." row appears once path is not root (local pane already shows one for /home/user) + const ups = await screen.findAllByText('..') + fireEvent.click(ups[ups.length - 1]) // remote pane is rendered last + await waitFor(() => expect(api.sftp.list).toHaveBeenLastCalledWith('sftp-1', '/')) + }) + + it('navigates the local pane up from the home directory', async () => { + const { api } = setup() + await ready() + fireEvent.click(screen.getAllByTitle('Up')[0]) // local pane, path /home/user + await waitFor(() => expect(api.localfs.list).toHaveBeenCalledWith('/home')) + }) + + it('disables the Up button at the remote root', async () => { + setup() + await ready() + const up = screen.getAllByTitle('Up')[1] as HTMLButtonElement + expect(up.disabled).toBe(true) + }) +}) + +describe('SftpBrowser — selection and transfers', () => { + it('downloads the selected remote file', async () => { + const { api } = setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.click(screen.getByText('Download')) + await waitFor(() => + expect(api.sftp.download).toHaveBeenCalledWith('sftp-1', '/alpha.txt', '/home/user/alpha.txt'), + ) + }) + + it('extends the selection with meta-click and downloads both', async () => { + const { api } = setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.click(screen.getByText('zeta.txt'), { metaKey: true }) + fireEvent.click(screen.getByText('Download')) + await waitFor(() => expect(api.sftp.download).toHaveBeenCalledTimes(2)) + }) + + it('selects a range with shift-click', async () => { + const { api } = setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.click(screen.getByText('zeta.txt'), { shiftKey: true }) + fireEvent.click(screen.getByText('Download')) + await waitFor(() => expect(api.sftp.download).toHaveBeenCalledTimes(2)) + }) + + it('uploads a selected local file and shows the transfer in the queue', async () => { + const { api } = setup() + await ready() + fireEvent.click(screen.getByText('notes.txt')) + fireEvent.click(screen.getByText('Upload')) + await waitFor(() => + expect(api.sftp.upload).toHaveBeenCalledWith('sftp-1', '/home/user/notes.txt', '/notes.txt'), + ) + // transfer row rendered in the queue (name appears twice: table + queue) + await waitFor(() => expect(screen.getAllByText('notes.txt').length).toBeGreaterThan(1)) + }) + + it('marks a failed upload and shows the error', async () => { + setup({ + sftp: { + connect: vi.fn().mockResolvedValue('sftp-1'), + list: vi.fn().mockResolvedValue(REMOTE_ENTRIES), + upload: vi.fn().mockRejectedValue(new Error('disk full')), + }, + }) + await ready() + fireEvent.click(screen.getByText('notes.txt')) + fireEvent.click(screen.getByText('Upload')) + await waitFor(() => expect(screen.getByText('disk full')).toBeTruthy()) + }) + + it('meta-click on a selected row deselects it', async () => { + setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.click(screen.getByText('alpha.txt'), { metaKey: true }) + const download = screen.getByText('Download').closest('button') as HTMLButtonElement + expect(download.disabled).toBe(true) + }) +}) + +describe('SftpBrowser — quick look', () => { + it('opens the remote quick look with Space and closes with Escape', async () => { + const { api } = setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.keyDown(document.body, { key: ' ' }) + await waitFor(() => expect(screen.getByText('remote file contents')).toBeTruthy()) + expect(api.sftp.readFile).toHaveBeenCalledWith('sftp-1', '/alpha.txt') + expect(screen.getByText('Press Space to close · Esc to dismiss')).toBeTruthy() + fireEvent.keyDown(document.body, { key: 'Escape' }) + await waitFor(() => expect(screen.queryByText('remote file contents')).toBeNull()) + }) + + it('reads local files for quick look when the local pane is focused', async () => { + const { api, container } = setup() + await ready() + // focus local pane, then select a local file + fireEvent.click(container.querySelectorAll('table')[0]) + fireEvent.click(screen.getByText('notes.txt')) + fireEvent.keyDown(document.body, { key: ' ' }) + await waitFor(() => expect(screen.getByText('local file contents')).toBeTruthy()) + expect(api.localfs.readTextFile).toHaveBeenCalledWith('/home/user/notes.txt') + }) + + it('falls back to a placeholder for unreadable files', async () => { + setup({ + sftp: { + connect: vi.fn().mockResolvedValue('sftp-1'), + list: vi.fn().mockResolvedValue(REMOTE_ENTRIES), + readFile: vi.fn().mockRejectedValue(new Error('binary')), + }, + }) + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.keyDown(document.body, { key: ' ' }) + await waitFor(() => expect(screen.getByText('(binary or unreadable file)')).toBeTruthy()) + }) + + it('pressing Space again on the same file closes the overlay', async () => { + setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.keyDown(document.body, { key: ' ' }) + await waitFor(() => expect(screen.getByText('remote file contents')).toBeTruthy()) + fireEvent.keyDown(document.body, { key: ' ' }) + await waitFor(() => expect(screen.queryByText('remote file contents')).toBeNull()) + }) + + it('closes via the X button', async () => { + setup() + await ready() + fireEvent.click(screen.getByText('alpha.txt')) + fireEvent.keyDown(document.body, { key: ' ' }) + const pre = await screen.findByText('remote file contents') + const overlayHeader = pre.previousElementSibling as HTMLElement + fireEvent.click(overlayHeader.querySelector('button')!) + await waitFor(() => expect(screen.queryByText('remote file contents')).toBeNull()) + }) +}) + +describe('SftpBrowser — diff mode and file management', () => { + it('toggles diff mode and shows the legend', async () => { + // overlapping names so all diff states occur + setup({ + localfs: { + home: vi.fn().mockResolvedValue('/home/user'), + list: vi.fn().mockResolvedValue([f('same.txt', 10, 1), f('changed.txt', 20, 1), f('onlylocal.txt', 5, 1)]), + }, + sftp: { + connect: vi.fn().mockResolvedValue('sftp-1'), + list: vi.fn().mockResolvedValue([f('same.txt', 10, 1), f('changed.txt', 99, 1), f('onlyremote.txt', 7, 1)]), + }, + }) + await waitFor(() => expect(screen.getByText('onlyremote.txt')).toBeTruthy()) + fireEvent.click(screen.getByText('Diff')) + expect(screen.getByText('local only')).toBeTruthy() + expect(screen.getByText('remote only')).toBeTruthy() + expect(screen.getByText('different')).toBeTruthy() + }) + + it('creates a remote folder via prompt', async () => { + vi.stubGlobal('prompt', vi.fn(() => 'newdir')) + const { api } = setup() + await ready() + fireEvent.click(screen.getByTitle('New folder')) + await waitFor(() => expect(api.sftp.mkdir).toHaveBeenCalledWith('sftp-1', '/newdir')) + vi.unstubAllGlobals() + }) + + it('renames a remote file via the row action', async () => { + vi.stubGlobal('prompt', vi.fn(() => 'renamed.txt')) + const { api } = setup() + await ready() + fireEvent.click(screen.getAllByTitle('Rename')[1]) // row 0 is the etc dir; row 1 is alpha.txt + await waitFor(() => + expect(api.sftp.rename).toHaveBeenCalledWith('sftp-1', '/alpha.txt', '/renamed.txt'), + ) + vi.unstubAllGlobals() + }) + + it('deletes a remote file after confirmation', async () => { + vi.stubGlobal('confirm', vi.fn(() => true)) + const { api } = setup() + await ready() + fireEvent.click(screen.getAllByTitle('Delete')[1]) // row 0 is the etc dir; row 1 is alpha.txt + await waitFor(() => expect(api.sftp.delete).toHaveBeenCalledWith('sftp-1', '/alpha.txt')) + vi.unstubAllGlobals() + }) + + it('refreshes the remote pane via the refresh button', async () => { + const { api } = setup() + await ready() + const before = api.sftp.list.mock.calls.length + fireEvent.click(screen.getAllByTitle('Refresh')[1]) + await waitFor(() => expect(api.sftp.list.mock.calls.length).toBeGreaterThan(before)) + }) +}) diff --git a/src/renderer/src/components/ServerContextMenu.tsx b/src/renderer/src/components/ServerContextMenu.tsx index 7ea69c4..c9a569e 100644 --- a/src/renderer/src/components/ServerContextMenu.tsx +++ b/src/renderer/src/components/ServerContextMenu.tsx @@ -35,9 +35,9 @@ export function useMenuBehavior(x: number, y: number, onClose: () => void) { return { menuRef, pos } } -export function MenuItem({ icon, label, onClick, danger, hasSubmenu }: { +export function MenuItem({ icon, label, onClick, danger, hasSubmenu }: Readonly<{ icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean; hasSubmenu?: boolean -}) { +}>) { return ( +
+ onConfirmChange(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') onSubmit() }} + placeholder="Confirm password" + className="w-full rounded-lg px-4 py-2.5 font-['Inter'] text-[13px] focus:outline-none focus:ring-2 focus:ring-[#3B5CCC]" + style={{ background: 'var(--nox-bg)', border: '1px solid var(--nox-border)', color: 'var(--nox-text)' }} + /> + {error &&

{error}

} + +
+ ) +} + function ChangeAuthModal({ currentMode, onClose, onDone, -}: { +}: Readonly<{ currentMode: AuthMode onClose: () => void onDone: (mode: AuthMode) => void -}) { +}>) { // step: 'verify' (verify current) → 'select' (pick new mode) → 'set' (enter new credential) const [step, setStep] = useState<'verify' | 'select' | 'set'>( currentMode === 'none' ? 'select' : 'verify' @@ -780,11 +873,13 @@ function ChangeAuthModal({ } } - const handleSetSubmit = async () => { + // `confirmPin` carries the just-completed confirm digits from SetPinInput — + // the `confirmDigits` state may not be committed yet when submit fires. + const handleSetSubmit = async (confirmPin?: string[]) => { if (!selectedMode) return if (selectedMode === 'pin') { const pin = newDigits.join('') - const conf = confirmDigits.join('') + const conf = (confirmPin ?? confirmDigits).join('') if (pin.length !== 4) { setError('Enter a 4-digit PIN'); return } if (pin !== conf) { setError('PINs do not match'); setConfirmDigits([]); setPinPhase('enter'); setNewDigits([]); return } applyNewMode(selectedMode, pin) @@ -796,7 +891,11 @@ function ChangeAuthModal({ } return ( -
{ if (e.target === e.currentTarget) onClose() }}> +
{ if (e.target === e.currentTarget) onClose() }} + onKeyDown={e => { if (e.key === 'Escape') onClose() }} + >

- {step === 'verify' ? 'Confirm Your Identity' : step === 'select' ? 'Choose Lock Method' : 'Set New ' + (selectedMode === 'pin' ? 'PIN' : 'Password')} + {authModalTitle(step, selectedMode)}

- {step === 'verify' ? 'Enter your current credential to continue' : step === 'select' ? 'How should noxed lock on startup?' : selectedMode === 'pin' ? 'Choose a 4-digit PIN' : 'Choose a strong password'} + {authModalSubtitle(step, selectedMode)}

{/* ── VERIFY STEP ── */} {step === 'verify' && currentMode === 'biometrics' && ( -
-
- -
-

- {loading ? 'Waiting for Touch ID…' : 'Place your finger on the sensor'} -

- {error &&

{error}

} - {!loading && ( - - )} -
+ )} {step === 'verify' && currentMode === 'pin' && ( @@ -912,40 +988,17 @@ function ChangeAuthModal({ )} {step === 'set' && selectedMode === 'password' && ( -
-
- setNewCredential(e.target.value)} - placeholder="New password" - autoFocus - className="w-full rounded-lg px-4 py-2.5 font-['Inter'] text-[13px] focus:outline-none focus:ring-2 focus:ring-[#3B5CCC]" - style={{ background: 'var(--nox-bg)', border: '1px solid var(--nox-border)', color: 'var(--nox-text)' }} - /> - -
- setConfirmCredential(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') handleSetSubmit() }} - placeholder="Confirm password" - className="w-full rounded-lg px-4 py-2.5 font-['Inter'] text-[13px] focus:outline-none focus:ring-2 focus:ring-[#3B5CCC]" - style={{ background: 'var(--nox-bg)', border: '1px solid var(--nox-border)', color: 'var(--nox-text)' }} - /> - {error &&

{error}

} - -
+ setShowNew(s => !s)} + onSubmit={() => handleSetSubmit()} + /> )}
@@ -967,19 +1020,28 @@ function ChangeAuthModal({ } /* ── Verify PIN input (compact numpad for modal) ─────────────────────────── */ -function VerifyPinInput({ loading, error, onSubmit }: { +function VerifyPinInput({ loading, error, onSubmit }: Readonly<{ loading: boolean; error: string; onSubmit: (pin: string) => void -}) { +}>) { const [digits, setDigits] = useState([]) + const pressKey = (key: string) => { + if (key === '⌫') { + setDigits(d => d.slice(0, -1)) + return + } + if (loading || digits.length >= 4) return + const next = [...digits, key] + setDigits(next) + if (next.length === 4) { + setTimeout(() => { onSubmit(next.join('')); setDigits([]) }, 80) + } + } + useEffect(() => { const handler = (e: KeyboardEvent) => { if (loading) return - if (/^[0-9]$/.test(e.key) && digits.length < 4) { - const next = [...digits, e.key] - setDigits(next) - if (next.length === 4) { onSubmit(next.join('')); setDigits([]) } - } + if (/^\d$/.test(e.key)) pressKey(e.key) if (e.key === 'Backspace') setDigits(d => d.slice(0, -1)) } window.addEventListener('keydown', handler) @@ -996,17 +1058,12 @@ function VerifyPinInput({ loading, error, onSubmit }: {
{error &&

{error}

}
- {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((key, i) => { - if (key === '') return
+ {['1','2','3','4','5','6','7','8','9','','0','⌫'].map((key) => { + if (key === '') return
const isDel = key === '⌫' return (
{/* Context menu */} - {ctxMenu && ctxMenu.session && ( + {ctxMenu?.session && ( @@ -321,7 +321,7 @@ function DraggableSection({ label, sessions, connectedIds, renamingId, onContext onRename: (s: Session, label: string) => void onRenameCancel: () => void onReorder: (ids: string[]) => void -}) { +}>) { const dragSrc = useRef(null) const [dragOver, setDragOver] = useState(null) const [order, setOrder] = useState([]) @@ -383,23 +383,24 @@ function DraggableSection({ label, sessions, connectedIds, renamingId, onContext } /* ── Nav item ────────────────────────────────────────────────────────────── */ -function NavItem({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) { +function NavItem({ icon, label, active, onClick }: Readonly<{ icon: React.ReactNode; label: string; active: boolean; onClick: () => void }>) { return ( -
{ if (!active) (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.background = '' }} > {icon} {label} -
+ ) } /* ── Section group ───────────────────────────────────────────────────────── */ -function SectionGroup({ label, icon, children }: { label: string; icon?: React.ReactNode; children: React.ReactNode }) { +function SectionGroup({ label, icon, children }: Readonly<{ label: string; icon?: React.ReactNode; children: React.ReactNode }>) { return (
@@ -414,7 +415,7 @@ function SectionGroup({ label, icon, children }: { label: string; icon?: React.R } /* ── Connection item ─────────────────────────────────────────────────────── */ -function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick, onRename, onRenameCancel }: { +function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick, onRename, onRenameCancel }: Readonly<{ session: Session connected: boolean isRenaming: boolean @@ -422,7 +423,7 @@ function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick onClick: () => void onRename: (label: string) => void onRenameCancel: () => void -}) { +}>) { const inputRef = useRef(null) const [draft, setDraft] = useState(session.label || session.host) @@ -454,10 +455,11 @@ function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick } return ( -
{ (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '' }} > @@ -466,20 +468,20 @@ function ConnectionItem({ session, connected, isRenaming, onContextMenu, onClick {session.label || session.host} {connected && } -
+ ) } /* ── Project view ────────────────────────────────────────────────────────── */ -function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, groupOrder, onGroupOrderChange }: { +function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, groupOrder, onGroupOrderChange }: Readonly<{ sessions: Session[] onConnect: (s: Session) => void onContextMenu: (e: React.MouseEvent, s: Session) => void onMoveToProject: (session: Session, group: string) => void groupOrder: string[] onGroupOrderChange: (order: string[]) => void -}) { +}>) { const [expanded, setExpanded] = useState>(new Set()) const [groupMenu, setGroupMenu] = useState<{ group: string; x: number; y: number } | null>(null) const groupColors = useAppStore(s => s.groupColors) @@ -605,14 +607,15 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou }} > {/* Group header */} -
toggle(group)} onContextMenu={e => { e.preventDefault() e.stopPropagation() setGroupMenu({ group, x: e.clientX, y: e.clientY }) }} - className="group flex items-center gap-1.5 px-2 py-1.5 rounded-md cursor-pointer transition-colors" + className="group w-full text-left flex items-center gap-1.5 px-2 py-1.5 rounded-md cursor-pointer transition-colors" style={{ background: isGroupTarget || isItemTarget ? 'var(--nox-hover)' : '', outline: isItemTarget ? '1px dashed var(--nox-active-t)' : 'none', @@ -629,6 +632,7 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou onDragStart={e => { e.stopPropagation(); dragGroupSrc.current = group }} onDragEnd={() => { dragGroupSrc.current = null; setDragOverGroup(null) }} onClick={e => e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} > @@ -643,12 +647,13 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou {items.length} -
+ {isExpanded && (
{items.map(s => ( -
{ e.stopPropagation(); dragItemSrc.current = s }} @@ -672,7 +677,7 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou }} onClick={() => onConnect(s)} onContextMenu={e => onContextMenu(e, s)} - className="flex items-center gap-2 px-2 py-1 rounded-md cursor-pointer transition-colors" + className="w-full text-left flex items-center gap-2 px-2 py-1 rounded-md cursor-pointer transition-colors" style={{ outline: reorderTarget === s.id ? '1px dashed var(--nox-active-t)' : 'none' }} onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = 'var(--nox-hover)' }} onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '' }} @@ -681,7 +686,7 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou {s.label || s.host} -
+ ))}
)} @@ -705,7 +710,7 @@ function ProjectView({ sessions, onConnect, onContextMenu, onMoveToProject, grou } /* ── Project context menu ────────────────────────────────────────────────── */ -function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClose }: { +function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClose }: Readonly<{ x: number y: number group: string @@ -713,7 +718,7 @@ function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClo onNewConnection: () => void onPick: (color: string | null) => void onClose: () => void -}) { +}>) { const { menuRef, pos } = useMenuBehavior(x, y, onClose) useEffect(() => { @@ -742,7 +747,7 @@ function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClo } - label={`New Connection in ${group === 'Ungrouped' ? 'Ungrouped' : `"${group}"`}`} + label={group === 'Ungrouped' ? 'New Connection in Ungrouped' : `New Connection in "${group}"`} onClick={onNewConnection} /> @@ -781,7 +786,7 @@ function GroupContextMenu({ x, y, group, current, onNewConnection, onPick, onClo } /* ── Connection type icon ────────────────────────────────────────────────── */ -function ConnectionTypeIcon({ type, size = 12 }: { type?: string; size?: number }) { +function ConnectionTypeIcon({ type, size = 12 }: Readonly<{ type?: string; size?: number }>) { const color = 'var(--nox-text-3)' switch (type) { case 'sftp': return @@ -793,9 +798,9 @@ function ConnectionTypeIcon({ type, size = 12 }: { type?: string; size?: number } } -function EmptyAreaMenu({ x, y, onNewConnection, onClose }: { +function EmptyAreaMenu({ x, y, onNewConnection, onClose }: Readonly<{ x: number; y: number; onNewConnection: () => void; onClose: () => void -}) { +}>) { const { menuRef, pos } = useMenuBehavior(x, y, onClose) return ( diff --git a/src/renderer/src/components/Sidebar/__tests__/Sidebar.test.tsx b/src/renderer/src/components/Sidebar/__tests__/Sidebar.test.tsx new file mode 100644 index 0000000..56d9fd6 --- /dev/null +++ b/src/renderer/src/components/Sidebar/__tests__/Sidebar.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import Sidebar from '../Sidebar' +import { installWindowApi, seedStore, makeSession } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +function rowFor(label: string): HTMLElement { + return screen.getByText(label).closest('button') as HTMLElement +} + +describe('Sidebar', () => { + beforeEach(() => { + installWindowApi() + seedStore({ + sessions: [], tabs: [], activeTabId: null, notifications: [], + sidebarView: 'type', sectionOrder: {}, projectGroupOrder: [], + groupColors: {}, focusedPaneId: null, showAddConnection: false, + }) + }) + + it('activates nav items on click', () => { + render() + + fireEvent.click(rowFor('Dashboard')) + expect(useAppStore.getState().tabs.some(t => t.view === 'dashboard')).toBe(true) + + fireEvent.click(rowFor('Tunnels')) + expect(useAppStore.getState().tabs.some(t => t.view === 'tunnels')).toBe(true) + + fireEvent.click(rowFor('Settings')) + expect(useAppStore.getState().tabs.some(t => t.view === 'settings')).toBe(true) + }) + + it('opens a session tab when a connection row is clicked', () => { + seedStore({ + sessions: [ + makeSession({ id: 'ssh-1', label: 'Web Server' }), + makeSession({ id: 'ssh-2', label: 'Db Server' }), + ], + }) + render() + + fireEvent.click(rowFor('Web Server')) + expect(useAppStore.getState().tabs.some(t => t.sessionId === 'ssh-1' && t.view === 'terminal')).toBe(true) + + fireEvent.click(rowFor('Db Server')) + expect(useAppStore.getState().tabs.some(t => t.sessionId === 'ssh-2')).toBe(true) + }) + + it('opens session tabs on click too', () => { + seedStore({ sessions: [makeSession({ id: 'ssh-9', label: 'Clicky' })] }) + render() + fireEvent.click(rowFor('Clicky')) + expect(useAppStore.getState().tabs.some(t => t.sessionId === 'ssh-9')).toBe(true) + }) + + it('toggles project groups and connects to grouped sessions on click', () => { + seedStore({ + sidebarView: 'project', + sessions: [makeSession({ id: 'p-1', label: 'Prod Box', group: 'Prod' })], + }) + render() + + // Group is collapsed initially — the session row is hidden + expect(screen.queryByText('Prod Box')).toBeNull() + + fireEvent.click(rowFor('Prod')) + expect(screen.getByText('Prod Box')).toBeTruthy() + + fireEvent.click(rowFor('Prod Box')) + expect(useAppStore.getState().tabs.some(t => t.sessionId === 'p-1')).toBe(true) + + // Clicking the header again collapses the group + fireEvent.click(rowFor('Prod')) + expect(screen.queryByText('Prod Box')).toBeNull() + }) + + it('opens redis sessions through the redis tab action', () => { + seedStore({ sessions: [makeSession({ id: 'r-1', label: 'Cache', type: 'redis' })] }) + render() + fireEvent.click(rowFor('Cache')) + const tab = useAppStore.getState().tabs.find(t => t.sessionId === 'r-1') + expect(tab?.view).toBe('redis') + }) +}) diff --git a/src/renderer/src/components/StatusBar/StatusBar.tsx b/src/renderer/src/components/StatusBar/StatusBar.tsx index ab73f1a..8a4f4c5 100644 --- a/src/renderer/src/components/StatusBar/StatusBar.tsx +++ b/src/renderer/src/components/StatusBar/StatusBar.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { Shield, Cable } from 'lucide-react' import { useAppStore } from '../../store' +const countActive = (list: Array<{ status: string }>) => list.filter(t => t.status === 'active').length + export default function StatusBar() { const sessions = useAppStore(s => s.sessions) const tabs = useAppStore(s => s.tabs) @@ -19,7 +21,7 @@ export default function StatusBar() { useEffect(() => { const refresh = () => { window.api.tunnels.list() - .then(list => setActiveTunnels(list.filter(t => t.status === 'active').length)) + .then(list => setActiveTunnels(countActive(list))) .catch(() => setActiveTunnels(0)) } refresh() diff --git a/src/renderer/src/components/StatusBar/__tests__/StatusBar.test.tsx b/src/renderer/src/components/StatusBar/__tests__/StatusBar.test.tsx new file mode 100644 index 0000000..8d58263 --- /dev/null +++ b/src/renderer/src/components/StatusBar/__tests__/StatusBar.test.tsx @@ -0,0 +1,45 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import StatusBar from '../StatusBar' +import { installWindowApi, seedStore, makeSession, makeTab } from '../../../__tests__/harness' +import { useAppStore } from '../../../store' + +describe('StatusBar', () => { + beforeEach(() => { + seedStore({ sessions: [], tabs: [], notifications: [] }) + }) + + it('counts only active tunnels', async () => { + installWindowApi({ + tunnels: { + list: vi.fn().mockResolvedValue([ + { status: 'active' }, { status: 'stopped' }, { status: 'error' }, + ]), + }, + }) + render() + await waitFor(() => expect(screen.getByText('1 tunnel active')).toBeTruthy()) + + fireEvent.click(screen.getByTitle('View tunnels')) + expect(useAppStore.getState().tabs.some(t => t.view === 'tunnels')).toBe(true) + }) + + it('hides the tunnel badge when nothing is active and shows session counts', async () => { + const api = installWindowApi({ tunnels: { list: vi.fn().mockResolvedValue([{ status: 'stopped' }]) } }) + seedStore({ + sessions: [makeSession(), makeSession()], + tabs: [ + makeTab({ view: 'terminal', status: 'connected' }), + makeTab({ view: 'terminal', status: 'error' }), + makeTab({ view: 'dashboard', status: 'connected' }), + ], + }) + render() + await waitFor(() => expect(api.tunnels.list).toHaveBeenCalled()) + + expect(screen.queryByTitle('View tunnels')).toBeNull() + expect(screen.getByText(/2 connections configured/)).toBeTruthy() + expect(screen.getByText('1 connection lost')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/TabBar/TabBar.tsx b/src/renderer/src/components/TabBar/TabBar.tsx index 4c345e1..598d3cb 100644 --- a/src/renderer/src/components/TabBar/TabBar.tsx +++ b/src/renderer/src/components/TabBar/TabBar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { LayoutDashboard, List, Settings, Terminal, Boxes, Database, FolderOpen, X, Layers, Plus, FileCode, Monitor } from 'lucide-react' +import { LayoutDashboard, List, Settings, Terminal, Boxes, Database, FolderOpen, X, Layers, Plus, FileCode, Monitor, Cable, TerminalSquare } from 'lucide-react' import { useAppStore, Tab } from '../../store' export default function TabBar() { @@ -99,9 +99,13 @@ export default function TabBar() { ) } -function CloseConfirmDialog({ tabLabel, unsavedEdits, onConfirm, onCancel }: { tabLabel: string; unsavedEdits?: boolean; onConfirm: () => void; onCancel: () => void }) { +function CloseConfirmDialog({ tabLabel, unsavedEdits, onConfirm, onCancel }: Readonly<{ tabLabel: string; unsavedEdits?: boolean; onConfirm: () => void; onCancel: () => void }>) { return ( -
{ if (e.target === e.currentTarget) onCancel() }}> +
{ if (e.target === e.currentTarget) onCancel() }} + onKeyDown={e => { if (e.key === 'Escape') onCancel() }} + >
> = { + dashboard: { Icon: LayoutDashboard, activeColor: '#3B5CCC' }, + connections: { Icon: List, activeColor: '#3B5CCC' }, + settings: { Icon: Settings, activeColor: '#3B5CCC' }, + terminal: { Icon: Terminal, activeColor: '#3B5CCC' }, + k8s: { Icon: Boxes, activeColor: '#8B5CF6' }, + database: { Icon: Database, activeColor: '#10B981' }, + sftp: { Icon: FolderOpen, activeColor: '#EC4899' }, + redis: { Icon: Layers, activeColor: '#DC382D' }, + rdp: { Icon: Monitor, activeColor: '#06B6D4' }, + editor: { Icon: FileCode, activeColor: '#F59E0B' }, + docker: { Icon: Boxes, activeColor: '#2496ED' }, + 'local-term': { Icon: Terminal, activeColor: '#10B981' }, + tunnels: { Icon: Cable, activeColor: '#3B5CCC' }, + runner: { Icon: TerminalSquare, activeColor: '#3B5CCC' }, +} + function tabIcon(view: Tab['view'], active: boolean) { - const color = active ? '#3B5CCC' : 'var(--nox-text-2)' - const size = 11 - switch (view) { - case 'dashboard': return - case 'connections': return - case 'settings': return - case 'terminal': return - case 'k8s': return - case 'database': return - case 'sftp': return - case 'redis': return - case 'rdp': return - case 'editor': return - case 'docker': return - case 'local-term': return - default: return - } + const { Icon, activeColor } = VIEW_ICONS[view] ?? { Icon: Terminal, activeColor: '#3B5CCC' } + return } -function TabPill({ tab, active, draggable, isDragging, isDropTarget, onActivate, onClose, onDragStart, onDragEnd, onDragOver, onDrop }: { +function TabPill({ tab, active, draggable, isDragging, isDropTarget, onActivate, onClose, onDragStart, onDragEnd, onDragOver, onDrop }: Readonly<{ tab: Tab active: boolean draggable: boolean @@ -171,7 +177,7 @@ function TabPill({ tab, active, draggable, isDragging, isDropTarget, onActivate, onDragEnd: () => void onDragOver: (e: React.DragEvent) => void onDrop: () => void -}) { +}>) { return ( @@ -224,12 +229,12 @@ function TunnelCard({ tunnel, via, busy, onToggle, onEdit, onDelete }: { ) } -function IconButton({ title, onClick, danger, children }: { +function IconButton({ title, onClick, danger, children }: Readonly<{ title: string onClick: () => void danger?: boolean children: React.ReactNode -}) { +}>) { return (