diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 21f17aa1..c994786d 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -10,6 +10,21 @@ jobs: unit-tests: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + POSTGRES_DB: testdb + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - uses: actions/checkout@v4 diff --git a/docker-compose.psql-test.yml b/docker-compose.psql-test.yml new file mode 100644 index 00000000..f16b9844 --- /dev/null +++ b/docker-compose.psql-test.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: just-bash-psql-test + environment: + POSTGRES_USER: testuser + POSTGRES_PASSWORD: testpass + POSTGRES_DB: testdb + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U testuser -d testdb"] + interval: 2s + timeout: 5s + retries: 10 diff --git a/package.json b/package.json index caacf410..30d32380 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "minimatch": "^10.1.1", "modern-tar": "^0.7.3", "papaparse": "^5.5.3", + "postgres": "^3.4.5", "pyodide": "^0.27.0", "re2js": "^1.2.1", "smol-toml": "^1.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a5f7f1c3..f50c2254 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ dependencies: papaparse: specifier: ^5.5.3 version: 5.5.3 + postgres: + specifier: ^3.4.5 + version: 3.4.8 pyodide: specifier: ^0.27.0 version: 0.27.7 @@ -1644,6 +1647,11 @@ packages: source-map-js: 1.2.1 dev: true + /postgres@3.4.8: + resolution: {integrity: sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg==} + engines: {node: '>=12'} + dev: false + /prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} diff --git a/src/Bash.ts b/src/Bash.ts index 1b800fc8..5f4e9c9f 100644 --- a/src/Bash.ts +++ b/src/Bash.ts @@ -42,8 +42,10 @@ import { import { type ExecutionLimits, resolveLimits } from "./limits.js"; import { createSecureFetch, + createSecurePostgresConnect, type NetworkConfig, type SecureFetch, + type SecurePostgresConnect, } from "./network/index.js"; import { LexerError } from "./parser/lexer.js"; import { type ParseException, parse } from "./parser/parser.js"; @@ -212,6 +214,7 @@ export class Bash { private useDefaultLayout: boolean = false; private limits: Required; private secureFetch?: SecureFetch; + private securePostgresConnect?: SecurePostgresConnect; private sleepFn?: (ms: number) => Promise; private traceFn?: TraceCallback; private logger?: BashLogger; @@ -261,6 +264,16 @@ export class Bash { // Create secure fetch if network is configured if (options.network) { this.secureFetch = createSecureFetch(options.network); + + // Create secure PostgreSQL connect if PostgreSQL hosts are configured + if ( + options.network.allowedPostgresHosts || + options.network.dangerouslyAllowFullInternetAccess + ) { + this.securePostgresConnect = createSecurePostgresConnect( + options.network, + ); + } } // Store sleep function if provided (for mock clocks in testing) @@ -540,6 +553,7 @@ export class Bash { limits: this.limits, exec: this.exec.bind(this), fetch: this.secureFetch, + connectPostgres: this.securePostgresConnect, sleep: this.sleepFn, trace: this.traceFn, coverage: this.coverageWriter, diff --git a/src/commands/psql/connection.ts b/src/commands/psql/connection.ts new file mode 100644 index 00000000..9c233600 --- /dev/null +++ b/src/commands/psql/connection.ts @@ -0,0 +1,50 @@ +/** + * Connection string parsing and option resolution for psql + */ + +import type { SecurePostgresOptions } from "../../network/index.js"; +import type { PsqlOptions } from "./parser.js"; + +/** + * Build SecurePostgresOptions from parsed CLI options + */ +export function buildConnectionOptions( + options: PsqlOptions, +): SecurePostgresOptions | null { + // Host is required + if (!options.host) { + return null; + } + + return { + host: options.host, + port: options.port, + database: options.database, + username: options.username, + password: undefined, // Password via CLI is not supported for security + ssl: "prefer", // Default to prefer SSL + }; +} + +/** + * Get SQL to execute from options + * Returns empty string only if no SQL source is available (no -c, no -f, no stdin) + */ +export function getSqlToExecute(options: PsqlOptions, stdin: string): string { + // -c takes precedence + if (options.command) { + return options.command; + } + + // -f will be read later, return placeholder + if (options.file) { + return "FILE"; // Non-empty placeholder to pass validation + } + + // Check stdin + if (stdin.trim()) { + return stdin.trim(); + } + + return ""; +} diff --git a/src/commands/psql/formatters.ts b/src/commands/psql/formatters.ts new file mode 100644 index 00000000..0125d729 --- /dev/null +++ b/src/commands/psql/formatters.ts @@ -0,0 +1,205 @@ +/** + * Output formatters for psql command + */ + +import type { PsqlOptions } from "./parser.js"; + +/** + * Format query results based on output options + */ +export function formatResults( + columns: string[], + rows: unknown[][], + options: PsqlOptions, +): string { + if (rows.length === 0 && options.tuplesOnly) { + return ""; + } + + switch (options.outputFormat) { + case "aligned": + return formatAligned(columns, rows, options); + case "unaligned": + return formatUnaligned(columns, rows, options); + case "csv": + return formatCsv(columns, rows, options); + case "json": + return formatJson(columns, rows); + case "html": + return formatHtml(columns, rows, options); + default: + return formatAligned(columns, rows, options); + } +} + +/** + * Format as aligned table (default psql output) + */ +function formatAligned( + columns: string[], + rows: unknown[][], + options: PsqlOptions, +): string { + if (columns.length === 0) return ""; + + const widths = columns.map((col, i) => { + const maxDataWidth = Math.max( + ...rows.map((row) => String(row[i] ?? "").length), + ); + return Math.max(col.length, maxDataWidth); + }); + + let output = ""; + + // Header + if (!options.tuplesOnly) { + output += + columns.map((col, i) => col.padEnd(widths[i])).join(" | ") + + options.recordSeparator; + + // Separator line + output += + widths.map((w) => "-".repeat(w)).join("-+-") + options.recordSeparator; + } + + // Rows + for (const row of rows) { + output += + row.map((val, i) => String(val ?? "").padEnd(widths[i])).join(" | ") + + options.recordSeparator; + } + + // Footer with row count + if (!options.tuplesOnly && !options.quiet) { + const rowText = rows.length === 1 ? "row" : "rows"; + output += `(${rows.length} ${rowText})${options.recordSeparator}`; + } + + return output; +} + +/** + * Format as unaligned output (field separator delimited) + */ +function formatUnaligned( + columns: string[], + rows: unknown[][], + options: PsqlOptions, +): string { + let output = ""; + + // Header + if (!options.tuplesOnly) { + output += columns.join(options.fieldSeparator) + options.recordSeparator; + } + + // Rows + for (const row of rows) { + output += + row.map((val) => String(val ?? "")).join(options.fieldSeparator) + + options.recordSeparator; + } + + return output; +} + +/** + * Format as CSV + */ +function formatCsv( + columns: string[], + rows: unknown[][], + options: PsqlOptions, +): string { + let output = ""; + + // Header + if (!options.tuplesOnly) { + output += columns.map(escapeCsv).join(",") + options.recordSeparator; + } + + // Rows + for (const row of rows) { + output += + row.map((val) => escapeCsv(String(val ?? ""))).join(",") + + options.recordSeparator; + } + + return output; +} + +/** + * Escape CSV field (add quotes if needed) + */ +function escapeCsv(field: string): string { + if ( + field.includes(",") || + field.includes('"') || + field.includes("\n") || + field.includes("\r") + ) { + return `"${field.replace(/"/g, '""')}"`; + } + return field; +} + +/** + * Format as JSON array of objects + */ +function formatJson(columns: string[], rows: unknown[][]): string { + const objects = rows.map((row) => { + const obj: Record = {}; + for (let i = 0; i < columns.length; i++) { + obj[columns[i]] = row[i]; + } + return obj; + }); + + return `${JSON.stringify(objects, null, 2)}\n`; +} + +/** + * Format as HTML table + */ +function formatHtml( + columns: string[], + rows: unknown[][], + options: PsqlOptions, +): string { + let output = "\n"; + + // Header + if (!options.tuplesOnly) { + output += " \n \n"; + for (const col of columns) { + output += ` \n`; + } + output += " \n \n"; + } + + // Body + output += " \n"; + for (const row of rows) { + output += " \n"; + for (const val of row) { + output += ` \n`; + } + output += " \n"; + } + output += " \n"; + + output += "
${escapeHtml(col)}
${escapeHtml(String(val ?? ""))}
\n"; + return output; +} + +/** + * Escape HTML entities + */ +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/src/commands/psql/parser.ts b/src/commands/psql/parser.ts new file mode 100644 index 00000000..587f749a --- /dev/null +++ b/src/commands/psql/parser.ts @@ -0,0 +1,178 @@ +/** + * CLI argument parser for psql command + */ + +import type { ExecResult } from "../../types.js"; + +export interface PsqlOptions { + host?: string; + port?: number; + database?: string; + username?: string; + command?: string; + file?: string; + outputFormat: "aligned" | "unaligned" | "csv" | "json" | "html"; + fieldSeparator: string; + recordSeparator: string; + tuplesOnly: boolean; + quiet: boolean; + singleTransaction: boolean; + outputFile?: string; + variables: Record; +} + +export function parseArgs(args: string[]): PsqlOptions | ExecResult { + const options: PsqlOptions = { + outputFormat: "aligned", + fieldSeparator: "|", + recordSeparator: "\n", + tuplesOnly: false, + quiet: false, + singleTransaction: false, + variables: Object.create(null) as Record, + }; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === "-h" || arg === "--host") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'h'\n", + exitCode: 1, + }; + } + options.host = args[++i]; + } else if (arg === "-p" || arg === "--port") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'p'\n", + exitCode: 1, + }; + } + const port = Number.parseInt(args[++i], 10); + if (Number.isNaN(port) || port <= 0 || port > 65535) { + return { + stdout: "", + stderr: `psql: invalid port number: ${args[i]}\n`, + exitCode: 1, + }; + } + options.port = port; + } else if (arg === "-U" || arg === "--username") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'U'\n", + exitCode: 1, + }; + } + options.username = args[++i]; + } else if (arg === "-d" || arg === "--dbname") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'd'\n", + exitCode: 1, + }; + } + options.database = args[++i]; + } else if (arg === "-c" || arg === "--command") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'c'\n", + exitCode: 1, + }; + } + options.command = args[++i]; + } else if (arg === "-f" || arg === "--file") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'f'\n", + exitCode: 1, + }; + } + options.file = args[++i]; + } else if (arg === "-t" || arg === "--tuples-only") { + options.tuplesOnly = true; + } else if (arg === "-A" || arg === "--no-align") { + options.outputFormat = "unaligned"; + } else if (arg === "-F") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'F'\n", + exitCode: 1, + }; + } + options.fieldSeparator = args[++i]; + } else if (arg === "-R") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'R'\n", + exitCode: 1, + }; + } + options.recordSeparator = args[++i]; + } else if (arg === "--csv") { + options.outputFormat = "csv"; + } else if (arg === "--json") { + options.outputFormat = "json"; + } else if (arg === "-H" || arg === "--html") { + options.outputFormat = "html"; + } else if (arg === "-q" || arg === "--quiet") { + options.quiet = true; + } else if (arg === "-1" || arg === "--single-transaction") { + options.singleTransaction = true; + } else if (arg === "-o" || arg === "--output") { + if (i + 1 >= args.length) { + return { + stdout: "", + stderr: "psql: option requires an argument -- 'o'\n", + exitCode: 1, + }; + } + options.outputFile = args[++i]; + } else if (arg.startsWith("--set=")) { + const varDef = arg.slice(6); + const eqIndex = varDef.indexOf("="); + if (eqIndex === -1) { + return { + stdout: "", + stderr: `psql: invalid variable definition: ${arg}\n`, + exitCode: 1, + }; + } + const name = varDef.slice(0, eqIndex); + const value = varDef.slice(eqIndex + 1); + options.variables[name] = value; + } else if (arg.startsWith("-")) { + return { + stdout: "", + stderr: `psql: invalid option -- '${arg}'\nTry 'psql --help' for more information.\n`, + exitCode: 1, + }; + } else { + // Positional argument (database name or connection string) + if (!options.database) { + options.database = arg; + } else { + return { + stdout: "", + stderr: `psql: too many command-line arguments (first is "${arg}")\nTry 'psql --help' for more information.\n`, + exitCode: 1, + }; + } + } + + i++; + } + + return options; +} diff --git a/src/commands/psql/psql.ts b/src/commands/psql/psql.ts new file mode 100644 index 00000000..ee15bef9 --- /dev/null +++ b/src/commands/psql/psql.ts @@ -0,0 +1,258 @@ +/** + * psql - PostgreSQL interactive terminal + * + * Implements a subset of psql functionality for executing SQL queries + * against PostgreSQL databases with Deno Sandbox-style secrets management. + * + * Security: + * - Requires explicit network configuration with allowedPostgresHosts + * - Supports transparent credential injection (user code never sees production passwords) + * - Query timeout protection to prevent runaway queries + * - Single connection per command to prevent resource exhaustion + */ + +import type { Command, CommandContext, ExecResult } from "../../types.js"; +import { hasHelpFlag, showHelp } from "../help.js"; +import { buildConnectionOptions, getSqlToExecute } from "./connection.js"; +import { formatResults } from "./formatters.js"; +import { parseArgs } from "./parser.js"; + +const DEFAULT_QUERY_TIMEOUT_MS = 5000; + +const psqlHelp = { + name: "psql", + summary: "PostgreSQL interactive terminal", + usage: "psql [OPTIONS] [DBNAME]", + options: [ + "-h, --host HOST database server host", + "-p, --port PORT database server port (default: 5432)", + "-U, --username USER database user name", + "-d, --dbname DBNAME database name to connect to", + "-c, --command COMMAND run single command (SQL) and exit", + "-f, --file FILE execute commands from file", + "-t, --tuples-only print rows only (no header)", + "-A, --no-align unaligned table output mode", + "-F SEP field separator (default: |)", + "-R SEP record separator (default: \\n)", + "--csv CSV output mode", + "--json JSON output mode", + "-H, --html HTML table output mode", + "-q, --quiet suppress notices and row count", + "-1, --single-transaction execute as single transaction", + "-o, --output FILE send output to file", + "--set=VAR=VALUE set psql variable VAR to VALUE", + "--help show this help", + ], + examples: [ + 'psql -h localhost -U myuser -d mydb -c "SELECT version()"', + 'psql -h localhost -d mydb --json -c "SELECT * FROM users"', + 'echo "SELECT 1+1" | psql -h localhost -d mydb', + 'psql -h localhost -d mydb --csv -t -c "SELECT id, name FROM products"', + ], +}; + +/** + * Split SQL into individual statements + */ +function splitStatements(sql: string): string[] { + const statements: string[] = []; + let current = ""; + let inString = false; + let stringChar = ""; + + for (let i = 0; i < sql.length; i++) { + const char = sql[i]; + + if (inString) { + current += char; + if (char === stringChar) { + // Check for escaped quote + if (sql[i + 1] === stringChar) { + current += sql[++i]; + } else { + inString = false; + } + } + } else if (char === "'" || char === '"') { + current += char; + inString = true; + stringChar = char; + } else if (char === ";") { + const stmt = current.trim(); + if (stmt) statements.push(stmt); + current = ""; + } else { + current += char; + } + } + + const stmt = current.trim(); + if (stmt) statements.push(stmt); + + return statements; +} + +/** + * Execute SQL with timeout protection + */ +async function executeWithTimeout( + promise: Promise, + timeoutMs: number, +): Promise { + let timeoutId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `Query timeout: execution exceeded ${timeoutMs}ms limit. Increase ctx.limits.maxPostgresTimeoutMs if needed.`, + ), + ); + }, timeoutMs); + }); + + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + +export const psqlCommand: Command = { + name: "psql", + + async execute(args: string[], ctx: CommandContext): Promise { + if (hasHelpFlag(args)) { + return showHelp(psqlHelp); + } + + // Check if PostgreSQL access is configured + if (!ctx.connectPostgres) { + return { + stdout: "", + stderr: + "psql: PostgreSQL access not configured. Configure 'allowedPostgresHosts' in network options.\n", + exitCode: 1, + }; + } + + // Parse options + const parsed = parseArgs(args); + if ("exitCode" in parsed) return parsed; + + const options = parsed; + + // Build connection options + const connOptions = buildConnectionOptions(options); + if (!connOptions) { + return { + stdout: "", + stderr: "psql: no host specified (-h/--host required)\n", + exitCode: 1, + }; + } + + // Get SQL to execute + const sql = getSqlToExecute(options, ctx.stdin); + if (!sql) { + return { + stdout: "", + stderr: "psql: no SQL provided (use -c, -f, or stdin)\n", + exitCode: 1, + }; + } + + // Read SQL from file if specified + let sqlToExecute: string; + if (options.file) { + try { + const filePath = ctx.fs.resolvePath(ctx.cwd, options.file); + sqlToExecute = await ctx.fs.readFile(filePath); + } catch (e) { + return { + stdout: "", + stderr: `psql: ${options.file}: ${(e as Error).message}\n`, + exitCode: 1, + }; + } + } else { + // Use SQL from -c or stdin + sqlToExecute = sql; + } + + // Connect to PostgreSQL + let sql_connection: Awaited>; + try { + sql_connection = await ctx.connectPostgres(connOptions); + } catch (e) { + return { + stdout: "", + stderr: `psql: ${(e as Error).message}\n`, + exitCode: 1, + }; + } + + try { + // Get timeout from execution limits or use default + const timeoutMs = + ctx.limits?.maxPostgresTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS; + + // Split into statements + const statements = splitStatements(sqlToExecute); + let output = ""; + + // Execute statements + for (const stmt of statements) { + try { + const result = await executeWithTimeout( + sql_connection.unsafe(stmt), + timeoutMs, + ); + + // Format results + if (Array.isArray(result) && result.length > 0) { + const columns = Object.keys(result[0] as object); + const rows = result.map((row) => + columns.map((col) => (row as Record)[col]), + ); + + output += formatResults(columns, rows, options); + } else { + // No results (e.g., INSERT, UPDATE, DELETE without RETURNING) + if (!options.quiet) { + // Try to show affected rows if available + output += `Command completed successfully${options.recordSeparator}`; + } + } + } catch (e) { + const error = (e as Error).message; + return { + stdout: output, + stderr: `psql: ERROR: ${error}\n`, + exitCode: 1, + }; + } + } + + // Write output to file if requested + if (options.outputFile) { + try { + const filePath = ctx.fs.resolvePath(ctx.cwd, options.outputFile); + await ctx.fs.writeFile(filePath, output); + return { stdout: "", stderr: "", exitCode: 0 }; + } catch (e) { + return { + stdout: output, + stderr: `psql: ${options.outputFile}: ${(e as Error).message}\n`, + exitCode: 1, + }; + } + } + + return { stdout: output, stderr: "", exitCode: 0 }; + } finally { + // Always close connection + await sql_connection.end(); + } + }, +}; diff --git a/src/commands/psql/tests/availability.test.ts b/src/commands/psql/tests/availability.test.ts new file mode 100644 index 00000000..b8b54ccf --- /dev/null +++ b/src/commands/psql/tests/availability.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for psql command availability + * + * psql is only available when PostgreSQL network access is configured. + */ + +import { describe, expect, it } from "vitest"; +import { Bash } from "../../../Bash.js"; + +describe("psql availability", () => { + describe("without network configuration", () => { + it("psql command does not exist", async () => { + const env = new Bash(); + const result = await env.exec('psql -h localhost -c "SELECT 1"'); + expect(result.exitCode).toBe(127); // Command not found + expect(result.stderr).toContain("command not found"); + }); + + it("psql is not in /bin", async () => { + const env = new Bash(); + const result = await env.exec("ls /bin/psql"); + expect(result.exitCode).not.toBe(0); + expect(result.stderr).toContain("No such file"); + }); + + it("psql does not appear in ls /bin output", async () => { + const env = new Bash(); + const result = await env.exec("ls /bin | grep ^psql$"); + expect(result.stdout).toBe(""); + expect(result.exitCode).toBe(1); // grep returns 1 when no match + }); + }); + + describe("with network configuration", () => { + it("psql command exists with allowedPostgresHosts", async () => { + const env = new Bash({ + network: { allowedPostgresHosts: ["localhost"] }, + }); + const result = await env.exec("psql --help"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("psql"); + }); + + it("psql is in /bin when network configured", async () => { + const env = new Bash({ + network: { allowedPostgresHosts: ["localhost"] }, + }); + const result = await env.exec("ls /bin/psql"); + expect(result.exitCode).toBe(0); + }); + + it("psql --help shows usage", async () => { + const env = new Bash({ + network: { allowedPostgresHosts: ["localhost"] }, + }); + const result = await env.exec("psql --help"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("PostgreSQL interactive terminal"); + expect(result.stdout).toContain("-h, --host"); + expect(result.stdout).toContain("-c, --command"); + expect(result.stdout).toContain("--json"); + }); + + it("psql command exists with dangerouslyAllowFullInternetAccess", async () => { + const env = new Bash({ + network: { dangerouslyAllowFullInternetAccess: true }, + }); + const result = await env.exec("psql --help"); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("psql"); + }); + }); + + describe("error messages", () => { + it("shows helpful error when host not specified", async () => { + const env = new Bash({ + network: { allowedPostgresHosts: ["localhost"] }, + }); + const result = await env.exec('psql -c "SELECT 1"'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("no host specified"); + expect(result.stderr).toContain("-h/--host required"); + }); + + it("shows helpful error when no SQL provided", async () => { + const env = new Bash({ + network: { allowedPostgresHosts: ["localhost"] }, + }); + const result = await env.exec("psql -h localhost"); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("no SQL provided"); + expect(result.stderr).toContain("use -c, -f, or stdin"); + }); + }); +}); diff --git a/src/commands/psql/tests/basic.test.ts b/src/commands/psql/tests/basic.test.ts new file mode 100644 index 00000000..a3b57682 --- /dev/null +++ b/src/commands/psql/tests/basic.test.ts @@ -0,0 +1,199 @@ +/** + * Basic psql functionality tests + * + * Tests require PostgreSQL running on localhost:5432 + * with user: testuser, password: testpass, database: testdb + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { Bash } from "../../../Bash.js"; +import { + getTestNetworkConfigWithCreds, + isPostgresAvailable, + TEST_PG_CONFIG, +} from "./test-helpers.js"; + +describe("psql basic operations", () => { + let pgAvailable = false; + + beforeAll(async () => { + pgAvailable = await isPostgresAvailable(); + if (!pgAvailable) { + console.warn( + "\n⚠️ PostgreSQL not available on localhost:5432 - skipping psql integration tests", + ); + console.warn( + " Start PostgreSQL with: docker-compose -f docker-compose.psql-test.yml up -d\n", + ); + } + }); + + describe("simple queries", () => { + it("should execute SELECT query", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec('psql -h localhost -c "SELECT 1 as num"'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("num"); + expect(result.stdout).toContain("1"); + expect(result.stdout).toContain("(1 row)"); + }); + + it("should execute version query", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec('psql -h localhost -c "SELECT version()"'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("PostgreSQL"); + }); + + it("should execute multiple columns", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -c "SELECT 1 as a, 2 as b, 3 as c"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("a"); + expect(result.stdout).toContain("b"); + expect(result.stdout).toContain("c"); + expect(result.stdout).toContain("1"); + expect(result.stdout).toContain("2"); + expect(result.stdout).toContain("3"); + }); + }); + + describe("multiple statements", () => { + it("should execute multiple SELECT statements", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -c "SELECT 1 as first; SELECT 2 as second"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("first"); + expect(result.stdout).toContain("second"); + expect(result.stdout).toContain("1"); + expect(result.stdout).toContain("2"); + }); + }); + + describe("stdin input", () => { + it("should read SQL from stdin", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'echo "SELECT 42 as answer" | psql -h localhost', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("42"); + }); + }); + + describe("file input", () => { + it("should execute SQL from file", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + await env.writeFile("/tmp/test.sql", "SELECT 'from file' as source"); + + const result = await env.exec("psql -h localhost -f /tmp/test.sql"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("from file"); + }); + + it("should error when file does not exist", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + "psql -h localhost -f /tmp/nonexistent.sql", + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("nonexistent.sql"); + }); + }); + + describe("connection parameters", () => { + it("should support port parameter", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + `psql -h localhost -p ${TEST_PG_CONFIG.port} -c "SELECT 1"`, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }); + + it("should support database parameter", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + `psql -h localhost -d ${TEST_PG_CONFIG.database} -c "SELECT 1"`, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }); + + it("should support username parameter with credential injection", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + // Even if we specify wrong username, credential injection should override + const result = await env.exec( + 'psql -h localhost -U wronguser -c "SELECT 1"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }); + }); + + describe("credential injection (Deno Sandbox pattern)", () => { + it("should work with user-provided credentials (string entry)", async () => { + if (!pgAvailable) return; + + const env = new Bash({ + network: { allowedPostgresHosts: ["localhost"] }, + }); + + // User must provide correct credentials + const result = await env.exec( + `psql -h localhost -U ${TEST_PG_CONFIG.username} -d ${TEST_PG_CONFIG.database} -c "SELECT 1"`, + ); + + // This will fail because password can't be provided via CLI (security feature) + // But it proves the string entry allows the connection attempt + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("password authentication failed"); + }); + + it("should inject credentials with object entry", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + + // User provides wrong/no credentials, but they get injected + const result = await env.exec('psql -h localhost -c "SELECT 1"'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("1"); + }); + }); +}); diff --git a/src/commands/psql/tests/errors.test.ts b/src/commands/psql/tests/errors.test.ts new file mode 100644 index 00000000..b6efe935 --- /dev/null +++ b/src/commands/psql/tests/errors.test.ts @@ -0,0 +1,144 @@ +/** + * Tests for psql error handling + * + * Tests require PostgreSQL running on localhost:5432 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { Bash } from "../../../Bash.js"; +import { + getTestNetworkConfigWithCreds, + isPostgresAvailable, +} from "./test-helpers.js"; + +describe("psql error handling", () => { + let pgAvailable = false; + + beforeAll(async () => { + pgAvailable = await isPostgresAvailable(); + if (!pgAvailable) { + console.warn( + "\n⚠️ PostgreSQL not available - skipping psql error tests\n", + ); + } + }); + + describe("SQL errors", () => { + it("should handle syntax errors", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -c "SELEC 1"', // Missing T in SELECT + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("ERROR"); + }); + + it("should handle missing table errors", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -c "SELECT * FROM nonexistent_table_xyz"', + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("ERROR"); + expect(result.stderr).toContain("does not exist"); + }); + }); + + describe("connection errors", () => { + it("should error when connecting to non-allowed host", async () => { + if (!pgAvailable) return; + + const env = new Bash({ + network: { + allowedPostgresHosts: ["not-localhost"], + }, + }); + + const result = await env.exec('psql -h localhost -c "SELECT 1"'); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("PostgreSQL access denied"); + expect(result.stderr).toContain("Host not in allow-list"); + }); + + it("should error with invalid credentials (string entry)", async () => { + if (!pgAvailable) return; + + const env = new Bash({ + network: { + allowedPostgresHosts: ["localhost"], + }, + }); + + // String entry allows connection but no password provided + const result = await env.exec( + 'psql -h localhost -U wronguser -d testdb -c "SELECT 1"', + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("password authentication failed"); + }); + }); + + describe("statement execution errors", () => { + it("should stop on first error in multi-statement query", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -c "SELECT 1; SELEC 2; SELECT 3"', + ); + + expect(result.exitCode).toBe(1); + expect(result.stdout).toContain("1"); // First query executed + expect(result.stdout).not.toContain("3"); // Third query not reached + expect(result.stderr).toContain("ERROR"); + }); + }); + + describe("command-line errors", () => { + it("should error on invalid port number", async () => { + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -p invalid -c "SELECT 1"', + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("invalid port"); + }); + + it("should error on port out of range", async () => { + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -p 999999 -c "SELECT 1"', + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("invalid port"); + }); + + it("should error on unknown option", async () => { + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost --unknown-option -c "SELECT 1"', + ); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("invalid option"); + }); + + it("should error when option missing argument", async () => { + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec("psql -h localhost -c"); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("requires an argument"); + }); + }); +}); diff --git a/src/commands/psql/tests/formats.test.ts b/src/commands/psql/tests/formats.test.ts new file mode 100644 index 00000000..72f3de28 --- /dev/null +++ b/src/commands/psql/tests/formats.test.ts @@ -0,0 +1,224 @@ +/** + * Tests for psql output formats + * + * Tests require PostgreSQL running on localhost:5432 + */ + +import { beforeAll, describe, expect, it } from "vitest"; +import { Bash } from "../../../Bash.js"; +import { + getTestNetworkConfigWithCreds, + isPostgresAvailable, +} from "./test-helpers.js"; + +describe("psql output formats", () => { + let pgAvailable = false; + + beforeAll(async () => { + pgAvailable = await isPostgresAvailable(); + if (!pgAvailable) { + console.warn( + "\n⚠️ PostgreSQL not available - skipping psql format tests\n", + ); + } + }); + + describe("aligned format (default)", () => { + it("should output aligned table", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + "psql -h localhost -c \"SELECT 1 as num, 'hello' as text\"", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("num"); + expect(result.stdout).toContain("text"); + expect(result.stdout).toContain("---"); // Separator line + expect(result.stdout).toContain("(1 row)"); + }); + + it("should align columns properly", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -c "SELECT 1 as a, 2 as b"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(" | "); // Column separator + }); + }); + + describe("unaligned format (-A)", () => { + it("should output unaligned with pipe separator", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -A -c "SELECT 1 as a, 2 as b"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("a|b"); + expect(result.stdout).toContain("1|2"); + }); + + it("should support custom field separator (-F)", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -A -F "," -c "SELECT 1 as a, 2 as b"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("a,b"); + expect(result.stdout).toContain("1,2"); + }); + }); + + describe("CSV format (--csv)", () => { + it("should output valid CSV", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + "psql -h localhost --csv -c \"SELECT 1 as num, 'hello' as text\"", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("num,text"); + expect(result.stdout).toContain("1,hello"); + }); + + it("should escape CSV special characters", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + "psql -h localhost --csv -c \"SELECT 'a,b' as val1, 'c\\\"d' as val2\"", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('"a,b"'); // Quoted because of comma + expect(result.stdout).toContain('"c""d"'); // Double quote escaped as two double quotes + }); + }); + + describe("JSON format (--json)", () => { + it("should output valid JSON array", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + "psql -h localhost --json -c \"SELECT 1 as id, 'test' as name\"", + ); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json).toEqual([{ id: 1, name: "test" }]); + }); + + it("should handle multiple rows in JSON", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost --json -c "SELECT n FROM generate_series(1,3) as n"', + ); + + expect(result.exitCode).toBe(0); + const json = JSON.parse(result.stdout); + expect(json).toEqual([{ n: 1 }, { n: 2 }, { n: 3 }]); + }); + }); + + describe("HTML format (-H)", () => { + it("should output HTML table", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + 'psql -h localhost -H -c "SELECT 1 as num"', + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(""); + expect(result.stdout).toContain(""); + expect(result.stdout).toContain(""); + expect(result.stdout).toContain("
num1
"); + }); + + it("should escape HTML entities", async () => { + if (!pgAvailable) return; + + const env = new Bash({ network: getTestNetworkConfigWithCreds() }); + const result = await env.exec( + "psql -h localhost -H -c \"SELECT '