diff --git a/canvas/e2e/chat-desktop.spec.ts b/canvas/e2e/chat-desktop.spec.ts index 02f4c7404..a14fbe76a 100644 --- a/canvas/e2e/chat-desktop.spec.ts +++ b/canvas/e2e/chat-desktop.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import type { Page } from "@playwright/test"; import { startEchoRuntime } from "./fixtures/echo-runtime"; -import { seedWorkspace, startHeartbeat, cleanupWorkspace, runPsql } from "./fixtures/chat-seed"; +import { seedWorkspace, startHeartbeat, cleanupWorkspace, queryPsql } from "./fixtures/chat-seed"; /** Enter the Org-map view so the Canvas (React Flow graph) mounts. */ async function enterMapView(page: Page): Promise { @@ -71,11 +71,8 @@ test.describe("Desktop ChatTab", () => { // Regression for #2786: external echo-runtime workspaces must be push-mode // so the proxy dispatches synchronously to the echo URL. Poll-mode defaults // short-circuit to {status:'queued'} and the inline echo never renders. - const out = runPsql( - `SELECT delivery_mode FROM workspaces WHERE id = '${workspaceId}';`, - 10_000, - ); - expect(out).toContain("push"); + const rows = await queryPsql<{ delivery_mode: string }>("SELECT delivery_mode FROM workspaces WHERE id = $1", [workspaceId]); + expect(rows[0]?.delivery_mode).toBe("push"); }); test("send text message and receive echo response", async ({ page }) => { diff --git a/canvas/e2e/chat-separation.spec.ts b/canvas/e2e/chat-separation.spec.ts index 65b2b97ee..c1a41506b 100644 --- a/canvas/e2e/chat-separation.spec.ts +++ b/canvas/e2e/chat-separation.spec.ts @@ -320,11 +320,17 @@ test.describe("Data Flow — Initial Prompt in Chat", () => { const stopHeartbeat = startHeartbeat(ws.id, ws.authToken); // Pre-seed chat history so the My Chat panel shows deterministic content. - // Include double quotes to regression-test shell-safe JSON quoting in - // seedChatHistory (CR2 #11517). + // Include double quotes, backslashes, and an apostrophe to regression-test + // parameterized JSON handling in seedChatHistory (#2782). await seedChatHistory(workspaceId, [ - { role: "user", content: 'Hello from seed with "quotes"' }, - { role: "agent", content: 'Hello back from seed with "quotes"' }, + { + role: "user", + content: 'Hello from seed with "quotes" and \\backslash\\ plus apostrophe\'s', + }, + { + role: "agent", + content: 'Hello back from seed with "quotes" and \\backslash\\ plus apostrophe\'s', + }, ]); cleanup = async () => { @@ -344,8 +350,12 @@ test.describe("Data Flow — Initial Prompt in Chat", () => { test("seeded chat history appears in My Chat", async ({ page }) => { const panel = panelLocator(page); - await expect(panel.getByText('Hello from seed with "quotes"')).toBeVisible({ timeout: 5_000 }); - await expect(panel.getByText('Hello back from seed with "quotes"')).toBeVisible({ timeout: 5_000 }); + await expect( + panel.getByText('Hello from seed with "quotes" and \\backslash\\ plus apostrophe\'s'), + ).toBeVisible({ timeout: 5_000 }); + await expect( + panel.getByText('Hello back from seed with "quotes" and \\backslash\\ plus apostrophe\'s'), + ).toBeVisible({ timeout: 5_000 }); }); test("My Chat empty state is not shown when history exists", async ({ page }) => { diff --git a/canvas/e2e/fixtures/chat-seed.ts b/canvas/e2e/fixtures/chat-seed.ts index c8c0f3ffe..e3b17bc51 100644 --- a/canvas/e2e/fixtures/chat-seed.ts +++ b/canvas/e2e/fixtures/chat-seed.ts @@ -10,6 +10,7 @@ import { randomUUID } from "node:crypto"; import { execFileSync } from "node:child_process"; +import { Client } from "pg"; const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080"; @@ -31,22 +32,52 @@ export function parseDbUrl(): PgCredentials | null { return { user, pass, host, port, db }; } -export function runPsql(sql: string, timeoutMs = 30_000): string { +/** + * Create a connected Postgres client from E2E_DATABASE_URL. + * Caller must call client.end(). + */ +async function newPgClient(): Promise { const creds = parseDbUrl(); if (!creds) { throw new Error("E2E_DATABASE_URL must be set for DB seeding"); } - const { user, pass, host, port, db } = creds; - const out = execFileSync( - "psql", - ["-h", host, "-p", port, "-U", user, "-d", db, "-c", sql], - { - env: { ...process.env, PGPASSWORD: pass }, - stdio: "pipe", - timeout: timeoutMs, - }, - ); - return out.toString(); + const client = new Client({ + host: creds.host, + port: Number(creds.port), + user: creds.user, + password: creds.pass, + database: creds.db, + }); + await client.connect(); + return client; +} + +/** + * Run a single SQL statement via node-postgres with bound parameters. + * Returns the query result. + */ +async function pgExec( + sql: string, + values: unknown[] = [], +): Promise { + const client = await newPgClient(); + try { + return await client.query(sql, values); + } finally { + await client.end(); + } +} + +/** + * Execute a read-only SQL query and return typed rows. + * Useful for test assertions that need to inspect seeded DB state. + */ +export async function queryPsql( + sql: string, + values: unknown[] = [], +): Promise { + const result = await pgExec(sql, values); + return (result.rows ?? []) as T[]; } export interface SeededWorkspace { @@ -98,7 +129,7 @@ export async function seedWorkspace(echoURL: string): Promise { // 2. Direct DB update: mark online + point url at echo runtime. // The platform blocks loopback URLs at the API layer (SSRF guard), - // so we bypass via psql for local E2E. + // so we bypass via direct Postgres query for local E2E. if (!process.env.E2E_DATABASE_URL) { throw new Error("E2E_DATABASE_URL must be set for DB seeding"); } @@ -111,8 +142,14 @@ export async function seedWorkspace(echoURL: string): Promise { ], ).join(""); - runPsql( - `UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}', delivery_mode = 'push' WHERE id = '${ws.id}'`, + await pgExec( + `UPDATE workspaces + SET status = 'online', + url = $1, + platform_inbound_secret = $2, + delivery_mode = 'push' + WHERE id = $3`, + [echoURL, inboundSecret, ws.id], ); cacheWorkspaceURL(ws.id, echoURL); @@ -181,9 +218,10 @@ export function startHeartbeat( * /chat-history hydrator picks them up. Each message becomes its own row * so arbitrary user/agent sequences can be seeded. * - * The JSON payloads are passed through psql as dollar-quoted literals so - * message text containing quotes, backslashes, or other special characters - * is preserved exactly and cannot break the SQL string escaping. + * The JSON payloads are bound as node-postgres parameters ($3/$4) so the + * message text is never interpolated into the SQL string or the shell + * command line. This survives quotes, backslashes, apostrophes, and any + * other special characters. */ export async function seedChatHistory( workspaceId: string, @@ -196,8 +234,10 @@ export async function seedChatHistory( throw new Error("E2E_DATABASE_URL must be set for chat-history seeding"); } - const rows = messages - .map((msg, i) => { + const client = await newPgClient(); + try { + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; const offsetSec = messages.length - i; const requestBody = msg.role === "user" @@ -218,32 +258,38 @@ export async function seedChatHistory( }) : "{}"; - // Use a per-row random dollar-quoting tag so the message content - // cannot accidentally close the literal. - const tag = `J${randomUUID().replace(/[^A-Za-z0-9]/g, "")}`; - const reqLit = `$${tag}$${requestBody}$${tag}$`; - const respLit = `$${tag}$${responseBody}$${tag}$`; - - return `('${randomUUID()}', '${workspaceId}', 'a2a_receive', NULL, NULL, 'message/send', NULL, ${reqLit}::jsonb, ${respLit}::jsonb, 0, 'ok', NOW() - INTERVAL '${offsetSec} seconds')`; - }) - .join(","); - - const sql = `INSERT INTO activity_logs (id, workspace_id, activity_type, source_id, target_id, method, summary, request_body, response_body, duration_ms, status, created_at) VALUES ${rows};`; - - runPsql(sql, 10_000); + // Bind the JSON payloads as parameters ($3/$4) so the content is never + // interpolated into the SQL string or the shell command line. This + // survives arbitrary quotes, backslashes, and apostrophes. + await client.query( + `INSERT INTO activity_logs ( + id, workspace_id, activity_type, source_id, target_id, + method, summary, request_body, response_body, duration_ms, + status, created_at + ) VALUES ( + $1, $2, 'a2a_receive', NULL, NULL, + 'message/send', NULL, $3::jsonb, $4::jsonb, 0, + 'ok', NOW() - ($5 * INTERVAL '1 second') + )`, + [randomUUID(), workspaceId, requestBody, responseBody, offsetSec], + ); + } + } finally { + await client.end(); + } } /** * Delete a seeded workspace row directly from the DB. - * Uses psql (same credentials as seedWorkspace) so we bypass any - * workspace-server side-effects (container stop, cascade cleanup, etc.) + * Uses direct Postgres query (same credentials as seedWorkspace) so we bypass + * any workspace-server side-effects (container stop, cascade cleanup, etc.) * that can race or 500 on external workspaces. */ export async function cleanupWorkspace(workspaceId: string): Promise { if (!process.env.E2E_DATABASE_URL) return; try { - runPsql(`DELETE FROM workspaces WHERE id = '${workspaceId}'`); + await pgExec("DELETE FROM workspaces WHERE id = $1", [workspaceId]); } catch { // Best-effort cleanup; don't fail the test suite if the row is already gone. } diff --git a/canvas/package-lock.json b/canvas/package-lock.json index 661728c6d..e4b424399 100644 --- a/canvas/package-lock.json +++ b/canvas/package-lock.json @@ -32,6 +32,7 @@ "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.1", @@ -39,6 +40,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^15.5.15", "jsdom": "^29.1.1", + "pg": "^8.21.0", "postcss": "^8.5.13", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", @@ -2852,6 +2854,17 @@ "undici-types": "~7.19.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8436,6 +8449,95 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dev": true, + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8539,6 +8641,45 @@ "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -9310,6 +9451,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10522,6 +10672,15 @@ "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, "node_modules/xterm": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz", diff --git a/canvas/package.json b/canvas/package.json index ffadf4f15..28b4482a7 100644 --- a/canvas/package.json +++ b/canvas/package.json @@ -35,6 +35,7 @@ "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.1.0", "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.1", @@ -42,6 +43,7 @@ "eslint": "^9.39.4", "eslint-config-next": "^15.5.15", "jsdom": "^29.1.1", + "pg": "^8.21.0", "postcss": "^8.5.13", "tailwindcss": "^4.0.0", "typescript": "^5.7.0",