From fc833c9ce0b40920fb969424bc27c5ba6c82d031 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sat, 13 Jun 2026 23:41:03 +0200 Subject: [PATCH 1/4] fix(connectors): actionable connection-test + write-preview errors (#1043 items 1,5) - Route the boolean-false connection check and the [id]/test catch path through classifyConnectionError; replace the dead-end 'Connection check returned false' with an actionable message + code. - Add mapPreviewError: map blocked-write driver errors (pg wrapped-write syntax error, Neo4j read-access-mode, read-only transaction) to a clear 'Writes aren't allowed from widget queries' message in the preview panel. Co-Authored-By: Claude Opus 4.8 --- .../[id]/test/__tests__/route.test.ts | 44 +++++++++++++ .../app/api/connections/[id]/test/route.ts | 24 +++++-- .../test-inline/__tests__/route.test.ts | 5 +- .../app/api/connections/test-inline/route.ts | 18 +++-- .../widget-editor/widget-preview-panel.tsx | 23 +++++-- .../connector/connection-error-classifier.ts | 8 +++ .../lib/query/__tests__/preview-error.test.ts | 47 ++++++++++++++ app/src/lib/query/preview-error.ts | 65 +++++++++++++++++++ 8 files changed, 216 insertions(+), 18 deletions(-) create mode 100644 app/src/lib/query/__tests__/preview-error.test.ts create mode 100644 app/src/lib/query/preview-error.ts diff --git a/app/src/app/api/connections/[id]/test/__tests__/route.test.ts b/app/src/app/api/connections/[id]/test/__tests__/route.test.ts index c2e48948..3b75bc0e 100644 --- a/app/src/app/api/connections/[id]/test/__tests__/route.test.ts +++ b/app/src/app/api/connections/[id]/test/__tests__/route.test.ts @@ -134,6 +134,50 @@ describe("POST /api/connections/[id]/test", () => { const body = await res.json(); expect(body.data.success).toBe(false); expect(body.data.error).toBe("Connection refused"); + // The error is classified so the UI can show a targeted hint (#1043). + expect(body.data.code).toBe("network"); + }); + + it("classifies an auth failure thrown by testConnection (#1043)", async () => { + mockRequireSession.mockResolvedValue(SESSION); + mockDb.select.mockReturnValue( + makeSelectChain([ + { id: "c1", userId: "user-1", type: "neo4j", configEncrypted: "enc" }, + ]), + ); + mockDecryptJson.mockReturnValue({ uri: "bolt://h", username: "u" }); + mockTestConnection.mockRejectedValue( + new Error("The client is unauthorized due to authentication failure."), + ); + + const res = await POST({} as Request, makeParams("c1")); + const body = await res.json(); + expect(body.data.success).toBe(false); + expect(body.data.code).toBe("auth_failed"); + }); + + it("returns an actionable message + code when testConnection returns false (#1043)", async () => { + mockRequireSession.mockResolvedValue(SESSION); + mockDb.select.mockReturnValue( + makeSelectChain([ + { + id: "c1", + userId: "user-1", + type: "postgresql", + configEncrypted: "enc", + }, + ]), + ); + mockDecryptJson.mockReturnValue({ uri: "pg://h", username: "u" }); + mockTestConnection.mockResolvedValue(false); + + const res = await POST({} as Request, makeParams("c1")); + const body = await res.json(); + expect(body.data.success).toBe(false); + expect(body.data.code).toBe("unknown"); + // No longer the dead-end "Connection check returned false". + expect(body.data.error).not.toMatch(/check returned false/i); + expect(body.data.error).toMatch(/verify the host, port, credentials/i); }); // Lost/rotated ENCRYPTION_KEY is a documented operational failure mode — diff --git a/app/src/app/api/connections/[id]/test/route.ts b/app/src/app/api/connections/[id]/test/route.ts index 7847c67f..62bb6c16 100644 --- a/app/src/app/api/connections/[id]/test/route.ts +++ b/app/src/app/api/connections/[id]/test/route.ts @@ -11,6 +11,10 @@ import { handleRouteError, sanitizeErrorMessage, } from "@/lib/api/api-utils"; +import { + classifyConnectionError, + CONNECTION_CHECK_FALSE_MESSAGE, +} from "@/lib/connector/connection-error-classifier"; export async function POST( _request: Request, @@ -54,20 +58,30 @@ export async function POST( connection.type as DbType, credentials, ); - return apiSuccess({ - success, - ...(!success ? { error: "Connection check returned false" } : {}), - }); + if (!success) { + // The driver returned false without throwing — no message to + // classify, so give an actionable fallback instead of the old + // non-actionable "Connection check returned false" (#1043). + return apiSuccess({ + success: false, + code: "unknown", + error: CONNECTION_CHECK_FALSE_MESSAGE, + }); + } + return apiSuccess({ success: true }); } catch (testError) { const rawMessage = testError instanceof Error ? testError.message : "Connection test failed"; + // Classify BEFORE sanitization so the UI can show a targeted hint + // (mirrors the test-inline route) (#1043). + const code = classifyConnectionError(rawMessage); const message = sanitizeErrorMessage( rawMessage, "Connection test failed", ); - return apiSuccess({ success: false, error: message }); + return apiSuccess({ success: false, code, error: message }); } } catch (error) { return handleRouteError(error, "Connection test failed"); diff --git a/app/src/app/api/connections/test-inline/__tests__/route.test.ts b/app/src/app/api/connections/test-inline/__tests__/route.test.ts index d6cf5d17..34c62f87 100644 --- a/app/src/app/api/connections/test-inline/__tests__/route.test.ts +++ b/app/src/app/api/connections/test-inline/__tests__/route.test.ts @@ -184,7 +184,7 @@ describe("POST /api/connections/test-inline", () => { ); }); - it("returns success:false when testConnection returns false", async () => { + it("returns success:false with actionable message + code when testConnection returns false (#1043)", async () => { mockRequireSession.mockResolvedValue(SESSION); mockTestConnection.mockResolvedValue(false); const res = await POST( @@ -196,6 +196,9 @@ describe("POST /api/connections/test-inline", () => { expect(res.status).toBe(200); const body = await res.json(); expect(body.data.success).toBe(false); + expect(body.data.code).toBe("unknown"); + expect(body.data.error).not.toMatch(/check returned false/i); + expect(body.data.error).toMatch(/verify the host, port, credentials/i); }); it("returns success:false with fallback message for non-Error throws", async () => { diff --git a/app/src/app/api/connections/test-inline/route.ts b/app/src/app/api/connections/test-inline/route.ts index 3c995abe..94a4b89a 100644 --- a/app/src/app/api/connections/test-inline/route.ts +++ b/app/src/app/api/connections/test-inline/route.ts @@ -9,7 +9,10 @@ import { validateBody, sanitizeErrorMessage, } from "@/lib/api/api-utils"; -import { classifyConnectionError } from "@/lib/connector/connection-error-classifier"; +import { + classifyConnectionError, + CONNECTION_CHECK_FALSE_MESSAGE, +} from "@/lib/connector/connection-error-classifier"; export async function POST(request: Request) { try { @@ -38,10 +41,15 @@ export async function POST(request: Request) { statementTimeout: config.statementTimeout, sslRejectUnauthorized: config.sslRejectUnauthorized, }); - return apiSuccess({ - success, - ...(!success ? { error: "Connection check returned false" } : {}), - }); + if (!success) { + // No thrown error to classify — give an actionable fallback (#1043). + return apiSuccess({ + success: false, + code: "unknown", + error: CONNECTION_CHECK_FALSE_MESSAGE, + }); + } + return apiSuccess({ success: true }); } catch (testError) { const rawMessage = testError instanceof Error diff --git a/app/src/components/widget-editor/widget-preview-panel.tsx b/app/src/components/widget-editor/widget-preview-panel.tsx index 89f931df..671fe541 100644 --- a/app/src/components/widget-editor/widget-preview-panel.tsx +++ b/app/src/components/widget-editor/widget-preview-panel.tsx @@ -4,6 +4,7 @@ import type { RefObject } from "react"; import { AlertCircle, Play } from "lucide-react"; import { CardContainer } from "../card-container"; import { ParameterPreview } from "./parameter-preview"; +import { mapPreviewError } from "@/lib/query/preview-error"; import type { StylingConfig } from "@/lib/db/schema"; import type { Transform } from "@/lib/query/data-transforms"; import type { ParamUIType, DateSubType } from "@/stores/widget-editor-store"; @@ -164,13 +165,21 @@ function renderChart(props: { )} {previewQuery.isError && !previewQuery.data && !initialPreviewData ? ( -
- -

Query failed

-

- {previewQuery.error?.message} -

-
+ (() => { + // Map blocked-write driver errors to a clear message (#1043). + const writeMsg = mapPreviewError(previewQuery.error?.message); + return ( +
+ +

+ {writeMsg ? "Writes not allowed" : "Query failed"} +

+

+ {writeMsg ?? previewQuery.error?.message} +

+
+ ); + })() ) : previewQuery.data || initialPreviewData ? ( { + it("maps a PostgreSQL wrapped-write syntax error to the write message", () => { + // DELETE wrapped as SELECT * FROM (DELETE …) AS __preview + expect(mapPreviewError('syntax error at or near "DELETE"')).toBe( + PREVIEW_WRITE_NOT_ALLOWED_MESSAGE, + ); + expect(mapPreviewError('syntax error at or near "UPDATE"')).toBe( + PREVIEW_WRITE_NOT_ALLOWED_MESSAGE, + ); + expect(mapPreviewError('syntax error at or near "INSERT"')).toBe( + PREVIEW_WRITE_NOT_ALLOWED_MESSAGE, + ); + }); + + it("maps a Neo4j read-access-mode write error to the write message", () => { + expect( + mapPreviewError( + "Neo.ClientError.Request.Invalid: Writing in read access mode not allowed.", + ), + ).toBe(PREVIEW_WRITE_NOT_ALLOWED_MESSAGE); + }); + + it("maps a PostgreSQL read-only transaction violation to the write message", () => { + expect( + mapPreviewError("cannot execute DELETE in a read-only transaction"), + ).toBe(PREVIEW_WRITE_NOT_ALLOWED_MESSAGE); + }); + + it("returns null for a genuine (non-write) syntax error so the raw message shows", () => { + expect(mapPreviewError('syntax error at or near "FROMM"')).toBeNull(); + }); + + it("returns null for an unrelated error", () => { + expect(mapPreviewError('column "foo" does not exist')).toBeNull(); + }); + + it("returns null for empty/undefined input", () => { + expect(mapPreviewError(undefined)).toBeNull(); + expect(mapPreviewError("")).toBeNull(); + }); +}); diff --git a/app/src/lib/query/preview-error.ts b/app/src/lib/query/preview-error.ts new file mode 100644 index 00000000..b15dace0 --- /dev/null +++ b/app/src/lib/query/preview-error.ts @@ -0,0 +1,65 @@ +/** + * Map a raw preview-query error into a clear, user-facing message (#1043). + * + * Widget previews run through the read-only query route, and non-Form widget + * queries are wrapped with a preview LIMIT. A write statement therefore fails + * in one of two confusing ways: + * + * - PostgreSQL: `DELETE …` wrapped as `SELECT * FROM (DELETE …) AS __preview` + * reports `syntax error at or near "DELETE"` — driver-speak that hides the + * real cause. + * - Neo4j: `CREATE …` runs in read access mode and reports + * `Writing in read access mode not allowed`. + * + * Both really mean the same thing: you can't write from a widget query. Detect + * those shapes and return a single actionable message; otherwise return null so + * the caller shows the original error. + */ + +const WRITE_KEYWORDS = [ + "insert", + "update", + "delete", + "merge", + "create", + "drop", + "alter", + "truncate", + "set ", + "remove ", +]; + +const READ_ONLY_PHRASES = [ + "writing in read access mode not allowed", + "write operations are not allowed", + "read-only transaction", + "read only transaction", + "cannot execute", +]; + +/** True when a wrapped write produced a "syntax error at or near ". */ +function isWrappedWriteSyntaxError(lower: string): boolean { + const m = /syntax error at or near "([a-z]+)"/.exec(lower); + if (!m) return false; + return WRITE_KEYWORDS.some((k) => k.trim() === m[1]); +} + +export const PREVIEW_WRITE_NOT_ALLOWED_MESSAGE = + "Writes aren't allowed from widget queries. Widget previews run read-only — use a Form widget to write to the database."; + +/** + * Returns the friendly write-not-allowed message when the raw error looks like + * a blocked write attempt, otherwise null. + */ +export function mapPreviewError(rawMessage: string | undefined): string | null { + if (!rawMessage) return null; + const lower = rawMessage.toLowerCase(); + + if (READ_ONLY_PHRASES.some((p) => lower.includes(p))) { + return PREVIEW_WRITE_NOT_ALLOWED_MESSAGE; + } + if (isWrappedWriteSyntaxError(lower)) { + return PREVIEW_WRITE_NOT_ALLOWED_MESSAGE; + } + return null; +} From 96d2109b8ae04c0404c4c1c93cfaa71d4ebdff85 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sat, 13 Jun 2026 23:53:39 +0200 Subject: [PATCH 2/4] fix(connectors): connection dialog, preview & list polish (#1043 items 2,3,4,6,8,9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edit dialog gains a Name field; rename persists via the existing PATCH (name) and fires a 'Connection updated' toast. - Create form is noValidate and reports all missing required fields at once (extracted missingRequiredConnectionFields) instead of native one-at-a-time tooltips. - Client-side URI format validation (validateConnectionUri) blocks save of malformed URIs on both create and edit, with an inline error. - Preview panel shows a 'Preview shows up to 25 rows' hint so the silent LIMIT is visible. - Connections list renders a distinct Neo4j / PostgreSQL logo via a new optional ConnectionCard `icon` prop (app passes the asset; library stays asset-free). Item 7 (stale-query auto-run on connection switch) was already prevented by clearQueryState on connector-type change — no code change needed. Co-Authored-By: Claude Opus 4.8 --- app/e2e/connections.spec.ts | 57 +++++++++++++++++++ app/src/app/(dashboard)/connections/page.tsx | 56 ++++++++++++++++++ .../widget-editor/widget-preview-panel.tsx | 51 ++++++++++------- .../connection-form-validation.test.ts | 32 +++++++++++ .../__tests__/validate-connection-uri.test.ts | 44 ++++++++++++++ .../connector/connection-form-validation.ts | 24 ++++++++ .../lib/connector/validate-connection-uri.ts | 47 +++++++++++++++ .../__tests__/connection-card.test.tsx | 40 ++++++++++--- .../components/composed/connection-card.tsx | 10 +++- 9 files changed, 331 insertions(+), 30 deletions(-) create mode 100644 app/src/lib/connector/__tests__/connection-form-validation.test.ts create mode 100644 app/src/lib/connector/__tests__/validate-connection-uri.test.ts create mode 100644 app/src/lib/connector/connection-form-validation.ts create mode 100644 app/src/lib/connector/validate-connection-uri.ts diff --git a/app/e2e/connections.spec.ts b/app/e2e/connections.spec.ts index 15c5f980..b0db15d2 100644 --- a/app/e2e/connections.spec.ts +++ b/app/e2e/connections.spec.ts @@ -221,6 +221,63 @@ test.describe("Connections", () => { await expect(dialog).not.toBeVisible(); }); + test("should rename a connection from the edit dialog (#1043)", async ({ + page, + }) => { + const name = `Rename Me ${Date.now()}`; + const renamed = `Renamed ${Date.now()}`; + // Create a connection to rename. + await page.getByRole("button", { name: "Add Connection" }).click(); + let dialog = page.getByRole("dialog"); + await dialog.getByTestId("pick-neo4j").click(); + await dialog.locator("#conn-name").fill(name); + await dialog.locator("#conn-uri").fill(TEST_NEO4J_BOLT_URL); + await dialog.locator("#conn-username").fill("neo4j"); + await dialog.locator("#conn-password").fill("neoboard123"); + await dialog.getByRole("button", { name: "Create" }).click(); + await expect(page.getByText(name)).toBeVisible(); + + // Open its edit dialog and change the Name field. Scope to the smallest + // card div that holds both this connection's name and its actions button. + const card = page + .locator("div") + .filter({ has: page.getByText(name, { exact: true }) }) + .filter({ has: page.getByRole("button", { name: "Connection actions" }) }) + .last(); + await card.getByRole("button", { name: "Connection actions" }).click(); + await page.getByRole("menuitem", { name: /Edit/ }).click(); + dialog = page.getByRole("dialog"); + await expect(dialog.locator("#edit-name")).toHaveValue(name, { + timeout: 5000, + }); + await dialog.locator("#edit-name").fill(renamed); + await dialog.getByRole("button", { name: "Save" }).click(); + + // The new name appears and a confirmation toast fires (#1043). Use exact + // match to avoid the aria-live "Notification …" status span. + await expect( + page.getByText("Connection updated", { exact: true }), + ).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(renamed)).toBeVisible(); + }); + + test("blocks save of a malformed URI with an inline error (#1043)", async ({ + page, + }) => { + await page.getByRole("button", { name: "Add Connection" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByTestId("pick-neo4j").click(); + await dialog.locator("#conn-name").fill("Bad URI"); + await dialog.locator("#conn-uri").fill("not-a-uri"); + await dialog.locator("#conn-username").fill("neo4j"); + await dialog.locator("#conn-password").fill("pw"); + await dialog.getByRole("button", { name: "Create" }).click(); + + // Inline error shown; dialog stays open (no Error-badge connection saved). + await expect(dialog.getByText(/valid URI/i)).toBeVisible({ timeout: 5000 }); + await expect(dialog).toBeVisible(); + }); + test("should delete a connection with confirmation", async ({ page }) => { const name = `To Delete ${Date.now()}`; // Create one first diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx index abe67dcb..08f6ae42 100644 --- a/app/src/app/(dashboard)/connections/page.tsx +++ b/app/src/app/(dashboard)/connections/page.tsx @@ -35,6 +35,7 @@ import { PasswordInput, Alert, AlertDescription, + useToast, } from "@neoboard/components"; import type { ConnectionState } from "@neoboard/components"; import { @@ -42,6 +43,8 @@ import { CONNECTOR_LABELS, } from "@/lib/connector/connector-types"; import { hintForConnectionErrorCode } from "@/lib/connector/connection-error-classifier"; +import { validateConnectionUri } from "@/lib/connector/validate-connection-uri"; +import { missingRequiredConnectionFields } from "@/lib/connector/connection-form-validation"; import { parseOptionalInt, mapConfigToEditForm, @@ -69,6 +72,7 @@ const DEFAULT_FORM = { export default function ConnectionsPage() { const { data: session } = useSession(); + const { toast } = useToast(); const isAdmin = session?.user?.role === "admin"; const { data: connections, isLoading } = useConnections(); const createConnection = useCreateConnection(); @@ -244,6 +248,20 @@ export default function ConnectionsPage() { async function handleCreate(e: React.FormEvent) { e.preventDefault(); setCreateError(null); + // Report all missing required fields at once via a styled inline alert + // (form is noValidate) instead of one native browser tooltip at a time + // (#1043). + const missing = missingRequiredConnectionFields(form); + if (missing.length > 0) { + setCreateError(`Please fill in: ${missing.join(", ")}.`); + return; + } + // Validate URI format client-side before save (#1043). + const uriError = validateConnectionUri(form.uri, form.type); + if (uriError) { + setCreateError(uriError); + return; + } try { const newConn = await createConnection.mutateAsync({ name: form.name, @@ -394,12 +412,28 @@ export default function ConnectionsPage() { if (!editTarget) return; setEditError(null); + if (!editForm.name.trim()) { + setEditError("Name is required."); + return; + } + // Validate URI *format* before save when the user changed it (blank keeps + // the existing one). Catches malformed URIs client-side (#1043). + if (editForm.uri.trim()) { + const uriError = validateConnectionUri(editForm.uri, editTarget.type); + if (uriError) { + setEditError(uriError); + return; + } + } + try { await updateConnection.mutateAsync({ id: editTarget.id, + name: editForm.name.trim(), config: buildEditConfig(), }); setEditTarget(null); + toast({ title: "Connection updated" }); handleTest(editTarget.id); } catch (error) { setEditError( @@ -473,6 +507,7 @@ export default function ConnectionsPage() { ) : (
@@ -758,6 +793,7 @@ export default function ConnectionsPage() { @@ -774,6 +810,19 @@ export default function ConnectionsPage() { the existing one.

+
+ + ) => + setEditForm((f) => ({ ...f, name: e.target.value })) + } + required + placeholder="My database" + /> +
+
+ ) : ( + + ) + } status={status} statusText={testErrors[c.id]} onClick={ diff --git a/app/src/components/widget-editor/widget-preview-panel.tsx b/app/src/components/widget-editor/widget-preview-panel.tsx index 671fe541..1c368c79 100644 --- a/app/src/components/widget-editor/widget-preview-panel.tsx +++ b/app/src/components/widget-editor/widget-preview-panel.tsx @@ -181,26 +181,37 @@ function renderChart(props: { ); })() ) : previewQuery.data || initialPreviewData ? ( - +
+
+ +
+ {/* The preview query is capped server-side; surface the silent + LIMIT so authors don't mistake it for the full result (#1043). */} +

+ Preview shows up to 25 rows +

+
) : connectionId && query.trim() && !previewQuery.isError ? (
diff --git a/app/src/lib/connector/__tests__/connection-form-validation.test.ts b/app/src/lib/connector/__tests__/connection-form-validation.test.ts new file mode 100644 index 00000000..2f5eabc0 --- /dev/null +++ b/app/src/lib/connector/__tests__/connection-form-validation.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { missingRequiredConnectionFields } from "../connection-form-validation"; + +const FULL = { + name: "DB", + uri: "bolt://localhost:7687", + username: "neo4j", + password: "pw", +}; + +describe("missingRequiredConnectionFields (#1043)", () => { + it("returns no missing fields when all are filled", () => { + expect(missingRequiredConnectionFields(FULL)).toEqual([]); + }); + + it("lists every missing required field at once", () => { + expect( + missingRequiredConnectionFields({ + name: "", + uri: "", + username: "", + password: "", + }), + ).toEqual(["Name", "URI", "Username", "Password"]); + }); + + it("treats whitespace-only values as missing", () => { + expect(missingRequiredConnectionFields({ ...FULL, name: " " })).toEqual([ + "Name", + ]); + }); +}); diff --git a/app/src/lib/connector/__tests__/validate-connection-uri.test.ts b/app/src/lib/connector/__tests__/validate-connection-uri.test.ts new file mode 100644 index 00000000..1bb6d775 --- /dev/null +++ b/app/src/lib/connector/__tests__/validate-connection-uri.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { validateConnectionUri } from "../validate-connection-uri"; + +describe("validateConnectionUri (#1043)", () => { + it("rejects a non-URI string", () => { + expect(validateConnectionUri("not-a-uri", "neo4j")).toMatch(/valid URI/i); + expect(validateConnectionUri("not-a-uri", "postgresql")).toMatch( + /valid URI/i, + ); + }); + + it("rejects an empty URI", () => { + expect(validateConnectionUri(" ", "neo4j")).toMatch(/required/i); + }); + + it("rejects a wrong scheme for the connector type", () => { + expect( + validateConnectionUri("postgresql://localhost:5432", "neo4j"), + ).toMatch(/scheme/i); + expect( + validateConnectionUri("bolt://localhost:7687", "postgresql"), + ).toMatch(/scheme/i); + }); + + it("accepts valid Neo4j schemes", () => { + expect(validateConnectionUri("bolt://localhost:7687", "neo4j")).toBeNull(); + expect(validateConnectionUri("neo4j+s://host.example", "neo4j")).toBeNull(); + }); + + it("accepts valid PostgreSQL schemes", () => { + expect( + validateConnectionUri("postgresql://localhost:5432/db", "postgresql"), + ).toBeNull(); + expect( + validateConnectionUri("postgres://user@host:5432/db", "postgresql"), + ).toBeNull(); + }); + + it("rejects a URI with no host", () => { + expect(validateConnectionUri("bolt://", "neo4j")).toMatch( + /valid URI|host/i, + ); + }); +}); diff --git a/app/src/lib/connector/connection-form-validation.ts b/app/src/lib/connector/connection-form-validation.ts new file mode 100644 index 00000000..c3abffe2 --- /dev/null +++ b/app/src/lib/connector/connection-form-validation.ts @@ -0,0 +1,24 @@ +/** + * Required-field check for the connection create form (#1043). + * + * Returns the labels of all missing required fields so the dialog can show + * them together in one styled inline alert, instead of the native browser + * tooltip surfacing them one at a time. + */ +export interface RequiredConnectionFields { + name: string; + uri: string; + username: string; + password: string; +} + +export function missingRequiredConnectionFields( + fields: RequiredConnectionFields, +): string[] { + const missing: string[] = []; + if (!fields.name.trim()) missing.push("Name"); + if (!fields.uri.trim()) missing.push("URI"); + if (!fields.username.trim()) missing.push("Username"); + if (!fields.password.trim()) missing.push("Password"); + return missing; +} diff --git a/app/src/lib/connector/validate-connection-uri.ts b/app/src/lib/connector/validate-connection-uri.ts new file mode 100644 index 00000000..9bce8dfa --- /dev/null +++ b/app/src/lib/connector/validate-connection-uri.ts @@ -0,0 +1,47 @@ +import type { ConnectorType } from "@/lib/connector/connector-types"; + +/** + * Client-side URI *format* validation for the connection dialog (#1043). + * + * Saving an unreachable connection is intentional, but a malformed URI like + * `not-a-uri` should be caught before save instead of persisting as an + * Error-badge connection. This checks shape only (parseable, expected scheme, + * has a host) — it never attempts a network connection. + * + * Returns null when the URI is well-formed, otherwise an actionable message. + */ +const SCHEMES: Record = { + neo4j: ["bolt:", "bolt+s:", "bolt+ssc:", "neo4j:", "neo4j+s:", "neo4j+ssc:"], + postgresql: ["postgres:", "postgresql:"], +}; + +export function validateConnectionUri( + uri: string, + type: ConnectorType, +): string | null { + const trimmed = uri.trim(); + if (!trimmed) return "URI is required."; + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return type === "neo4j" + ? "Enter a valid URI, e.g. bolt://localhost:7687 or neo4j+s://host." + : "Enter a valid URI, e.g. postgresql://localhost:5432/db."; + } + + if (!parsed.hostname) { + return "The URI is missing a host."; + } + + const allowed = SCHEMES[type]; + if (allowed && !allowed.includes(parsed.protocol)) { + return `Unexpected scheme "${parsed.protocol.replace( + ":", + "", + )}". Use one of: ${allowed.map((s) => s.replace(":", "")).join(", ")}.`; + } + + return null; +} diff --git a/component/src/components/composed/__tests__/connection-card.test.tsx b/component/src/components/composed/__tests__/connection-card.test.tsx index e4010671..1b1a1b7e 100644 --- a/component/src/components/composed/__tests__/connection-card.test.tsx +++ b/component/src/components/composed/__tests__/connection-card.test.tsx @@ -35,6 +35,16 @@ describe("ConnectionCard", () => { expect(container.querySelector("svg")).toBeInTheDocument(); }); + it("renders a custom connector-type icon when provided (#1043)", () => { + render( + } + />, + ); + expect(screen.getByTestId("neo4j-logo")).toBeInTheDocument(); + }); + it("applies active border when active", () => { const { container } = render(); expect(container.firstChild).toHaveClass("border-primary"); @@ -42,7 +52,7 @@ describe("ConnectionCard", () => { it("applies cursor-pointer when onClick is provided", () => { const { container } = render( - + , ); expect(container.firstChild).toHaveClass("cursor-pointer"); }); @@ -56,30 +66,38 @@ describe("ConnectionCard", () => { it("renders dropdown menu when action handlers are provided", () => { render(); - expect(screen.getByRole("button", { name: /connection actions/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /connection actions/i }), + ).toBeInTheDocument(); }); it("does not render dropdown when no action handlers", () => { render(); - expect(screen.queryByRole("button", { name: /connection actions/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /connection actions/i }), + ).not.toBeInTheDocument(); }); it("applies custom className", () => { const { container } = render( - + , ); expect(container.firstChild).toHaveClass("custom-card"); }); it("renders actions dropdown when onDuplicate is provided", () => { render(); - expect(screen.getByRole("button", { name: /connection actions/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /connection actions/i }), + ).toBeInTheDocument(); }); it("renders Duplicate menu item when onDuplicate is provided", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: /connection actions/i })); + await user.click( + screen.getByRole("button", { name: /connection actions/i }), + ); expect(screen.getByText("Duplicate")).toBeInTheDocument(); }); @@ -87,7 +105,9 @@ describe("ConnectionCard", () => { const user = userEvent.setup(); const onDuplicate = vi.fn(); render(); - await user.click(screen.getByRole("button", { name: /connection actions/i })); + await user.click( + screen.getByRole("button", { name: /connection actions/i }), + ); await user.click(screen.getByText("Duplicate")); expect(onDuplicate).toHaveBeenCalledTimes(1); }); @@ -95,7 +115,9 @@ describe("ConnectionCard", () => { it("does not render Duplicate menu item when onDuplicate is not provided", async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole("button", { name: /connection actions/i })); + await user.click( + screen.getByRole("button", { name: /connection actions/i }), + ); expect(screen.getByText("Edit")).toBeInTheDocument(); expect(screen.queryByText("Duplicate")).not.toBeInTheDocument(); }); @@ -106,7 +128,7 @@ describe("ConnectionCard", () => { {...defaultProps} status="error" statusText="Connection refused" - /> + />, ); // Error badge is still rendered expect(screen.getByText("Error")).toBeInTheDocument(); diff --git a/component/src/components/composed/connection-card.tsx b/component/src/components/composed/connection-card.tsx index 67f788bb..6584a2ac 100644 --- a/component/src/components/composed/connection-card.tsx +++ b/component/src/components/composed/connection-card.tsx @@ -1,3 +1,4 @@ +import type { ReactNode } from "react"; import { Database, MoreVertical, @@ -24,6 +25,12 @@ import { cn } from "@/lib/utils"; export interface ConnectionCardProps { name: string; host: string; + /** + * Optional connector-type icon (e.g. a Neo4j or PostgreSQL logo). Falls back + * to a generic database glyph so every type is visually distinct (#1043). + * Passed in by the app to keep this library free of app-specific assets. + */ + icon?: ReactNode; database?: string; status: ConnectionState; statusText?: string; @@ -44,6 +51,7 @@ export interface ConnectionCardProps { function ConnectionCard({ name, host, + icon, database, status, statusText, @@ -70,7 +78,7 @@ function ConnectionCard({ >
- + {icon ?? }
From 33b07bc12babbb680a47d22c0646cd8edf7d42a5 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 14 Jun 2026 00:08:28 +0200 Subject: [PATCH 3/4] refactor(connectors): extract shared connection-test-result helper (#1043) SonarCloud flagged 4.3% duplication on new code: the [id]/test and test-inline routes had near-identical false/catch handling. Extract connectionCheckFalseResult + connectionTestErrorResult so both routes build the result identically, with a direct unit test. Co-Authored-By: Claude Opus 4.8 --- .../app/api/connections/[id]/test/route.ts | 41 +++++-------------- .../app/api/connections/test-inline/route.ts | 39 +++++------------- .../__tests__/connection-test-result.test.ts | 35 ++++++++++++++++ .../lib/connector/connection-test-result.ts | 38 +++++++++++++++++ 4 files changed, 93 insertions(+), 60 deletions(-) create mode 100644 app/src/lib/connector/__tests__/connection-test-result.test.ts create mode 100644 app/src/lib/connector/connection-test-result.ts diff --git a/app/src/app/api/connections/[id]/test/route.ts b/app/src/app/api/connections/[id]/test/route.ts index 62bb6c16..4a56938b 100644 --- a/app/src/app/api/connections/[id]/test/route.ts +++ b/app/src/app/api/connections/[id]/test/route.ts @@ -6,15 +6,11 @@ import { decryptJson } from "@/lib/crypto/crypto"; import { testConnection } from "@/lib/query/query-executor"; import type { ConnectionCredentials, DbType } from "@/lib/query/query-executor"; import { apiSuccess } from "@/lib/api/api-response"; +import { notFound, handleRouteError } from "@/lib/api/api-utils"; import { - notFound, - handleRouteError, - sanitizeErrorMessage, -} from "@/lib/api/api-utils"; -import { - classifyConnectionError, - CONNECTION_CHECK_FALSE_MESSAGE, -} from "@/lib/connector/connection-error-classifier"; + connectionCheckFalseResult, + connectionTestErrorResult, +} from "@/lib/connector/connection-test-result"; export async function POST( _request: Request, @@ -58,30 +54,13 @@ export async function POST( connection.type as DbType, credentials, ); - if (!success) { - // The driver returned false without throwing — no message to - // classify, so give an actionable fallback instead of the old - // non-actionable "Connection check returned false" (#1043). - return apiSuccess({ - success: false, - code: "unknown", - error: CONNECTION_CHECK_FALSE_MESSAGE, - }); - } - return apiSuccess({ success: true }); - } catch (testError) { - const rawMessage = - testError instanceof Error - ? testError.message - : "Connection test failed"; - // Classify BEFORE sanitization so the UI can show a targeted hint - // (mirrors the test-inline route) (#1043). - const code = classifyConnectionError(rawMessage); - const message = sanitizeErrorMessage( - rawMessage, - "Connection test failed", + // A false result (no throw) gets an actionable fallback; a thrown error + // is classified for a targeted hint. Both via the shared helper (#1043). + return apiSuccess( + success ? { success: true } : connectionCheckFalseResult(), ); - return apiSuccess({ success: false, code, error: message }); + } catch (testError) { + return apiSuccess(connectionTestErrorResult(testError)); } } catch (error) { return handleRouteError(error, "Connection test failed"); diff --git a/app/src/app/api/connections/test-inline/route.ts b/app/src/app/api/connections/test-inline/route.ts index 94a4b89a..351b7258 100644 --- a/app/src/app/api/connections/test-inline/route.ts +++ b/app/src/app/api/connections/test-inline/route.ts @@ -4,15 +4,11 @@ import { testConnection } from "@/lib/query/query-executor"; import type { DbType } from "@/lib/query/query-executor"; import { testInlineSchema } from "@/lib/shared/schemas"; import { apiSuccess } from "@/lib/api/api-response"; +import { handleRouteError, validateBody } from "@/lib/api/api-utils"; import { - handleRouteError, - validateBody, - sanitizeErrorMessage, -} from "@/lib/api/api-utils"; -import { - classifyConnectionError, - CONNECTION_CHECK_FALSE_MESSAGE, -} from "@/lib/connector/connection-error-classifier"; + connectionCheckFalseResult, + connectionTestErrorResult, +} from "@/lib/connector/connection-test-result"; export async function POST(request: Request) { try { @@ -41,28 +37,13 @@ export async function POST(request: Request) { statementTimeout: config.statementTimeout, sslRejectUnauthorized: config.sslRejectUnauthorized, }); - if (!success) { - // No thrown error to classify — give an actionable fallback (#1043). - return apiSuccess({ - success: false, - code: "unknown", - error: CONNECTION_CHECK_FALSE_MESSAGE, - }); - } - return apiSuccess({ success: true }); - } catch (testError) { - const rawMessage = - testError instanceof Error - ? testError.message - : "Connection test failed"; - // Classify BEFORE sanitization — the classifier needs the raw driver - // text to bucket reliably; the sanitizer strips that detail for display. - const code = classifyConnectionError(rawMessage); - const message = sanitizeErrorMessage( - rawMessage, - "Connection test failed", + // Shared helper builds the false/thrown result identically to the + // [id]/test route (#1043). + return apiSuccess( + success ? { success: true } : connectionCheckFalseResult(), ); - return apiSuccess({ success: false, code, error: message }); + } catch (testError) { + return apiSuccess(connectionTestErrorResult(testError)); } } catch (error) { return handleRouteError(error, "Connection test failed"); diff --git a/app/src/lib/connector/__tests__/connection-test-result.test.ts b/app/src/lib/connector/__tests__/connection-test-result.test.ts new file mode 100644 index 00000000..5e6f7a9a --- /dev/null +++ b/app/src/lib/connector/__tests__/connection-test-result.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { + connectionCheckFalseResult, + connectionTestErrorResult, +} from "../connection-test-result"; + +describe("connection-test-result (#1043)", () => { + it("builds an actionable false result with code unknown", () => { + const r = connectionCheckFalseResult(); + expect(r.success).toBe(false); + expect(r.code).toBe("unknown"); + expect(r.error).not.toMatch(/check returned false/i); + expect(r.error).toMatch(/verify the host, port, credentials/i); + }); + + it("classifies a thrown network error", () => { + const r = connectionTestErrorResult(new Error("connect ECONNREFUSED")); + expect(r.success).toBe(false); + expect(r.code).toBe("network"); + expect(r.error).toBeTruthy(); + }); + + it("classifies a thrown auth error", () => { + const r = connectionTestErrorResult( + new Error("password authentication failed for user"), + ); + expect(r.code).toBe("auth_failed"); + }); + + it("falls back for a non-Error throw", () => { + const r = connectionTestErrorResult("boom"); + expect(r.success).toBe(false); + expect(r.code).toBe("unknown"); + }); +}); diff --git a/app/src/lib/connector/connection-test-result.ts b/app/src/lib/connector/connection-test-result.ts new file mode 100644 index 00000000..2e5c4af5 --- /dev/null +++ b/app/src/lib/connector/connection-test-result.ts @@ -0,0 +1,38 @@ +import { sanitizeErrorMessage } from "@/lib/api/api-utils"; +import { + classifyConnectionError, + CONNECTION_CHECK_FALSE_MESSAGE, + type ConnectionErrorCode, +} from "@/lib/connector/connection-error-classifier"; + +/** + * Shared shape of a connection-test API result, so the `[id]/test` and + * `test-inline` routes build it identically (#1043) — they previously + * duplicated the false/catch handling. + */ +export interface ConnectionTestResult { + success: boolean; + code?: ConnectionErrorCode; + error?: string; +} + +/** A driver check that returned false without throwing — no message to classify. */ +export function connectionCheckFalseResult(): ConnectionTestResult { + return { + success: false, + code: "unknown", + error: CONNECTION_CHECK_FALSE_MESSAGE, + }; +} + +/** A thrown driver error — classify for a targeted hint, then sanitize for display. */ +export function connectionTestErrorResult( + thrown: unknown, +): ConnectionTestResult { + const rawMessage = + thrown instanceof Error ? thrown.message : "Connection test failed"; + // Classify BEFORE sanitization — the classifier needs the raw driver text. + const code = classifyConnectionError(rawMessage); + const error = sanitizeErrorMessage(rawMessage, "Connection test failed"); + return { success: false, code, error }; +} From 0d3bb7e902a13b366f15bf2618a9c1648f3bae22 Mon Sep 17 00:00:00 2001 From: alfredorubin96 Date: Sun, 14 Jun 2026 00:21:19 +0200 Subject: [PATCH 4/4] fix(connectors): trim edit credential fields before PATCH (#1043) Address CodeRabbit: with the form now noValidate, whitespace-only uri/username/password were truthy and could overwrite stored credentials with blanks. Gate buildEditConfig inclusion on the trimmed value so 'blank keeps existing' holds for whitespace-only input too. Co-Authored-By: Claude Opus 4.8 --- app/src/app/(dashboard)/connections/page.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/src/app/(dashboard)/connections/page.tsx b/app/src/app/(dashboard)/connections/page.tsx index 08f6ae42..13511c61 100644 --- a/app/src/app/(dashboard)/connections/page.tsx +++ b/app/src/app/(dashboard)/connections/page.tsx @@ -388,12 +388,16 @@ export default function ConnectionsPage() { function buildEditConfig() { // Only include credential fields when the user has explicitly filled them in. // Omitting them (undefined) tells the server to keep the existing stored values - // rather than overwriting them with blank strings. + // rather than overwriting them with blank strings. Gate on the *trimmed* + // value so whitespace-only input (possible now the form is noValidate) + // doesn't clobber stored credentials (#1043). + const uri = editForm.uri.trim(); + const username = editForm.username.trim(); return { - ...(editForm.uri ? { uri: editForm.uri } : {}), - ...(editForm.username ? { username: editForm.username } : {}), - ...(editForm.password ? { password: editForm.password } : {}), - database: editForm.database || undefined, + ...(uri ? { uri } : {}), + ...(username ? { username } : {}), + ...(editForm.password.trim() ? { password: editForm.password } : {}), + database: editForm.database.trim() || undefined, connectionTimeout: parseOptionalInt(editForm.connectionTimeout), queryTimeout: parseOptionalInt(editForm.queryTimeout), maxPoolSize: parseOptionalInt(editForm.maxPoolSize),