diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5673e0d..27e4fb5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -89,7 +89,7 @@ jobs: - name: Upload integration test results if: always() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: integration-test-results-pg${{ matrix.postgres-version }} path: test-results/ @@ -127,8 +127,13 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Install PostgreSQL client + run: | + sudo apt-get update + sudo apt-get install -y postgresql-client + - name: Start PostgreSQL test containers - run: docker-compose -f docker-compose.test.yml up -d + run: docker compose -f docker-compose.test.yml up -d - name: Wait for PostgreSQL services run: | @@ -163,7 +168,7 @@ jobs: - name: Cleanup Docker containers if: always() - run: docker-compose -f docker-compose.test.yml down + run: docker compose -f docker-compose.test.yml down lint-and-format: runs-on: ubuntu-latest @@ -192,7 +197,7 @@ jobs: - uses: actions/checkout@v4 - name: Download all coverage reports - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: path: coverage-reports diff --git a/.gitignore b/.gitignore index 25e9e6c..01b2576 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist pat-open-vsx .nyc_output/ coverage/ +out_test/ \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 42f584e..9597c69 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,27 +1,33 @@ { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "watch", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "presentation": { - "reveal": "never" - }, - "group": { - "kind": "build", - "isDefault": true - } - }, - { - "type": "npm", - "script": "compile", - "problemMatcher": "$tsc", - "presentation": { - "reveal": "never" - }, - "group": "build" - } - ] -} \ No newline at end of file + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "esbuild", + "group": { + "kind": "build", + "isDefault": true + }, + "problemMatcher": [] + }, + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": "build" + }, + { + "type": "npm", + "script": "compile", + "problemMatcher": "$tsc", + "presentation": { + "reveal": "never" + }, + "group": "build" + } + ] +} diff --git a/package.json b/package.json index c7aa666..9c84445 100644 --- a/package.json +++ b/package.json @@ -2072,14 +2072,15 @@ "watch": "tsc -watch -p ./", "esbuild": "npm run esbuild-base -- --sourcemap && npm run esbuild-renderer -- --sourcemap", "esbuild-watch": "npm run esbuild-base -- --sourcemap --watch & npm run esbuild-renderer -- --sourcemap --watch", - "test": "npm run compile && ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/unit/**/*.test.ts'", - "test:unit": "npm run compile && ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/unit/**/*.test.ts'", - "test:integration": "npm run compile && ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/integration/**/*.test.ts'", - "test:renderer": "npm run compile && ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/unit/RendererComponents.test.ts'", - "test:renderer:coverage": "npm run compile && nyc ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/unit/RendererComponents.test.ts'", - "test:versions": "npm run compile && ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/integration/ConnectionLifecycle.test.ts'", - "test:all": "npm run compile && ts-mocha -p src/test/tsconfig.json -r src/test/setup.ts 'src/test/**/*.test.ts'", - "coverage": "npm run compile && nyc npm run test:all", + "test": "npm run compile && mocha --loader ./node_modules/ts-node/esm/transpile-only.mjs -r tsconfig-paths/register -r src/test/setup.ts 'src/test/unit/**/*.test.ts'", + "test:unit": "npm run compile && ts-mocha -p src/test/tsconfig.json -r tsconfig-paths/register -r src/test/setup.ts 'src/test/unit/**/*.test.ts'", + "test:integration": "npm run compile && ts-mocha -p src/test/tsconfig.json -r tsconfig-paths/register -r src/test/setup.ts 'src/test/integration/**/*.test.ts'", + "test:renderer": "npm run compile && ts-mocha -p src/test/tsconfig.json -r tsconfig-paths/register -r src/test/setup.ts 'src/test/unit/RendererComponents.test.ts'", + "test:renderer:coverage": "npm run compile && nyc ts-mocha -p src/test/tsconfig.json -r tsconfig-paths/register -r src/test/setup.ts 'src/test/unit/RendererComponents.test.ts'", + "test:versions": "npm run compile && ts-mocha -p src/test/tsconfig.json -r tsconfig-paths/register -r src/test/setup.ts 'src/test/integration/ConnectionLifecycle.test.ts'", + "test:all": "npm run compile && ts-mocha -p src/test/tsconfig.json -r ts-node/register -r tsconfig-paths/register -r src/test/setup.ts 'src/test/**/*.test.ts'", + "build-tests": "tsc -p src/test/tsconfig.json --outDir out_test", + "coverage": "npm run compile && npm run build-tests && nyc mocha -r tsconfig-paths/register -r ./out_test/setup.js 'out_test/**/*.test.js'", "coverage:report": "nyc report --reporter=html --reporter=text" }, "dependencies": { diff --git a/src/commands/aiAssist.ts b/src/commands/aiAssist.ts index 00d9e86..08805f1 100644 --- a/src/commands/aiAssist.ts +++ b/src/commands/aiAssist.ts @@ -537,7 +537,7 @@ Then provide the SQL query. Remember: NO markdown formatting, just the raw SQL ( const AiTaskSelector = { tasks: [ // Custom - Always First - { label: '$(edit) Custom Instruction', description: 'Enter your own instruction', detail: 'Tell the AI exactly what you want to do with this query', kind: vscode.QuickPickItemKind.Default }, + { label: '$(edit) Custom Instruction', description: 'Enter your own instruction', detail: 'Tell the AI exactly what you want to do with this query' }, // Separator { label: '', kind: vscode.QuickPickItemKind.Separator }, diff --git a/src/commands/connection.ts b/src/commands/connection.ts index 2791aad..5f2e942 100644 --- a/src/commands/connection.ts +++ b/src/commands/connection.ts @@ -4,6 +4,7 @@ import { PostgresMetadata } from '../common/types'; import { DatabaseTreeItem, DatabaseTreeProvider } from '../providers/DatabaseTreeProvider'; import { ConnectionManager } from '../services/ConnectionManager'; import { SecretStorageService } from '../services/SecretStorageService'; +import { resolvePgPassPassword } from '../utils/pgPassUtils'; import { ErrorHandlers } from './helper'; /** @@ -73,7 +74,7 @@ export function validateCategoryItem(item: DatabaseTreeItem): asserts item is Da /** * getConnectionWithPassword - Retrieves the connection details and password for the specified connection ID. */ -export async function getConnectionWithPassword(connectionId: string): Promise { +export async function getConnectionWithPassword(connectionId: string, databaseName?: string): Promise { const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; const connection = connections.find(c => c.id === connectionId); @@ -81,9 +82,18 @@ export async function getConnectionWithPassword(connectionId: string): Promise void = validateItem) { validateFn(item); - const connection = await getConnectionWithPassword(item.connectionId!); + const connection = await getConnectionWithPassword(item.connectionId!, item.databaseName); const client = await ConnectionManager.getInstance().getPooledClient({ id: connection.id, host: connection.host, diff --git a/src/connectionForm.ts b/src/connectionForm.ts index b863e1c..f969fa3 100644 --- a/src/connectionForm.ts +++ b/src/connectionForm.ts @@ -1,8 +1,12 @@ -import { Client } from 'pg'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { SSHService } from './services/SSHService'; -import { ConnectionManager } from './services/ConnectionManager'; +import { Client } from "pg"; +import * as vscode from "vscode"; +import * as fs from "fs"; +import { SSHService } from "./services/SSHService"; +import { ConnectionManager } from "./services/ConnectionManager"; +import { + resolvePgPassPasswordAsync, + pgPassFileDescription, +} from "./utils/pgPassUtils"; export interface ConnectionInfo { id: string; @@ -14,10 +18,16 @@ export interface ConnectionInfo { database?: string; group?: string; // Safety & confidence features - environment?: 'production' | 'staging' | 'development'; + environment?: "production" | "staging" | "development"; readOnlyMode?: boolean; // Advanced connection options - sslmode?: 'disable' | 'allow' | 'prefer' | 'require' | 'verify-ca' | 'verify-full'; + sslmode?: + | "disable" + | "allow" + | "prefer" + | "require" + | "verify-ca" + | "verify-full"; sslCertPath?: string; sslKeyPath?: string; sslRootCertPath?: string; @@ -40,7 +50,12 @@ export class ConnectionFormPanel { private readonly _extensionUri: vscode.Uri; private _disposables: vscode.Disposable[] = []; - private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri, private readonly _extensionContext: vscode.ExtensionContext, private readonly _connectionToEdit?: ConnectionInfo) { + private constructor( + panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + private readonly _extensionContext: vscode.ExtensionContext, + private readonly _connectionToEdit?: ConnectionInfo, + ) { this._panel = panel; this._extensionUri = extensionUri; @@ -50,24 +65,47 @@ export class ConnectionFormPanel { this._panel.webview.onDidReceiveMessage( async (message) => { // Helper to build client config with SSL - const buildClientConfig = (connection: any, dbName: string, forceDisableSSL: boolean) => { + const buildClientConfig = ( + connection: any, + dbName: string, + forceDisableSSL: boolean, + overridePassword?: string, + ) => { + // Use the explicitly-resolved password (from pgpass lookup or the + // form field) when provided; otherwise fall back to the form value. + const effectivePassword = + overridePassword !== undefined + ? overridePassword + : connection.password || undefined; const config: any = { user: connection.username || undefined, - password: connection.password || undefined, - database: dbName + password: effectivePassword, + database: dbName, }; if (!forceDisableSSL) { - const sslMode = connection.sslmode || 'prefer'; // Default to prefer - if (sslMode !== 'disable') { + const sslMode = connection.sslmode || "prefer"; // Default to prefer + if (sslMode !== "disable") { const sslConfig: any = { - rejectUnauthorized: sslMode === 'verify-ca' || sslMode === 'verify-full' + rejectUnauthorized: + sslMode === "verify-ca" || sslMode === "verify-full", }; try { - if (connection.sslRootCertPath) sslConfig.ca = fs.readFileSync(connection.sslRootCertPath).toString(); - if (connection.sslCertPath) sslConfig.cert = fs.readFileSync(connection.sslCertPath).toString(); - if (connection.sslKeyPath) sslConfig.key = fs.readFileSync(connection.sslKeyPath).toString(); - } catch (e: any) { console.warn('Error reading SSL certs:', e); } + if (connection.sslRootCertPath) + sslConfig.ca = fs + .readFileSync(connection.sslRootCertPath) + .toString(); + if (connection.sslCertPath) + sslConfig.cert = fs + .readFileSync(connection.sslCertPath) + .toString(); + if (connection.sslKeyPath) + sslConfig.key = fs + .readFileSync(connection.sslKeyPath) + .toString(); + } catch (e: any) { + console.warn("Error reading SSL certs:", e); + } config.ssl = sslConfig; } } @@ -75,13 +113,65 @@ export class ConnectionFormPanel { }; const runTest = async (connection: any, isSave: boolean) => { - let config = buildClientConfig(connection, isSave ? 'postgres' : (connection.database || 'postgres'), false); + // Always use the user's configured database for both test and save + // validation. Previously, save validation hardcoded 'postgres', which + // broke .pgpass: pg reads ~/.pgpass by matching (host, port, database, + // user). Forcing 'postgres' caused a mismatch when the .pgpass entry + // specifies the user's actual database, so pgpass returned no password + // and PostgreSQL rejected the connection with "empty password returned + // by client". The 3D000 fallback below still handles the case where + // the configured database does not yet exist. + const targetDb = connection.database || "postgres"; + + // ── Explicit pgpass resolution ─────────────────────────────────── + // When the user leaves the password field empty (relying on a pgpass + // file), the pg library's *internal* pgpass lookup can silently fail + // — most commonly on Windows where the expected path is + // %APPDATA%\postgresql\pgpass.conf + // rather than ~/.pgpass. If that lookup returns undefined, pg keeps + // the password as null and SCRAM authentication throws: + // "SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string" + // + // By resolving the pgpass password ourselves *before* constructing + // the Client we can: + // 1. Pass it explicitly, bypassing pg's internal lookup entirely. + // 2. Emit a clear error that includes the expected file path when + // neither an explicit password nor a pgpass match is found. + let resolvedPassword: string | undefined = + connection.password || undefined; + if (!resolvedPassword && connection.username) { + resolvedPassword = await resolvePgPassPasswordAsync( + connection.host, + parseInt(String(connection.port), 10) || 5432, + targetDb, + connection.username, + ); + + // If pgpass didn't match for the target db, also try the postgres + // fallback db (mirrors the 3D000 retry below, but at the pgpass + // lookup stage so we can detect a credential problem early). + if (!resolvedPassword && targetDb !== "postgres") { + resolvedPassword = await resolvePgPassPasswordAsync( + connection.host, + parseInt(String(connection.port), 10) || 5432, + "postgres", + connection.username, + ); + } + } + + let config = buildClientConfig( + connection, + targetDb, + false, + resolvedPassword, + ); if (connection.ssh && connection.ssh.enabled) { const stream = await SSHService.getInstance().createStream( connection.ssh, connection.host, - connection.port + connection.port, ); config.stream = stream; } else { @@ -93,26 +183,37 @@ export class ConnectionFormPanel { try { await client.connect(); if (isSave) { - await client.query('SELECT 1'); + await client.query("SELECT 1"); } else { - const result = await client.query('SELECT version()'); + const result = await client.query("SELECT version()"); return result.rows[0].version; } await client.end(); return true; } catch (err: any) { // fallbacks - const sslMode = connection.sslmode || 'prefer'; - const isSSLFailure = (err.message || '').toString().toLowerCase().includes('server does not support ssl') || err.code === 'ECONNRESET' || err.code === 'EPROTO'; - - if ((sslMode === 'prefer' || sslMode === 'allow') && isSSLFailure) { - // Retry without SSL - config = buildClientConfig(connection, isSave ? 'postgres' : (connection.database || 'postgres'), true); + const sslMode = connection.sslmode || "prefer"; + const isSSLFailure = + (err.message || "") + .toString() + .toLowerCase() + .includes("server does not support ssl") || + err.code === "ECONNRESET" || + err.code === "EPROTO"; + + if ((sslMode === "prefer" || sslMode === "allow") && isSSLFailure) { + // Retry without SSL - keep using targetDb so .pgpass still matches + config = buildClientConfig( + connection, + targetDb, + true, + resolvedPassword, + ); if (connection.ssh && connection.ssh.enabled) { const stream = await SSHService.getInstance().createStream( connection.ssh, connection.host, - connection.port + connection.port, ); config.stream = stream; } else { @@ -121,24 +222,50 @@ export class ConnectionFormPanel { } client = new Client(config); - await client.connect(); - if (isSave) { - await client.query('SELECT 1'); - } else { - const result = await client.query('SELECT version()'); - return result.rows[0].version; + try { + await client.connect(); + if (isSave) { + await client.query("SELECT 1"); + } else { + const result = await client.query("SELECT version()"); + return result.rows[0].version; + } + await client.end(); + return true; + } catch (sslErr: any) { + err = sslErr; } - await client.end(); - return true; } - // Database verification fallback for testConnection only - // If database doesn't exist, try postgres database - if (!isSave && err.code === '3D000' && connection.database !== 'postgres') { - // Retry with postgres db - config = buildClientConfig(connection, 'postgres', false); + // Database fallback: if the configured database doesn't exist yet, + // retry against 'postgres' so credentials can still be validated. + // This applies to both test and save — for save the connection is + // stored with the original database name (it may be created later). + if (err.code === "3D000" && targetDb !== "postgres") { + // Re-resolve pgpass for the 'postgres' database specifically, + // in case the entry was wildcarded or the earlier targetDb lookup + // returned nothing but a postgres entry exists. + let fallbackPassword = resolvedPassword; + if (!fallbackPassword && connection.username) { + fallbackPassword = await resolvePgPassPasswordAsync( + connection.host, + parseInt(String(connection.port), 10) || 5432, + "postgres", + connection.username, + ); + } + config = buildClientConfig( + connection, + "postgres", + false, + fallbackPassword, + ); if (connection.ssh && connection.ssh.enabled) { - const stream = await SSHService.getInstance().createStream(connection.ssh, connection.host, connection.port); + const stream = await SSHService.getInstance().createStream( + connection.ssh, + connection.host, + connection.port, + ); config.stream = stream; } else { config.host = connection.host; @@ -146,38 +273,78 @@ export class ConnectionFormPanel { } client = new Client(config); - await client.connect(); - const result = await client.query('SELECT version()'); - await client.end(); - return result.rows[0].version + ' (connected to postgres database)'; + try { + await client.connect(); + if (isSave) { + await client.query("SELECT 1"); + await client.end(); + return true; + } + const result = await client.query("SELECT version()"); + await client.end(); + return ( + result.rows[0].version + " (connected to postgres database)" + ); + } catch (fallbackErr: any) { + // If the fallback fails, throw the original 3D000 error so the + // user knows their database doesn't exist, rather than confusing + // them with a pgpass error for the 'postgres' database. + throw err; + } + } + // ── Friendly pgpass error ──────────────────────────────────── + // When SCRAM authentication fires and pg has no password string + // it means: no explicit password was given AND our own pgpass + // lookup returned nothing. Surface the expected file path so + // the user knows exactly where to put the pgpass entry. + if ( + err.message && + (err.message as string).includes( + "client password must be a string", + ) + ) { + const { pgPassFileDescription } = + await import("./utils/pgPassUtils"); + const location = pgPassFileDescription(); + throw new Error( + `No password found for this connection.\n\n` + + `Either enter a password in the form, or add a matching entry to your pgpass file:\n` + + ` ${location}\n\n` + + `The entry format is:\n` + + ` hostname:port:database:username:password\n\n` + + `Example:\n` + + ` ${connection.host}:${connection.port}:${targetDb}:${connection.username || "*"}:yourpassword`, + ); } throw err; } }; switch (message.command) { - case 'testConnection': + case "testConnection": try { const version = await runTest(message.connection, false); this._panel.webview.postMessage({ - type: 'testSuccess', - version: version + type: "testSuccess", + version: version, }); } catch (err: any) { this._panel.webview.postMessage({ - type: 'testError', - error: err.message + type: "testError", + error: err.message, }); } break; - case 'saveConnection': + case "saveConnection": try { await runTest(message.connection, true); const connections = this.getStoredConnections(); const newConnection: ConnectionInfo = { - id: this._connectionToEdit ? this._connectionToEdit.id : Date.now().toString(), + id: this._connectionToEdit + ? this._connectionToEdit.id + : Date.now().toString(), name: message.connection.name, host: message.connection.host, port: message.connection.port, @@ -192,16 +359,21 @@ export class ConnectionFormPanel { sslmode: message.connection.sslmode || undefined, sslCertPath: message.connection.sslCertPath || undefined, sslKeyPath: message.connection.sslKeyPath || undefined, - sslRootCertPath: message.connection.sslRootCertPath || undefined, - statementTimeout: message.connection.statementTimeout || undefined, + sslRootCertPath: + message.connection.sslRootCertPath || undefined, + statementTimeout: + message.connection.statementTimeout || undefined, connectTimeout: message.connection.connectTimeout || undefined, - applicationName: message.connection.applicationName || undefined, + applicationName: + message.connection.applicationName || undefined, options: message.connection.options || undefined, - ssh: message.connection.ssh + ssh: message.connection.ssh, }; if (this._connectionToEdit) { - const index = connections.findIndex(c => c.id === this._connectionToEdit!.id); + const index = connections.findIndex( + (c) => c.id === this._connectionToEdit!.id, + ); if (index !== -1) { connections[index] = newConnection; } else { @@ -216,27 +388,39 @@ export class ConnectionFormPanel { // Close any active connections for this ID to ensure pool is refreshed with new settings try { // Use the ID of the connection we just saved - await ConnectionManager.getInstance().closeAllConnectionsById(newConnection.id); + await ConnectionManager.getInstance().closeAllConnectionsById( + newConnection.id, + ); } catch (e) { - console.error('Failed to close stale connections:', e); + console.error("Failed to close stale connections:", e); } - vscode.window.showInformationMessage(`Connection ${this._connectionToEdit ? 'updated' : 'saved'} successfully!`); - vscode.commands.executeCommand('postgres-explorer.refreshConnections'); + vscode.window.showInformationMessage( + `Connection ${this._connectionToEdit ? "updated" : "saved"} successfully!`, + ); + vscode.commands.executeCommand( + "postgres-explorer.refreshConnections", + ); this._panel.dispose(); } catch (err: any) { - const errorMessage = err?.message || 'Unknown error occurred'; - vscode.window.showErrorMessage(`Failed to connect: ${errorMessage}`); + const errorMessage = err?.message || "Unknown error occurred"; + vscode.window.showErrorMessage( + `Failed to connect: ${errorMessage}`, + ); } break; } }, undefined, - this._disposables + this._disposables, ); } - public static show(extensionUri: vscode.Uri, extensionContext: vscode.ExtensionContext, connectionToEdit?: ConnectionInfo) { + public static show( + extensionUri: vscode.Uri, + extensionContext: vscode.ExtensionContext, + connectionToEdit?: ConnectionInfo, + ) { if (ConnectionFormPanel.currentPanel) { // Check if we are switching contexts (Add <-> Edit) or Edit <-> Edit (different ID) const current = ConnectionFormPanel.currentPanel; @@ -254,15 +438,20 @@ export class ConnectionFormPanel { } const panel = vscode.window.createWebviewPanel( - 'connectionForm', - connectionToEdit ? 'Edit Connection' : 'Add PostgreSQL Connection', + "connectionForm", + connectionToEdit ? "Edit Connection" : "Add PostgreSQL Connection", vscode.ViewColumn.One, { - enableScripts: true - } + enableScripts: true, + }, ); - ConnectionFormPanel.currentPanel = new ConnectionFormPanel(panel, extensionUri, extensionContext, connectionToEdit); + ConnectionFormPanel.currentPanel = new ConnectionFormPanel( + panel, + extensionUri, + extensionContext, + connectionToEdit, + ); } private async _initialize() { @@ -271,37 +460,63 @@ export class ConnectionFormPanel { } private async _update() { - this._panel.webview.html = await this._getHtmlForWebview(this._panel.webview); + this._panel.webview.html = await this._getHtmlForWebview( + this._panel.webview, + ); } private async _getHtmlForWebview(webview: vscode.Webview): Promise { - const logoPath = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'resources', 'postgres-vsc-icon.png')); + const logoPath = webview.asWebviewUri( + vscode.Uri.joinPath( + this._extensionUri, + "resources", + "postgres-vsc-icon.png", + ), + ); const nonce = this._getNonce(); const cspSource = webview.cspSource; let connectionData: any = null; if (this._connectionToEdit) { // Get the password from secret storage - const password = await this._extensionContext.secrets.get(`postgres-password-${this._connectionToEdit.id}`); + const password = await this._extensionContext.secrets.get( + `postgres-password-${this._connectionToEdit.id}`, + ); connectionData = { ...this._connectionToEdit, - password + password, }; } // Dynamic content for placeholders - const pageTitle = this._connectionToEdit ? 'Edit Connection' : 'Add PostgreSQL Connection'; - const headerTitle = this._connectionToEdit ? 'Edit Connection' : 'New Connection'; - const submitButtonText = this._connectionToEdit ? 'Save Changes' : 'Add Connection'; + const pageTitle = this._connectionToEdit + ? "Edit Connection" + : "Add PostgreSQL Connection"; + const headerTitle = this._connectionToEdit + ? "Edit Connection" + : "New Connection"; + const submitButtonText = this._connectionToEdit + ? "Save Changes" + : "Add Connection"; try { // Load template files - const templatesDir = vscode.Uri.joinPath(this._extensionUri, 'templates', 'connection-form'); + const templatesDir = vscode.Uri.joinPath( + this._extensionUri, + "templates", + "connection-form", + ); const [htmlBuffer, cssBuffer, jsBuffer] = await Promise.all([ - vscode.workspace.fs.readFile(vscode.Uri.joinPath(templatesDir, 'index.html')), - vscode.workspace.fs.readFile(vscode.Uri.joinPath(templatesDir, 'styles.css')), - vscode.workspace.fs.readFile(vscode.Uri.joinPath(templatesDir, 'scripts.js')) + vscode.workspace.fs.readFile( + vscode.Uri.joinPath(templatesDir, "index.html"), + ), + vscode.workspace.fs.readFile( + vscode.Uri.joinPath(templatesDir, "styles.css"), + ), + vscode.workspace.fs.readFile( + vscode.Uri.joinPath(templatesDir, "scripts.js"), + ), ]); let html = new TextDecoder().decode(htmlBuffer); @@ -313,24 +528,27 @@ export class ConnectionFormPanel { // Replace JavaScript placeholder for connection data with regex to be safe against spacing issues // const connectionDataJs = JSON.stringify(connectionData) || 'null'; - // js = js.replace(/{{\s*CONNECTION_DATA\s*}}/, () => connectionDataJs); + // js = js.replace(/{{\s*CONNECTION_DATA\s*}}/, () => connectionDataJs); // Safe replacement using a function to avoid special replacement patterns in the data string - js = js.replace(/{{\s*CONNECTION_DATA\s*}}/, () => JSON.stringify(connectionData) || 'null'); - console.log('Connection form template loaded and processed'); + js = js.replace( + /{{\s*CONNECTION_DATA\s*}}/, + () => JSON.stringify(connectionData) || "null", + ); + console.log("Connection form template loaded and processed"); // Replace HTML placeholders - html = html.replace('{{CSP}}', csp); - html = html.replace('{{INLINE_STYLES}}', () => css); - html = html.replace('{{INLINE_SCRIPTS}}', () => js); + html = html.replace("{{CSP}}", csp); + html = html.replace("{{INLINE_STYLES}}", () => css); + html = html.replace("{{INLINE_SCRIPTS}}", () => js); html = html.replace(/\{\{NONCE\}\}/g, nonce); - html = html.replace('{{LOGO_URI}}', logoPath.toString()); - html = html.replace('{{PAGE_TITLE}}', () => pageTitle); - html = html.replace('{{HEADER_TITLE}}', () => headerTitle); - html = html.replace('{{SUBMIT_BUTTON_TEXT}}', () => submitButtonText); + html = html.replace("{{LOGO_URI}}", logoPath.toString()); + html = html.replace("{{PAGE_TITLE}}", () => pageTitle); + html = html.replace("{{HEADER_TITLE}}", () => headerTitle); + html = html.replace("{{SUBMIT_BUTTON_TEXT}}", () => submitButtonText); return html; } catch (error) { - console.error('Failed to load connection form templates:', error); + console.error("Failed to load connection form templates:", error); return ` @@ -343,8 +561,9 @@ export class ConnectionFormPanel { } private _getNonce(): string { - let text = ''; - const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; for (let i = 0; i < 32; i++) { text += possible.charAt(Math.floor(Math.random() * possible.length)); } @@ -352,30 +571,55 @@ export class ConnectionFormPanel { } private getStoredConnections(): ConnectionInfo[] { - const connections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; + const connections = + vscode.workspace + .getConfiguration() + .get("postgresExplorer.connections") || []; return connections; } private async storeConnections(connections: ConnectionInfo[]): Promise { try { // First store the connections without passwords in settings - const connectionsForSettings = connections.map(({ password, ...connWithoutPassword }) => connWithoutPassword); - await vscode.workspace.getConfiguration().update('postgresExplorer.connections', connectionsForSettings, vscode.ConfigurationTarget.Global); + const connectionsForSettings = connections.map( + ({ password, ...connWithoutPassword }) => connWithoutPassword, + ); + await vscode.workspace + .getConfiguration() + .update( + "postgresExplorer.connections", + connectionsForSettings, + vscode.ConfigurationTarget.Global, + ); // Then store passwords in SecretStorage const secretsStorage = this._extensionContext.secrets; for (const conn of connections) { if (conn.password) { // Removed logging of sensitive connection information for security. - await secretsStorage.store(`postgres-password-${conn.id}`, conn.password); + await secretsStorage.store( + `postgres-password-${conn.id}`, + conn.password, + ); } } } catch (error) { - console.error('Failed to store connections:', error); + console.error("Failed to store connections:", error); // If anything fails, make sure we don't leave passwords in settings - const existingConnections = vscode.workspace.getConfiguration().get('postgresExplorer.connections') || []; - const sanitizedConnections = existingConnections.map(({ password, ...connWithoutPassword }) => connWithoutPassword); - await vscode.workspace.getConfiguration().update('postgresExplorer.connections', sanitizedConnections, vscode.ConfigurationTarget.Global); + const existingConnections = + vscode.workspace + .getConfiguration() + .get("postgresExplorer.connections") || []; + const sanitizedConnections = existingConnections.map( + ({ password, ...connWithoutPassword }) => connWithoutPassword, + ); + await vscode.workspace + .getConfiguration() + .update( + "postgresExplorer.connections", + sanitizedConnections, + vscode.ConfigurationTarget.Global, + ); throw error; } } diff --git a/src/providers/DatabaseTreeProvider.ts b/src/providers/DatabaseTreeProvider.ts index af1d336..bd452f9 100644 --- a/src/providers/DatabaseTreeProvider.ts +++ b/src/providers/DatabaseTreeProvider.ts @@ -437,7 +437,7 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider { return await client!.query(` SELECT datname, pg_size_pretty(pg_database_size(datname)) as size diff --git a/src/services/ConnectionManager.js b/src/services/ConnectionManager.js new file mode 100644 index 0000000..9c979f4 --- /dev/null +++ b/src/services/ConnectionManager.js @@ -0,0 +1,2 @@ +// Proxy to compiled output for test runner resolution +module.exports = require('../../out/services/ConnectionManager'); diff --git a/src/services/ConnectionManager.ts b/src/services/ConnectionManager.ts index e9a84c5..cb40e1c 100644 --- a/src/services/ConnectionManager.ts +++ b/src/services/ConnectionManager.ts @@ -1,10 +1,14 @@ -import { Client, Pool, PoolClient, ClientConfig, PoolConfig } from 'pg'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { ConnectionConfig } from '../common/types'; -import { SecretStorageService } from './SecretStorageService'; -import { SSHService } from './SSHService'; -import { ErrorService } from './ErrorService'; +import { Client, Pool, PoolClient, ClientConfig, PoolConfig } from "pg"; +import * as vscode from "vscode"; +import * as fs from "fs"; +import { ConnectionConfig } from "../common/types"; +import { SecretStorageService } from "./SecretStorageService"; +import { SSHService } from "./SSHService"; +import { ErrorService } from "./ErrorService"; +import { + resolvePgPassPassword, + pgPassFileDescription, +} from "../utils/pgPassUtils"; export interface PoolMetrics { connectionId: string; @@ -20,7 +24,7 @@ export class ConnectionManager { private pools: Map = new Map(); private sessions: Map = new Map(); private poolMetrics: Map = new Map(); - + // Configuration for pool management private readonly IDLE_TIMEOUT = 300000; // 5 minutes private readonly CLEANUP_INTERVAL = 60000; // Check every 1 minute @@ -55,7 +59,10 @@ export class ConnectionManager { const poolsToClose: string[] = []; for (const [key, metrics] of this.poolMetrics.entries()) { - if (now - metrics.lastActivity > this.IDLE_TIMEOUT && metrics.totalConnections === 0) { + if ( + now - metrics.lastActivity > this.IDLE_TIMEOUT && + metrics.totalConnections === 0 + ) { poolsToClose.push(key); } } @@ -90,20 +97,20 @@ export class ConnectionManager { private isSSLFailure(err: any): boolean { if (!err) return false; - const msg = (err.message || '').toString().toLowerCase(); + const msg = (err.message || "").toString().toLowerCase(); // Common errors when server doesn't support SSL or handshake fails gracefully return ( - msg.includes('server does not support ssl') || - err.code === 'ECONNRESET' || - err.code === 'EPROTO' + msg.includes("server does not support ssl") || + err.code === "ECONNRESET" || + err.code === "EPROTO" ); } private shouldFallback(config: ConnectionConfig, err: any): boolean { - const sslMode = config.sslmode || 'prefer'; + const sslMode = config.sslmode || "prefer"; // Only fallback if mode is prefer (or 'allow' - rare) // require, verify-ca, verify-full should NOT fallback - if (sslMode !== 'prefer' && sslMode !== 'allow') { + if (sslMode !== "prefer" && sslMode !== "allow") { return false; } return this.isSSLFailure(err); @@ -122,27 +129,30 @@ export class ConnectionManager { try { const client = await pool.connect(); - + // Apply read-only mode if configured if (config.readOnlyMode) { try { - await client.query('SET default_transaction_read_only = ON'); + await client.query("SET default_transaction_read_only = ON"); } catch (err) { - console.warn('Failed to set read-only mode:', err); + console.warn("Failed to set read-only mode:", err); } } - + return client; } catch (err: any) { // Handle SSL Fallback if (this.shouldFallback(config, err)) { - console.warn(`SSL connection failed for ${key}, falling back to non-SSL`, err); + console.warn( + `SSL connection failed for ${key}, falling back to non-SSL`, + err, + ); // Remove the failed pool this.pools.delete(key); - try { - await pool.end(); - } catch (e) { + try { + await pool.end(); + } catch (e) { console.error(`Error closing failed SSL pool for ${key}:`, e); } @@ -152,16 +162,16 @@ export class ConnectionManager { this.pools.set(key, pool); const client = await pool.connect(); - + // Apply read-only mode if configured if (config.readOnlyMode) { try { - await client.query('SET default_transaction_read_only = ON'); + await client.query("SET default_transaction_read_only = ON"); } catch (err) { - console.warn('Failed to set read-only mode:', err); + console.warn("Failed to set read-only mode:", err); } } - + return client; } throw err; @@ -172,10 +182,10 @@ export class ConnectionManager { const poolConfig: PoolConfig = { ...clientConfig, max: 10, - idleTimeoutMillis: 30000 + idleTimeoutMillis: 30000, }; const pool = new Pool(poolConfig); - pool.on('error', (err) => { + pool.on("error", (err) => { console.error(`Pool error for ${key}`, err); // Don't show modal for background pool errors, but could log to output channel in future }); @@ -183,7 +193,10 @@ export class ConnectionManager { } /** Get a persistent client for a session (notebooks, transactions). */ - public async getSessionClient(config: ConnectionConfig, sessionId: string): Promise { + public async getSessionClient( + config: ConnectionConfig, + sessionId: string, + ): Promise { const key = `${this.getConnectionKey(config)}:session:${sessionId}`; if (this.sessions.has(key)) return this.sessions.get(key)!; @@ -193,30 +206,33 @@ export class ConnectionManager { try { await client.connect(); - + // Apply read-only mode if configured if (config.readOnlyMode) { try { - await client.query('SET default_transaction_read_only = ON'); + await client.query("SET default_transaction_read_only = ON"); } catch (err) { - console.warn('Failed to set read-only mode:', err); + console.warn("Failed to set read-only mode:", err); } } } catch (err: any) { if (this.shouldFallback(config, err)) { - console.warn(`Session SSL connection failed for ${key}, falling back to non-SSL`, err); + console.warn( + `Session SSL connection failed for ${key}, falling back to non-SSL`, + err, + ); // Retry with SSL disabled const nonSSLConfig = await this.createClientConfig(config, true); client = new Client(nonSSLConfig); await client.connect(); - + // Apply read-only mode if configured if (config.readOnlyMode) { try { - await client.query('SET default_transaction_read_only = ON'); + await client.query("SET default_transaction_read_only = ON"); } catch (err) { - console.warn('Failed to set read-only mode:', err); + console.warn("Failed to set read-only mode:", err); } } } else { @@ -224,19 +240,22 @@ export class ConnectionManager { } } - client.on('end', () => this.sessions.delete(key)); - client.on('error', (err) => { + client.on("end", () => this.sessions.delete(key)); + client.on("error", (err) => { console.error(`Session client error for ${key}`, err); - ErrorService.getInstance().showError(`Session connection error (${config.name}): ${err.message}`); + ErrorService.getInstance().showError( + `Session connection error (${config.name}): ${err.message}`, + ); this.sessions.delete(key); }); this.sessions.set(key, client); return client; } - - - public async closeSession(config: ConnectionConfig, sessionId: string): Promise { + public async closeSession( + config: ConnectionConfig, + sessionId: string, + ): Promise { const key = `${this.getConnectionKey(config)}:session:${sessionId}`; const client = this.sessions.get(key); if (client) { @@ -256,7 +275,9 @@ export class ConnectionManager { if (pool) { try { await pool.end(); - } catch (e) { console.error(`Error closing pool ${baseKey}`, e); } + } catch (e) { + console.error(`Error closing pool ${baseKey}`, e); + } this.pools.delete(baseKey); } @@ -264,7 +285,9 @@ export class ConnectionManager { if (key.startsWith(baseKey)) { try { await client.end(); - } catch (e) { console.error(`Error closing session ${key}`, e); } + } catch (e) { + console.error(`Error closing session ${key}`, e); + } this.sessions.delete(key); } } @@ -281,7 +304,9 @@ export class ConnectionManager { for (const key of poolKeysToRemove) { const pool = this.pools.get(key); if (pool) { - await pool.end().catch(e => console.error(`Error ending pool ${key}`, e)); + await pool + .end() + .catch((e) => console.error(`Error ending pool ${key}`, e)); this.pools.delete(key); } } @@ -293,7 +318,9 @@ export class ConnectionManager { for (const key of sessionKeysToRemove) { const client = this.sessions.get(key); if (client) { - await client.end().catch(e => console.error(`Error ending session ${key}`, e)); + await client + .end() + .catch((e) => console.error(`Error ending session ${key}`, e)); this.sessions.delete(key); } } @@ -301,62 +328,134 @@ export class ConnectionManager { console.log(`Closed resources for ID: ${connectionId}`); } - public async closeAll(): Promise { for (const pool of this.pools.values()) { - await pool.end().catch(e => console.error('Error closing pool', e)); + await pool.end().catch((e) => console.error("Error closing pool", e)); } this.pools.clear(); for (const client of this.sessions.values()) { - await client.end().catch(e => console.error('Error closing session', e)); + await client + .end() + .catch((e) => console.error("Error closing session", e)); } this.sessions.clear(); } private getConnectionKey(config: ConnectionConfig): string { - return `${config.id}:${config.database || 'postgres'}`; + return `${config.id}:${config.database || "postgres"}`; } - private async createClientConfig(config: ConnectionConfig, forceDisableSSL: boolean = false): Promise { + private async createClientConfig( + config: ConnectionConfig, + forceDisableSSL: boolean = false, + ): Promise { let password: string | undefined; if (config.username) { - password = await SecretStorageService.getInstance().getPassword(config.id); + password = await SecretStorageService.getInstance().getPassword( + config.id, + ); + } + + // ── Explicit pgpass resolution ───────────────────────────────────────── + // When no password is stored in SecretStorage (the user relies on a pgpass + // file), the pg library's *internal* pgpass lookup can silently fail — + // most commonly on Windows where the expected file path is + // %APPDATA%\postgresql\pgpass.conf + // rather than ~/.pgpass. If that lookup returns undefined, pg keeps the + // password as null and SCRAM authentication throws: + // "SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string" + // + // By resolving the pgpass password ourselves and passing it explicitly we + // bypass pg's internal lookup entirely and maintain consistent behaviour + // across all platforms. + if (!password && config.username) { + const targetDb = config.database || "postgres"; + const pgpassPassword = resolvePgPassPassword( + config.host, + config.port, + targetDb, + config.username, + ); + if (pgpassPassword !== undefined) { + password = pgpassPassword; + console.log( + `[ConnectionManager] Password resolved from pgpass file for ${config.username}@${config.host}:${config.port}/${targetDb}`, + ); + } else if (targetDb !== "postgres") { + // Also try the postgres database in case the entry uses that as the + // database field (e.g. when the target database doesn't exist yet). + const fallback = resolvePgPassPassword( + config.host, + config.port, + "postgres", + config.username, + ); + if (fallback !== undefined) { + password = fallback; + console.log( + `[ConnectionManager] Password resolved from pgpass file (postgres fallback) for ${config.username}@${config.host}:${config.port}`, + ); + } + } + + if (!password) { + console.warn( + `[ConnectionManager] No password found in SecretStorage or pgpass for ` + + `${config.username}@${config.host}:${config.port}/${targetDb}. ` + + `Expected pgpass file location: ${pgPassFileDescription()}`, + ); + } } let sslConfig: boolean | any = false; // Default to 'prefer' if empty/undefined. // If forceDisableSSL is true, we ignore sslmode and leave sslConfig as false. - const sslMode = config.sslmode || 'prefer'; + const sslMode = config.sslmode || "prefer"; - if (!forceDisableSSL && sslMode !== 'disable') { + if (!forceDisableSSL && sslMode !== "disable") { sslConfig = { - rejectUnauthorized: sslMode === 'verify-ca' || sslMode === 'verify-full' + rejectUnauthorized: + sslMode === "verify-ca" || sslMode === "verify-full", }; if (config.sslRootCertPath) { - try { sslConfig.ca = fs.readFileSync(config.sslRootCertPath).toString(); } - catch (e) { console.warn('Failed to read SSL CA:', e); } + try { + sslConfig.ca = fs.readFileSync(config.sslRootCertPath).toString(); + } catch (e) { + console.warn("Failed to read SSL CA:", e); + } } if (config.sslCertPath) { - try { sslConfig.cert = fs.readFileSync(config.sslCertPath).toString(); } - catch (e) { console.warn('Failed to read SSL Cert:', e); } + try { + sslConfig.cert = fs.readFileSync(config.sslCertPath).toString(); + } catch (e) { + console.warn("Failed to read SSL Cert:", e); + } } if (config.sslKeyPath) { - try { sslConfig.key = fs.readFileSync(config.sslKeyPath).toString(); } - catch (e) { console.warn('Failed to read SSL Key:', e); } + try { + sslConfig.key = fs.readFileSync(config.sslKeyPath).toString(); + } catch (e) { + console.warn("Failed to read SSL Key:", e); + } } } const clientConfig: ClientConfig = { user: config.username || undefined, password: password || undefined, - database: config.database || 'postgres', + database: config.database || "postgres", connectionTimeoutMillis: (config.connectTimeout || 5) * 1000, - statement_timeout: config.statementTimeout || vscode.workspace.getConfiguration('postgresExplorer').get('queryTimeout') || undefined, - application_name: config.applicationName || 'PgStudio', + statement_timeout: + config.statementTimeout || + vscode.workspace + .getConfiguration("postgresExplorer") + .get("queryTimeout") || + undefined, + application_name: config.applicationName || "PgStudio", ssl: sslConfig || undefined, - ...(config.options ? { options: config.options } : {}) + ...(config.options ? { options: config.options } : {}), }; if (config.ssh && config.ssh.enabled) { @@ -364,12 +463,14 @@ export class ConnectionManager { const stream = await SSHService.getInstance().createStream( config.ssh, config.host, - config.port + config.port, ); clientConfig.stream = stream as any; } catch (err: any) { // SSH errors are critical for connection creation - ErrorService.getInstance().showError(`SSH Connection failed: ${err.message}`); + ErrorService.getInstance().showError( + `SSH Connection failed: ${err.message}`, + ); throw new Error(`SSH Connection failed: ${err.message}`); } } else { @@ -380,4 +481,3 @@ export class ConnectionManager { return clientConfig; } } - diff --git a/src/services/SecretStorageService.js b/src/services/SecretStorageService.js new file mode 100644 index 0000000..c7b8737 --- /dev/null +++ b/src/services/SecretStorageService.js @@ -0,0 +1,2 @@ +// Proxy to compiled output for test runner resolution +module.exports = require('../../out/services/SecretStorageService'); diff --git a/src/test/setup.ts b/src/test/setup.ts index 92db585..2a37fce 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,4 +1 @@ -import moduleAlias from 'module-alias'; -import path from 'path'; - -moduleAlias.addAlias('vscode', path.join(__dirname, 'unit/mocks/vscode.ts')); +// Test setup file - tsconfig-paths handles path resolution via -r flag in test commands diff --git a/src/test/tsconfig.json b/src/test/tsconfig.json index e398cbf..4b30b30 100644 --- a/src/test/tsconfig.json +++ b/src/test/tsconfig.json @@ -6,7 +6,7 @@ "baseUrl": ".", "paths": { "vscode": [ - "unit/mocks/vscode.ts" + "unit/mocks/vscode" ] }, "types": [ @@ -16,9 +16,20 @@ "sinon" ], "esModuleInterop": true, - "allowSyntheticDefaultImports": true + "allowSyntheticDefaultImports": true, + "allowJs": true + }, + "ts-node": { + "compilerOptions": { + "module": "commonjs" + }, + "require": ["tsconfig-paths/register"] }, "include": [ - "**/*.ts" + "./**/*.ts", + "./**/*.js" + ], + "exclude": [ + "../../node_modules" ] } \ No newline at end of file diff --git a/src/test/unit/mocks/vscode.js b/src/test/unit/mocks/vscode.js new file mode 100644 index 0000000..a56e786 --- /dev/null +++ b/src/test/unit/mocks/vscode.js @@ -0,0 +1,225 @@ +import * as sinon from 'sinon'; + +export const workspace = { + getConfiguration: () => ({ + get: () => [], + update: () => Promise.resolve(), + }), + onDidChangeConfiguration: () => ({ dispose: () => { } }), + notebookDocuments: [], + onDidOpenNotebookDocument: () => ({ dispose: () => { } }), + onDidSaveNotebookDocument: () => ({ dispose: () => { } }), + onDidChangeNotebookDocument: () => ({ dispose: () => { } }), + onDidCloseNotebookDocument: () => ({ dispose: () => { } }), + fs: { + readFile: async () => new Uint8Array(), + writeFile: async () => { } + } +}; + +export const window = { + createOutputChannel: () => ({ + appendLine: () => { }, + show: () => { }, + dispose: () => { } + }), + showErrorMessage: async () => { }, + showInformationMessage: async () => { }, + createTreeView: () => ({ + reveal: async () => { } + }), + registerTreeDataProvider: () => ({ dispose: () => { } }) +}; + +export const commands = { + registerCommand: () => ({ dispose: () => { } }), + executeCommand: async () => { } +}; + +export const notebooks = { + createNotebookController: () => ({ + createNotebookCellExecution: () => ({ + start: () => { }, + end: () => { }, + replaceOutput: () => { }, + clearOutput: () => { } + }) + }) +}; + +export const TreeItemCollapsibleState = { + None: 0, + Collapsed: 1, + Expanded: 2 +}; + +export class TreeItem { + constructor(label, collapsibleState) { + this.label = label; + this.collapsibleState = collapsibleState; + } +} + +export class EventEmitter { + constructor() { + this.event = () => ({ dispose: () => { } }); + this.fire = () => { }; + } +} + +export class Disposable { + constructor() { + this.dispose = () => { }; + } +} + +export const ExtensionContext = { + subscriptions: [] +}; + +export const SecretStorage = { + get: async () => undefined, + store: async () => { }, + delete: async () => { }, + onDidChange: () => ({ dispose: () => { } }) +}; + +export class ThemeColor { + constructor(id) { + this.id = id; + } +} + +export class ThemeIcon { + constructor(id, color) { + this.id = id; + this.color = color; + } +} + +export class NotebookCellOutput { + constructor(items, metadata) { + this.items = items; + this.metadata = metadata; + } +} + +export class NotebookCellOutputItem { + constructor(data, mime) { + this.data = data; + this.mime = mime; + } + + static text(value, mime) { + return new NotebookCellOutputItem(Buffer.from(value), mime || 'text/plain'); + } + + static error(err) { + return new NotebookCellOutputItem(Buffer.from(String(err)), 'application/vnd.code.notebook.error'); + } +} + +export const languages = { + registerCompletionItemProvider: () => ({ dispose: () => { } }) +}; + +export const NotebookCellKind = { + Markup: 1, + Code: 2 +}; + +export const CompletionItemKind = { + Text: 0, + Method: 1, + Function: 2, + Constructor: 3, + Field: 4, + Variable: 5, + Class: 6, + Interface: 7, + Module: 8, + Property: 9, + Unit: 10, + Value: 11, + Enum: 12, + Keyword: 13, + Snippet: 14, + Color: 15, + File: 16, + Reference: 17, + Folder: 18, + EnumMember: 19, + Constant: 20, + Struct: 21, + Event: 22, + Operator: 23, + TypeParameter: 24, + User: 25, + Issue: 26, +}; + +export const QuickPickItemKind = { + Separator: -1, + Default: 0 +}; + +export class CompletionItem { + constructor(label, kind) { + this.label = label; + this.kind = kind; + this.detail = undefined; + this.documentation = undefined; + this.insertText = undefined; + } +} + +export class MarkdownString { + constructor(value) { + this.value = value; + } + + appendCodeblock(value, language) { return this; } + appendMarkdown(value) { return this; } + appendText(value) { return this; } +} + +export class SnippetString { + constructor(value) { + this.value = value; + } + + appendText(value) { return this; } + appendTabstop(number) { return this; } + appendPlaceholder(value, number) { return this; } + appendVariable(name, defaultValue) { return this; } +} + +export class Position { + constructor(line, character) { + this.line = line; + this.character = character; + } +} + +export class Range { + constructor(start, end) { + this.start = start; + this.end = end; + } +} + +export class Uri { + constructor(fsPath) { + this.fsPath = fsPath; + } + + static file(path) { return new Uri(path); } + static parse(path) { return new Uri(path); } + static joinPath(base, ...segments) { + const joined = [base.fsPath, ...segments].join('/').replace(/\/+/g, '/'); + return new Uri(joined); + } + + toString() { return this.fsPath; } + with(change) { return this; } +} diff --git a/src/test/unit/mocks/vscode.ts b/src/test/unit/mocks/vscode.ts index f4318f2..e3d4cb2 100644 --- a/src/test/unit/mocks/vscode.ts +++ b/src/test/unit/mocks/vscode.ts @@ -1,193 +1,122 @@ -import * as sinon from 'sinon'; +// Comprehensive mock of a subset of the `vscode` API used by tests +// This mock focuses on types and members referenced in the codebase and tests. +export type Thenable = Promise; +declare global { type Thenable = Promise; } + +export interface Memento { + get(key: string, defaultValue?: T): T; + update(key: string, value: any): Thenable; +} + +export interface ExtensionContext { + subscriptions: { dispose(): void }[]; + workspaceState: Memento; + globalState: Memento; + extensionPath?: string; + extensionUri?: Uri; + secrets?: { + get(key: string): Promise; + store(key: string, value: string): Promise; + delete(key: string): Promise; + }; +} + +export class OutputChannel { appendLine(_s: string) { } show() { } dispose() { } } + +export const ProgressLocation = { Notification: 1, Window: 2, SourceControl: 3 } as const; export const workspace = { - getConfiguration: () => ({ - get: () => [], - update: () => Promise.resolve(), - }), - onDidChangeConfiguration: () => ({ dispose: () => { } }), + getConfiguration: (_section?: string) => ({ get: (_k: string, _d?: T) => _d as T, update: async () => { } }), + onDidChangeConfiguration: (_cb?: any) => ({ dispose: () => { } }), + fs: { + readFile: async (_uri: any) => new Uint8Array(), + writeFile: async (_uri: any, _b: Uint8Array) => { } + }, notebookDocuments: [] as any[], onDidOpenNotebookDocument: () => ({ dispose: () => { } }), onDidSaveNotebookDocument: () => ({ dispose: () => { } }), onDidChangeNotebookDocument: () => ({ dispose: () => { } }), onDidCloseNotebookDocument: () => ({ dispose: () => { } }), - fs: { - readFile: async () => new Uint8Array(), - writeFile: async () => { } - } -}; + applyEdit: async (_edit: any) => true +} as any; -export const window = { - createOutputChannel: () => ({ - appendLine: () => { }, - show: () => { }, - dispose: () => { } - }), - showErrorMessage: async () => { }, - showInformationMessage: async () => { }, - createTreeView: () => ({ - reveal: async () => { } - }), - registerTreeDataProvider: () => ({ dispose: () => { } }) -}; - -export const commands = { - registerCommand: () => ({ dispose: () => { } }), - executeCommand: async () => { } -}; - -export const notebooks = { - createNotebookController: () => ({ - createNotebookCellExecution: () => ({ - start: () => { }, - end: () => { }, - replaceOutput: () => { }, - clearOutput: () => { } - }) - }) -}; - -export enum TreeItemCollapsibleState { - None = 0, - Collapsed = 1, - Expanded = 2 -} +export class ThemeColor { constructor(public id: string) { } } +export class ThemeIcon { constructor(public id: string, public color?: ThemeColor) { } } -export class TreeItem { - constructor(public label: string, public collapsibleState?: TreeItemCollapsibleState) { } +export class EventEmitter { + private listeners: ((e: T) => any)[] = []; + event = (listener: (e: T) => any) => { this.listeners.push(listener); return { dispose: () => { } }; }; + fire = (e?: T) => { this.listeners.forEach(l => l(e as T)); }; } -export class EventEmitter { - event = () => ({ dispose: () => { } }); - fire = () => { }; -} +export class Disposable { dispose() { } } -export class Disposable { - dispose = () => { }; -} +export const env = { clipboard: { writeText: async (_s: string) => { } } } as any; -export const ExtensionContext = { - subscriptions: [] -}; - -export const SecretStorage = { - get: async () => undefined, - store: async () => { }, - delete: async () => { }, - onDidChange: () => ({ dispose: () => { } }) -}; -export class ThemeColor { - constructor(public id: string) { } -} +export const window = { + createOutputChannel: (_name?: string) => new OutputChannel(), + showErrorMessage: async (_msg?: string) => undefined, + showInformationMessage: async (_msg?: string) => undefined, + showWarningMessage: async (_msg?: string) => undefined, + showQuickPick: async (_items?: any[], _opts?: any) => undefined, + showSaveDialog: async (_opts?: any) => undefined, + createTreeView: (_id: string, _opts?: any) => ({ reveal: async () => { } }), + withProgress: async (_opt: any, _cb: any) => { return await _cb({ report: (_: any) => { } }); } +} as any; -export class ThemeIcon { - constructor(public id: string, public color?: ThemeColor) { } -} +export const commands = { registerCommand: (_name: string, _cb?: any) => ({ dispose: () => { } }), executeCommand: async (_cmd: string, ..._args: any[]) => undefined } as any; -export class NotebookCellOutput { - metadata: any; - constructor(public items: any[], metadata?: any) { - this.items = items; - this.metadata = metadata; - } -} +export const languages = { registerCompletionItemProvider: (_selector: any, _provider: any) => ({ dispose: () => { } }) } as any; -export class NotebookCellOutputItem { - constructor(public data: any, public mime: string) { } +export const TreeItemCollapsibleState = { None: 0, Collapsed: 1, Expanded: 2 } as const; +export type TreeItemCollapsibleState = typeof TreeItemCollapsibleState[keyof typeof TreeItemCollapsibleState]; +export class TreeItem { constructor(public label: string, public collapsibleState?: TreeItemCollapsibleState) { } } - static text(value: string, mime?: string) { - return new NotebookCellOutputItem(Buffer.from(value), mime || 'text/plain'); - } - static error(err: any) { - return new NotebookCellOutputItem(Buffer.from(String(err)), 'application/vnd.code.notebook.error'); - } -} +export const NotebookCellKind = { Markup: 1, Code: 2 } as const; +export type NotebookCellKind = typeof NotebookCellKind[keyof typeof NotebookCellKind]; -export const languages = { - registerCompletionItemProvider: () => ({ dispose: () => { } }) -}; +export class NotebookCellData { constructor(public kind: NotebookCellKind, public value: string, public language?: string) { } } +export class NotebookRange { constructor(public start: number, public end: number) { } } +export class NotebookEdit { constructor(public range: NotebookRange, public cells: NotebookCellData[]) { } } +export class WorkspaceEdit { private map = new Map(); set(uri: Uri, edits: any[]) { this.map.set(uri.toString(), edits); } } -export enum NotebookCellKind { - Markup = 1, - Code = 2 -} +export class NotebookCellOutput { constructor(public items: any[], public metadata?: any) { } } +export class NotebookCellOutputItem { constructor(public data: any, public mime: string) { } static text(v: string, m?: string) { return new NotebookCellOutputItem(v, m || 'text/plain'); } static json(o: any, m?: string) { return new NotebookCellOutputItem(JSON.stringify(o), m || 'application/json'); } static error(e: any) { return new NotebookCellOutputItem(String(e), 'application/vnd.code.notebook.error'); } } +export class NotebookDocument { uri?: Uri; metadata?: any; constructor(uri?: Uri, metadata?: any) { this.uri = uri; this.metadata = metadata || {}; } } +export class NotebookEditor { notebook: NotebookDocument = new NotebookDocument(new Uri('/tmp/mock')); selection?: NotebookRange; } -export enum CompletionItemKind { - Text = 0, - Method = 1, - Function = 2, - Constructor = 3, - Field = 4, - Variable = 5, - Class = 6, - Interface = 7, - Module = 8, - Property = 9, - Unit = 10, - Value = 11, - Enum = 12, - Keyword = 13, - Snippet = 14, - Color = 15, - File = 16, - Reference = 17, - Folder = 18, - EnumMember = 19, - Constant = 20, - Struct = 21, - Event = 22, - Operator = 23, - TypeParameter = 24, - User = 25, - Issue = 26, -} +export class CompletionItem { detail?: string; documentation?: string | MarkdownString; insertText?: string | SnippetString; constructor(public label: string, public kind?: number) { } } +export const CompletionItemKind = { Text: 0, Method: 1, Function: 2, Constructor: 3, Field: 4, Variable: 5, Class: 6, Interface: 7, Module: 8, Property: 9, Unit: 10, Value: 11, Enum: 12, Keyword: 13, Snippet: 14, Color: 15, File: 16, Reference: 17, Folder: 18, EnumMember: 19, Constant: 20, Struct: 21, Event: 22, Operator: 23, TypeParameter: 24 } as const; +export type CompletionItemKind = typeof CompletionItemKind[keyof typeof CompletionItemKind]; -export class CompletionItem { - detail?: string; - documentation?: string | MarkdownString; - insertText?: string | SnippetString; - kind?: CompletionItemKind; +export class MarkdownString { constructor(public value?: string) { } appendCodeblock(_v: string, _lang?: string) { return this; } appendMarkdown(_v: string) { return this; } appendText(_v: string) { return this; } } +export class SnippetString { constructor(public value?: string) { } appendText(_v: string) { return this; } appendTabstop(_n?: number) { return this; } appendPlaceholder(_v: string, _n?: number) { return this; } appendVariable(_n: string, _d?: string) { return this; } } - constructor(public label: string | CompletionItemLabel, kind?: CompletionItemKind) { - this.kind = kind; - } -} +export interface TextDocument { getText(range?: any): string; uri?: Uri; } +export class Position { constructor(public line: number, public character: number) { } } +export interface CancellationToken { readonly isCancellationRequested?: boolean; } +export interface CompletionContext { triggerKind?: number; triggerCharacter?: string; } +export interface CompletionItemProvider { provideCompletionItems(document: TextDocument, position: Position, token?: CancellationToken, context?: CompletionContext): Thenable | any } -export type CompletionItemLabel = { label: string, detail?: string, description?: string }; +export class NotebookCell { outputs: NotebookCellOutput[] = []; constructor(public document: NotebookDocument, public index: number, public kind?: NotebookCellKind) { } } +export class NotebookController { createNotebookCellExecution(_cell: NotebookCell) { return { start: () => { }, end: () => { }, replaceOutput: () => { }, clearOutput: () => { } }; } } -export class MarkdownString { - constructor(public value?: string) { } - appendCodeblock(value: string, language?: string) { return this; } - appendMarkdown(value: string) { return this; } - appendText(value: string) { return this; } -} +export class NotebookCellOutputItem { /* static helpers above */ } -export class SnippetString { - constructor(public value?: string) { } - appendText(value: string) { return this; } - appendTabstop(number?: number) { return this; } - appendPlaceholder(value: string | ((snippet: SnippetString) => any), number?: number) { return this; } - appendVariable(name: string, defaultValue?: string | ((snippet: SnippetString) => any)) { return this; } -} +export class Uri { constructor(public fsPath: string) { } toString() { return this.fsPath; } static file(path: string) { return new Uri(path); } static parse(s: string) { return new Uri(s); } static joinPath(base: Uri, ...segments: string[]) { return new Uri([base.fsPath, ...segments].join('/')); } } -export class Position { - constructor(public line: number, public character: number) { } -} +export const ViewColumn = { One: 1, Two: 2, Three: 3 } as const; +export interface Webview { html?: string; onDidReceiveMessage(cb: (m: any) => any): { dispose(): void }; postMessage(m: any): Thenable; asWebviewUri(uri: Uri): Uri } +export interface WebviewPanel { webview: Webview; reveal(column?: any): void; onDidDispose(cb: () => any): { dispose(): void } } +export const QuickPickItemKind = { Separator: -1, Default: 0 } as const; +export interface QuickPickItem { label: string; description?: string; detail?: string; original?: any } -export class Range { - constructor(public start: Position, public end: Position) { } -} -export class Uri { - static file(path: string) { return new Uri(path); } - static parse(path: string) { return new Uri(path); } - static joinPath(base: Uri, ...segments: string[]) { - // Simple mock implementation - const joined = [base.fsPath, ...segments].join('/').replace(/\/+/g, '/'); - return new Uri(joined); - } - constructor(public readonly fsPath: string) { } - toString() { return this.fsPath; } - with(change: any) { return this; } -} +// wire up helpers +(workspace as any).applyEdit = async (_e: any) => true; +(window as any).createWebviewPanel = (_vt: string, _title: string, _show: any, _opts?: any) => ({ webview: { html: undefined, onDidReceiveMessage: (_cb: any) => ({ dispose: () => { } }), postMessage: async (_m: any) => true, asWebviewUri: (u: Uri) => u }, reveal: (_c?: any) => { }, onDidDispose: (_cb: any) => ({ dispose: () => { } }) } as any); + +// default namespace-like export +const vscode = { Thenable: undefined, Memento: undefined, ExtensionContext: undefined, ProgressLocation, workspace, window, commands, languages, EventEmitter, Disposable, TreeItem, TreeItemCollapsibleState, NotebookCellKind, NotebookCellData, NotebookRange, NotebookEdit, WorkspaceEdit, NotebookCellOutput, NotebookCellOutputItem, NotebookDocument, NotebookEditor, CompletionItem, CompletionItemKind, MarkdownString, SnippetString, Uri, SecretStorage, ThemeColor, ThemeIcon, env, ViewColumn, Webview, WebviewPanel, QuickPickItemKind, QuickPickItem } as any; +export default vscode; diff --git a/src/utils/connectionUtils.ts b/src/utils/connectionUtils.ts index ab29562..1d6a306 100644 --- a/src/utils/connectionUtils.ts +++ b/src/utils/connectionUtils.ts @@ -35,7 +35,7 @@ export class ConnectionUtils { ): Promise { const newMetadata = { ...notebook.metadata, ...updates }; const edit = new vscode.WorkspaceEdit(); - edit.set(notebook.uri, [vscode.NotebookEdit.updateNotebookMetadata(newMetadata)]); + edit.set(notebook.uri, [vscode.NotebookEdit?.updateNotebookMetadata(newMetadata)]); await vscode.workspace.applyEdit(edit); } diff --git a/src/utils/pgPassUtils.ts b/src/utils/pgPassUtils.ts new file mode 100644 index 0000000..4686a6a --- /dev/null +++ b/src/utils/pgPassUtils.ts @@ -0,0 +1,219 @@ +/** + * pgPassUtils.ts + * + * Explicit, OS-aware reader for PostgreSQL's password file (.pgpass / pgpass.conf). + * + * Why this exists + * --------------- + * The `pg` library calls the `pgpass` npm package internally to look up + * passwords, but only AFTER the client is constructed and only when password + * is null/undefined. If the lookup returns undefined (file missing, wrong + * path, no matching entry) the password stays null, and SCRAM-SHA-256 + * authentication throws: + * + * "SASL: SCRAM-SERVER-FIRST-MESSAGE: client password must be a string" + * + * This module lets callers resolve the password **before** constructing the + * pg Client, so we can: + * 1. Pass the resolved password explicitly → pg never reaches the null case. + * 2. Surface a clear, actionable error message that includes the expected + * file path for the user's OS when no password can be found. + * + * File locations (mirrors libpq / pgpass npm package behaviour) + * ------------------------------------------------------------- + * PGPASSFILE env var → whatever it points to (any OS) + * Windows (win32) → %APPDATA%\postgresql\pgpass.conf + * Unix / macOS → ~/.pgpass + * + * File format (RFC-like) + * ---------------------- + * hostname:port:database:username:password + * - Each line is one entry. + * - Lines starting with # are comments. + * - Each of the first four fields may be a literal value or * (wildcard). + * - Colons and backslashes inside field values must be escaped as \: and \\. + * - The password field is everything after the 4th colon (may contain colons). + */ + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Returns the absolute path to the pgpass file that would be consulted on the + * current machine. Useful for generating actionable error messages. + */ +export function getPgPassFilePath(): string { + if (process.env.PGPASSFILE) { + return process.env.PGPASSFILE; + } + if (process.platform === 'win32') { + // %APPDATA% is normally set on Windows; fall back to home dir if not. + const appData = process.env.APPDATA || os.homedir(); + return path.join(appData, 'postgresql', 'pgpass.conf'); + } + return path.join(os.homedir(), '.pgpass'); +} + +/** + * Look up a password in the pgpass file for the given connection parameters. + * + * @returns The matching password string, or `undefined` if no entry matched + * (file absent, unreadable, or no entry for those parameters). + */ +export function resolvePgPassPassword( + host: string, + port: number | string, + database: string, + user: string, +): string | undefined { + const filePath = getPgPassFilePath(); + + let content: string; + try { + content = fs.readFileSync(filePath, 'utf8'); + } catch { + // File absent or unreadable — not an error in itself. + return undefined; + } + + const portStr = String(port); + + // Normalise line endings (Windows files inside a cross-platform repo may + // use CRLF even when the runtime is Linux, and vice-versa on Windows). + const lines = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n'); + + for (const rawLine of lines) { + const line = rawLine.trim(); + + // Skip blank lines and comments. + if (!line || line.startsWith('#')) { + continue; + } + + const fields = splitPgPassLine(line); + if (fields === null) { + // Malformed line (fewer than 5 fields) — skip. + continue; + } + + const [pgHost, pgPort, pgDatabase, pgUser, ...passwordParts] = fields; + // The password is everything after the 4th colon (already unescaped by + // splitPgPassLine), re-joined with ':' in case colons were escaped in + // the original but we've already unescaped them as individual chars. + const pgPassword = passwordParts.join(':'); + + if ( + fieldMatches(pgHost, host) && + fieldMatches(pgPort, portStr) && + fieldMatches(pgDatabase, database) && + fieldMatches(pgUser, user) + ) { + return pgPassword; + } + } + + return undefined; +} + +/** + * Same as `resolvePgPassPassword` but wrapped in a Promise for callers that + * prefer async/await. The underlying file read is synchronous (pgpass files + * are tiny), so this is just a convenience wrapper. + */ +export async function resolvePgPassPasswordAsync( + host: string, + port: number | string, + database: string, + user: string, +): Promise { + return resolvePgPassPassword(host, port, database, user); +} + +/** + * Returns a human-readable description of the pgpass file location for the + * current OS — suitable for embedding in error messages shown to the user. + * + * Example outputs: + * Windows → "C:\Users\alice\AppData\Roaming\postgresql\pgpass.conf" + * Unix → "/home/alice/.pgpass" + * Custom → "/custom/path/pgpass" (via PGPASSFILE env var) + */ +export function pgPassFileDescription(): string { + const filePath = getPgPassFilePath(); + if (process.env.PGPASSFILE) { + return `${filePath} (set by PGPASSFILE environment variable)`; + } + if (process.platform === 'win32') { + return `${filePath} (Windows: %%APPDATA%%\\postgresql\\pgpass.conf)`; + } + return `${filePath} (Unix/macOS: ~/.pgpass)`; +} + +/** + * Returns true when the pgpass file exists and is readable. + */ +export function pgPassFileExists(): boolean { + try { + fs.accessSync(getPgPassFilePath(), fs.constants.R_OK); + return true; + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Split one pgpass line into its (already-unescaped) fields. + * + * Returns null if the line has fewer than 5 fields after parsing. + * + * Escaping rules (from libpq docs): + * \\ → \ + * \: → : + * Any other \X sequence is left as-is (backslash dropped, X kept) to match + * the behaviour of the reference pgpass npm package. + */ +function splitPgPassLine(line: string): string[] | null { + const fields: string[] = []; + let current = ''; + let i = 0; + + while (i < line.length) { + const ch = line[i]; + + if (ch === '\\' && i + 1 < line.length) { + // Consume the backslash and treat the next character literally. + current += line[i + 1]; + i += 2; + } else if (ch === ':') { + fields.push(current); + current = ''; + i++; + } else { + current += ch; + i++; + } + } + + // Push the last field (the password, which may contain colons that have + // already been unescaped above). + fields.push(current); + + return fields.length >= 5 ? fields : null; +} + +/** + * Return true when pgpass `pattern` matches the connection `value`. + * A pattern of `*` matches any value. + */ +function fieldMatches(pattern: string, value: string): boolean { + return pattern === '*' || pattern === value; +} diff --git a/tsconfig.json b/tsconfig.json index 74921b2..38495de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,9 @@ ], "esModuleInterop": true }, + "include": [ + "src/**/*" + ], "exclude": [ "node_modules", ".vscode-test",