This is an automated email from the ASF dual-hosted git repository.

mtaha pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/age.git


The following commit(s) were added to refs/heads/master by this push:
     new 77a16ece Fix security vulnerabilities in Node.js driver (#2329)
77a16ece is described below

commit 77a16ece0bbd8f137ad40b4e203c9622c249352d
Author: John Gemignani <[email protected]>
AuthorDate: Fri Feb 13 09:31:35 2026 -0800

    Fix security vulnerabilities in Node.js driver (#2329)
    
    Fix security vulnerabilities in Node.js driver and harden input
    validation and query construction.
    
    Note: This PR was created with AI tools and a human.
    
    - Add input validation for graph names, label names, and column names
      to prevent SQL injection via string interpolation. Graph name rules
      are based on Apache AGE's naming conventions and Neo4j/openCypher
      compatibility (hyphens and dots permitted, min 3 chars, max 63 chars).
      Label names follow AGE's stricter rules (no hyphens or dots).
    - Add design documentation noting intentional ASCII-only restriction
      in driver-side regex validation as a security hardening measure
      (homoglyph/encoding attack surface reduction). AGE's server-side
      validation (name_validation.h) uses full Unicode ID_Start/ID_Continue
      and remains the authoritative check for Unicode names.
    - Add safe query helpers: queryCypher(), createGraph(), dropGraph()
      with graph name validation and dollar-quoting for Cypher strings
    - Add runtime typeof check on dropGraph cascade parameter to prevent
      injection from plain JavaScript consumers (TypeScript types are
      erased at runtime)
    - Use BigInt for integer values exceeding Number.MAX_SAFE_INTEGER to
      prevent silent precision loss with 64-bit AGE graph IDs
    - Make CREATE EXTENSION opt-in via SetAGETypesOptions.createExtension
      instead of running DDL automatically without user consent
    - Wrap LOAD/search_path setup in try/catch with actionable error
      message mentioning CREATE EXTENSION and { createExtension: true }
    - Improve agtype-not-found error message with installation guidance
    - Tighten pg dependency from >=6.0.0 to >=8.0.0
    - Add comprehensive test suites covering validation, SQL injection
      prevention, cascade type safety, hyphenated graph name integration,
      BigInt parsing, and setAGETypes error handling
    - Add design note in tests documenting why createExtension: false is
      the correct default (CI image has AGE pre-installed, auto-creating
      extensions requires SUPERUSER and conflates concerns)
    
    modified:   drivers/nodejs/package.json
    modified:   drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
    modified:   drivers/nodejs/src/index.ts
    modified:   drivers/nodejs/test/Agtype.test.ts
    modified:   drivers/nodejs/test/index.test.ts
---
 drivers/nodejs/package.json                       |   2 +-
 drivers/nodejs/src/antlr4/CustomAgTypeListener.ts |   6 +-
 drivers/nodejs/src/index.ts                       | 301 +++++++++++++++++++++-
 drivers/nodejs/test/Agtype.test.ts                |  27 ++
 drivers/nodejs/test/index.test.ts                 | 285 +++++++++++++++-----
 5 files changed, 539 insertions(+), 82 deletions(-)

diff --git a/drivers/nodejs/package.json b/drivers/nodejs/package.json
index 6be11c78..15c2371f 100644
--- a/drivers/nodejs/package.json
+++ b/drivers/nodejs/package.json
@@ -30,7 +30,7 @@
   "author": "Alex Kwak <[email protected]>",
   "dependencies": {
     "antlr4ts": "^0.5.0-alpha.4",
-    "pg": ">=6.0.0"
+    "pg": ">=8.0.0"
   },
   "devDependencies": {
     "@types/jest": "^29.5.14",
diff --git a/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts 
b/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
index fceabee2..6089ee99 100644
--- a/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
+++ b/drivers/nodejs/src/antlr4/CustomAgTypeListener.ts
@@ -92,7 +92,11 @@ class CustomAgTypeListener implements AgtypeListener, 
ParseTreeListener {
   }
 
   exitIntegerValue (ctx: IntegerValueContext): void {
-    const value = parseInt(ctx.text)
+    // Use BigInt for values that exceed Number.MAX_SAFE_INTEGER to
+    // prevent silent precision loss with large AGE graph IDs (64-bit).
+    const text = ctx.text
+    const num = Number(text)
+    const value = Number.isSafeInteger(num) ? num : BigInt(text)
     if (!this.pushIfArray(value)) {
       this.lastValue = value
     }
diff --git a/drivers/nodejs/src/index.ts b/drivers/nodejs/src/index.ts
index 416cc3da..dad004a8 100644
--- a/drivers/nodejs/src/index.ts
+++ b/drivers/nodejs/src/index.ts
@@ -17,7 +17,7 @@
  * under the License.
  */
 
-import { Client } from 'pg'
+import { Client, QueryResult, QueryResultRow } from 'pg'
 import pgTypes from 'pg-types'
 import { CharStreams, CommonTokenStream } from 'antlr4ts'
 import { AgtypeLexer } from './antlr4/AgtypeLexer'
@@ -25,6 +25,122 @@ import { AgtypeParser } from './antlr4/AgtypeParser'
 import CustomAgTypeListener from './antlr4/CustomAgTypeListener'
 import { ParseTreeWalker } from 'antlr4ts/tree'
 
+/**
+ * Valid graph name pattern based on Apache AGE's naming conventions
+ * (Neo4j/openCypher compatible). Allows ASCII letters, digits,
+ * underscores, plus dots and hyphens in middle positions.
+ *
+ * DESIGN NOTE: AGE's server-side validation (name_validation.h) uses
+ * full Unicode ID_Start/ID_Continue character classes, accepting names
+ * like 'mydätabase' or 'mydঅtabase'. This driver intentionally restricts
+ * to ASCII-only as a security hardening measure — it reduces the attack
+ * surface for homoglyph and encoding-based injection vectors. Server-side
+ * validation remains the authoritative check for Unicode names.
+ *
+ * Start: ASCII letter or underscore
+ * Middle: ASCII letter, digit, underscore, dot, or hyphen
+ * End: ASCII letter, digit, or underscore
+ */
+const VALID_GRAPH_NAME = /^[A-Za-z_][A-Za-z0-9_.\-]*[A-Za-z0-9_]$/
+
+/**
+ * Valid label name pattern based on Apache AGE's naming rules.
+ * Labels follow stricter identifier rules than graph names — dots and
+ * hyphens are NOT permitted in label names.
+ *
+ * Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above).
+ */
+const VALID_LABEL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
+
+/**
+ * Valid SQL identifier pattern for column names and types in the
+ * query result AS clause. Standard PostgreSQL unquoted identifier rules.
+ *
+ * Note: ASCII-only restriction (see VALID_GRAPH_NAME design note above).
+ */
+const VALID_SQL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/
+
+/**
+ * Validates that a graph name conforms to Apache AGE's naming conventions
+ * and is safe for use in SQL queries.
+ *
+ * Graph names must:
+ * - Be at least 3 characters and at most 63 characters
+ * - Start with an ASCII letter or underscore
+ * - Contain only ASCII letters, digits, underscores, dots, and hyphens
+ * - End with an ASCII letter, digit, or underscore
+ *
+ * Note: This is intentionally stricter than AGE's server-side validation
+ * (which accepts Unicode letters). See VALID_GRAPH_NAME design note.
+ *
+ * @param graphName - The graph name to validate
+ * @throws Error if the graph name is invalid
+ */
+function validateGraphName (graphName: string): void {
+  if (!graphName || typeof graphName !== 'string') {
+    throw new Error('Graph name must be a non-empty string')
+  }
+  if (graphName.length < 3) {
+    throw new Error(
+      `Invalid graph name: '${graphName}'. Graph names must be at least 3 
characters.`
+    )
+  }
+  if (graphName.length > 63) {
+    throw new Error('Graph name must not exceed 63 characters (PostgreSQL name 
limit)')
+  }
+  if (!VALID_GRAPH_NAME.test(graphName)) {
+    throw new Error(
+      `Invalid graph name: '${graphName}'. Graph names must start with a 
letter ` +
+      'or underscore, may contain letters, digits, underscores, dots, and 
hyphens, ' +
+      'and must end with a letter, digit, or underscore.'
+    )
+  }
+}
+
+/**
+ * Validates that a label name conforms to Apache AGE's naming rules.
+ * Label names are stricter than graph names — only letters, digits,
+ * and underscores are permitted (no dots or hyphens).
+ *
+ * @param labelName - The label name to validate
+ * @throws Error if the label name is invalid
+ */
+function validateLabelName (labelName: string): void {
+  if (!labelName || typeof labelName !== 'string') {
+    throw new Error('Label name must be a non-empty string')
+  }
+  if (labelName.length > 63) {
+    throw new Error('Label name must not exceed 63 characters (PostgreSQL name 
limit)')
+  }
+  if (!VALID_LABEL_NAME.test(labelName)) {
+    throw new Error(
+      `Invalid label name: '${labelName}'. Label names must start with a 
letter ` +
+      'or underscore and contain only letters, digits, and underscores.'
+    )
+  }
+}
+
+/**
+ * Escapes a PostgreSQL dollar-quoted string literal by ensuring the
+ * cypher query does not contain the dollar-quote delimiter. If the
+ * default $$ delimiter conflicts, a unique tagged delimiter is used.
+ *
+ * @param cypher - The Cypher query string
+ * @returns An object with the delimiter and safely quoted string
+ */
+function cypherDollarQuote (cypher: string): { delimiter: string; quoted: 
string } {
+  if (!cypher.includes('$$')) {
+    return { delimiter: '$$', quoted: `$$${cypher}$$` }
+  }
+  // Generate a unique tag that doesn't appear in the cypher query
+  let tag = 'cypher'
+  let counter = 0
+  while (cypher.includes(`$${tag}$`)) {
+    tag = `cypher${counter++}`
+  }
+  return { delimiter: `$${tag}$`, quoted: `$${tag}$${cypher}$${tag}$` }
+}
+
 function AGTypeParse (input: string) {
   const chars = CharStreams.fromString(input)
   const lexer = new AgtypeLexer(chars)
@@ -36,21 +152,180 @@ function AGTypeParse (input: string) {
   return printer.getResult()
 }
 
-async function setAGETypes (client: Client, types: typeof pgTypes) {
-  await client.query(`
-        CREATE EXTENSION IF NOT EXISTS age;
-        LOAD 'age';
-        SET search_path = ag_catalog, "$user", public;
-    `)
+/**
+ * Options for setAGETypes configuration.
+ */
+interface SetAGETypesOptions {
+  /**
+   * If true, will attempt to CREATE EXTENSION IF NOT EXISTS age.
+   * Defaults to false. Set to true only if the connected user has
+   * sufficient privileges.
+   */
+  createExtension?: boolean
+}
+
+/**
+ * Configures the pg client to properly parse AGE agtype results.
+ *
+ * This function:
+ * 1. Loads the AGE extension into the session
+ * 2. Sets the search_path to include ag_catalog
+ * 3. Registers the agtype type parser
+ *
+ * @param client - A connected pg Client instance
+ * @param types - The pg types module for registering type parsers
+ * @param options - Optional configuration settings
+ * @throws Error if AGE extension is not installed or agtype is not found
+ */
+async function setAGETypes (client: Client, types: typeof pgTypes, options?: 
SetAGETypesOptions) {
+  const createExtension = options?.createExtension ?? false
+
+  if (createExtension) {
+    await client.query('CREATE EXTENSION IF NOT EXISTS age;')
+  }
+
+  try {
+    await client.query("LOAD 'age';")
+    await client.query('SET search_path = ag_catalog, "$user", public;')
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err)
+    throw new Error(
+      `Failed to load AGE extension: ${msg}. ` +
+      'Ensure the AGE extension is installed in the database. ' +
+      'You may need to run CREATE EXTENSION age; first, or pass ' +
+      '{ createExtension: true } to setAGETypes().'
+    )
+  }
 
-  const oidResults = await client.query(`
-        select typelem
-        from pg_type
-        where typname = '_agtype';`)
+  const oidResults = await client.query(
+    "SELECT typelem FROM pg_type WHERE typname = '_agtype';"
+  )
 
-  if (oidResults.rows.length < 1) { throw new Error() }
+  if (oidResults.rows.length < 1) {
+    throw new Error(
+      'AGE agtype type not found. Ensure the AGE extension is installed ' +
+      'and loaded in the current database. Run CREATE EXTENSION age; first, ' +
+      'or pass { createExtension: true } to setAGETypes().'
+    )
+  }
 
   types.setTypeParser(oidResults.rows[0].typelem, AGTypeParse)
 }
 
-export { setAGETypes, AGTypeParse }
+/**
+ * Column definition for Cypher query results.
+ */
+interface CypherColumn {
+  /** Column alias name */
+  name: string
+  /** Column type (defaults to 'agtype') */
+  type?: string
+}
+
+/**
+ * Executes a Cypher query safely against an AGE graph.
+ *
+ * This function validates the graph name to prevent SQL injection,
+ * properly quotes the Cypher query using dollar-quoting, and builds
+ * the required SQL wrapper.
+ *
+ * @param client - A connected pg Client instance (with AGE types set)
+ * @param graphName - The target graph name (must be a valid AGE graph name)
+ * @param cypher - The Cypher query string
+ * @param columns - Column definitions for the result set
+ * @returns The query result
+ * @throws Error if graphName is invalid or query fails
+ *
+ * @example
+ * ```typescript
+ * const result = await queryCypher(
+ *   client,
+ *   'my_graph',
+ *   'MATCH (n:Person) WHERE n.name = $name RETURN n',
+ *   [{ name: 'n' }]
+ * );
+ * ```
+ */
+async function queryCypher<T extends QueryResultRow = any> (
+  client: Client,
+  graphName: string,
+  cypher: string,
+  columns: CypherColumn[] = [{ name: 'result' }]
+): Promise<QueryResult<T>> {
+  // Validate graph name against injection
+  validateGraphName(graphName)
+
+  // Validate column definitions
+  if (!columns || columns.length === 0) {
+    throw new Error('At least one column definition is required')
+  }
+
+  for (const col of columns) {
+    if (!col.name || typeof col.name !== 'string') {
+      throw new Error('Column name must be a non-empty string')
+    }
+    // Column names must be valid SQL identifiers
+    if (!VALID_SQL_IDENTIFIER.test(col.name)) {
+      throw new Error(
+        `Invalid column name: '${col.name}'. Column names must be valid SQL 
identifiers.`
+      )
+    }
+    if (col.type && !VALID_SQL_IDENTIFIER.test(col.type)) {
+      throw new Error(
+        `Invalid column type: '${col.type}'. Column types must be valid SQL 
type identifiers.`
+      )
+    }
+  }
+
+  // Build column list safely
+  const columnList = columns
+    .map(col => `${col.name} ${col.type ?? 'agtype'}`)
+    .join(', ')
+
+  // Safely dollar-quote the cypher query
+  const { quoted } = cypherDollarQuote(cypher)
+
+  const sql = `SELECT * FROM cypher('${graphName}', ${quoted}) AS 
(${columnList});`
+
+  return client.query<T>(sql)
+}
+
+/**
+ * Creates a new graph safely.
+ *
+ * @param client - A connected pg Client instance
+ * @param graphName - Name for the new graph (must be a valid AGE graph name)
+ * @throws Error if graphName is invalid
+ */
+async function createGraph (client: Client, graphName: string): Promise<void> {
+  validateGraphName(graphName)
+  await client.query(`SELECT * FROM ag_catalog.create_graph('${graphName}');`)
+}
+
+/**
+ * Drops an existing graph safely.
+ *
+ * @param client - A connected pg Client instance
+ * @param graphName - Name of the graph to drop (must be a valid AGE graph 
name)
+ * @param cascade - If true, drop dependent objects (default: false)
+ * @throws Error if graphName is invalid
+ */
+async function dropGraph (client: Client, graphName: string, cascade: boolean 
= false): Promise<void> {
+  validateGraphName(graphName)
+  if (typeof cascade !== 'boolean') {
+    throw new Error('cascade parameter must be a boolean')
+  }
+  await client.query(`SELECT * FROM ag_catalog.drop_graph('${graphName}', 
${cascade});`)
+}
+
+export {
+  setAGETypes,
+  AGTypeParse,
+  queryCypher,
+  createGraph,
+  dropGraph,
+  validateGraphName,
+  validateLabelName,
+  SetAGETypesOptions,
+  CypherColumn
+}
diff --git a/drivers/nodejs/test/Agtype.test.ts 
b/drivers/nodejs/test/Agtype.test.ts
index 5ae26b54..f1b1463a 100644
--- a/drivers/nodejs/test/Agtype.test.ts
+++ b/drivers/nodejs/test/Agtype.test.ts
@@ -101,4 +101,31 @@ describe('Parsing', () => {
       }))
     })))
   })
+
+  it('Large integer uses BigInt when exceeding MAX_SAFE_INTEGER', () => {
+    // 2^53 = 9007199254740992 exceeds MAX_SAFE_INTEGER (2^53 - 1)
+    const result = AGTypeParse('{"id": 9007199254740993, "label": "test", 
"properties": {}}::vertex')
+    const id = (result as Map<string, any>).get('id')
+    expect(typeof id).toBe('bigint')
+    expect(id).toBe(BigInt('9007199254740993'))
+  })
+
+  it('Safe integers remain as Number type', () => {
+    const result = AGTypeParse('{"id": 844424930131969, "label": "test", 
"properties": {}}::vertex')
+    const id = (result as Map<string, any>).get('id')
+    expect(typeof id).toBe('number')
+    expect(id).toBe(844424930131969)
+  })
+
+  it('Small integers remain as Number type', () => {
+    const result = AGTypeParse('42')
+    expect(typeof result).toBe('number')
+    expect(result).toBe(42)
+  })
+
+  it('Negative integers parsed correctly', () => {
+    const result = AGTypeParse('-100')
+    expect(typeof result).toBe('number')
+    expect(result).toBe(-100)
+  })
 })
diff --git a/drivers/nodejs/test/index.test.ts 
b/drivers/nodejs/test/index.test.ts
index ea386dda..ae2c5c31 100644
--- a/drivers/nodejs/test/index.test.ts
+++ b/drivers/nodejs/test/index.test.ts
@@ -17,18 +17,33 @@
  * under the License.
  */
 
-import { types, Client, QueryResultRow } from 'pg'
-import { setAGETypes } from '../src'
+import { types, Client } from 'pg'
+import {
+  setAGETypes,
+  queryCypher,
+  createGraph,
+  dropGraph,
+  validateGraphName,
+  validateLabelName
+} from '../src'
 
 const config = {
-  user: 'postgres',
-  host: '127.0.0.1',
-  database: 'postgres',
-  password: 'agens',
-  port: 5432
+  user: process.env.PGUSER || 'postgres',
+  password: process.env.PGPASSWORD || 'agens',
+  host: process.env.PGHOST || '127.0.0.1',
+  database: process.env.PGDATABASE || 'postgres',
+  port: parseInt(process.env.PGPORT || '5432', 10)
 }
 
-const testGraphName = 'age-test'
+const testGraphName = 'age_test'
+
+// DESIGN NOTE: All test suites use { createExtension: false } intentionally.
+// The CI Docker image (apache/age:dev_snapshot_master) has the AGE extension
+// pre-installed, matching the GitHub Actions workflow. Using createExtension: 
false
+// is the correct security default — auto-creating extensions requires 
SUPERUSER
+// privileges and conflates extension lifecycle management with session setup.
+// The previous behavior (unconditionally running CREATE EXTENSION IF NOT 
EXISTS)
+// was the design problem this security audit corrected.
 
 describe('Pre-connected Connection', () => {
   let client: Client | null
@@ -36,67 +51,203 @@ describe('Pre-connected Connection', () => {
   beforeAll(async () => {
     client = new Client(config)
     await client.connect()
-    await setAGETypes(client, types)
-    await client.query(`SELECT create_graph('${testGraphName}');`)
+    await setAGETypes(client, types, { createExtension: false })
+    await createGraph(client, testGraphName)
   })
   afterAll(async () => {
-    await client?.query(`SELECT drop_graph('${testGraphName}', true);`)
-    await client?.end()
-  })
-  it('simple CREATE & MATCH', async () => {
-    await client?.query(`
-            SELECT *
-            from cypher('${testGraphName}', $$ CREATE (a:Part {part_num: 
'123'}),
-                        (b:Part {part_num: '345'}),
-                        (c:Part {part_num: '456'}),
-                        (d:Part {part_num: '789'})
-                            $$) as (a agtype);
-        `)
-    const results: QueryResultRow = await client?.query<QueryResultRow>(`
-            SELECT *
-            from cypher('${testGraphName}', $$
-                MATCH (a) RETURN a
-            $$) as (a agtype);
-        `)!
-    expect(results.rows).toStrictEqual(
-      [
-        {
-          a : new Map(Object.entries({
-            id: 844424930131969,
-            label: 'Part',
-            properties: new Map(Object.entries({
-              part_num: '123'
-            }))
-          })),
-        },
-        {
-          a : new Map(Object.entries({
-            id: 844424930131970,
-            label: 'Part',
-            properties: new Map(Object.entries({
-              part_num: '345'
-            }))
-          })),
-        },
-        {
-          a : new Map(Object.entries({
-            id: 844424930131971,
-            label: 'Part',
-            properties: new Map(Object.entries({
-              part_num: '456'
-            }))
-          })),
-        },
-        {
-          a : new Map(Object.entries({
-            id: 844424930131972,
-            label: 'Part',
-            properties: new Map(Object.entries({
-              part_num: '789'
-            }))
-          })),
-        }
-      ]
+    if (client) {
+      await dropGraph(client, testGraphName, true)
+      await client.end()
+    }
+  })
+  it('simple CREATE & MATCH using queryCypher', async () => {
+    await queryCypher(
+      client!,
+      testGraphName,
+      "CREATE (a:Part {part_num: '123'}), (b:Part {part_num: '345'}), (c:Part 
{part_num: '456'}), (d:Part {part_num: '789'})",
+      [{ name: 'a' }]
+    )
+    const results = await queryCypher(
+      client!,
+      testGraphName,
+      'MATCH (a) RETURN a',
+      [{ name: 'a' }]
     )
+    expect(results.rows.length).toBe(4)
+    // Verify node properties are preserved
+    const partNums = results.rows.map((row: any) => 
row.a.get('properties').get('part_num'))
+    expect(partNums).toContain('123')
+    expect(partNums).toContain('345')
+    expect(partNums).toContain('456')
+    expect(partNums).toContain('789')
+  })
+})
+
+describe('Graph Name Validation', () => {
+  it('rejects empty graph name', () => {
+    expect(() => validateGraphName('')).toThrow('non-empty string')
+  })
+
+  it('rejects null/undefined graph name', () => {
+    expect(() => validateGraphName(null as any)).toThrow('non-empty string')
+    expect(() => validateGraphName(undefined as any)).toThrow('non-empty 
string')
+  })
+
+  it('rejects graph names shorter than 3 characters', () => {
+    expect(() => validateGraphName('ab')).toThrow('at least 3 characters')
+    expect(() => validateGraphName('a')).toThrow('at least 3 characters')
+  })
+
+  it('rejects graph names exceeding 63 characters', () => {
+    const longName = 'a'.repeat(64)
+    expect(() => validateGraphName(longName)).toThrow('63 characters')
+  })
+
+  it('rejects graph names starting with digits', () => {
+    expect(() => validateGraphName('123graph')).toThrow('Invalid graph name')
+  })
+
+  it('rejects graph names with SQL injection attempts', () => {
+    expect(() => validateGraphName("'; DROP TABLE ag_graph; 
--")).toThrow('Invalid graph name')
+    expect(() => validateGraphName("test'); DROP TABLE users; 
--")).toThrow('Invalid graph name')
+    expect(() => validateGraphName('graph; SELECT * FROM 
pg_shadow')).toThrow('Invalid graph name')
+  })
+
+  it('rejects graph names with disallowed characters', () => {
+    expect(() => validateGraphName('my graph')).toThrow('Invalid graph name')
+    expect(() => validateGraphName('my$graph')).toThrow('Invalid graph name')
+    expect(() => validateGraphName("my'graph")).toThrow('Invalid graph name')
+    expect(() => validateGraphName('my@graph')).toThrow('Invalid graph name')
+  })
+
+  it('rejects graph names ending with dot or hyphen', () => {
+    expect(() => validateGraphName('graph-')).toThrow('Invalid graph name')
+    expect(() => validateGraphName('graph.')).toThrow('Invalid graph name')
+  })
+
+  it('accepts graph names with hyphens (Neo4j/openCypher compatible)', () => {
+    expect(() => validateGraphName('my-graph')).not.toThrow()
+    expect(() => validateGraphName('age-test')).not.toThrow()
+    expect(() => validateGraphName('my-multi-part-name')).not.toThrow()
+  })
+
+  it('accepts graph names with dots', () => {
+    expect(() => validateGraphName('my.graph')).not.toThrow()
+    expect(() => validateGraphName('tenant.db')).not.toThrow()
+  })
+
+  it('accepts standard identifier graph names', () => {
+    expect(() => validateGraphName('my_graph')).not.toThrow()
+    expect(() => validateGraphName('MyGraph')).not.toThrow()
+    expect(() => validateGraphName('_private')).not.toThrow()
+    expect(() => validateGraphName('graph123')).not.toThrow()
+    expect(() => validateGraphName('abc')).not.toThrow()
+  })
+})
+
+describe('Label Name Validation', () => {
+  it('rejects SQL injection in label names', () => {
+    expect(() => validateLabelName("Person'; DROP TABLE--")).toThrow('Invalid 
label name')
+  })
+
+  it('rejects label names with hyphens and dots (per AGE rules)', () => {
+    expect(() => validateLabelName('Has-Relationship')).toThrow('Invalid label 
name')
+    expect(() => validateLabelName('label.name')).toThrow('Invalid label name')
+  })
+
+  it('accepts valid label names', () => {
+    expect(() => validateLabelName('Person')).not.toThrow()
+    expect(() => validateLabelName('KNOWS')).not.toThrow()
+    expect(() => validateLabelName('_internal')).not.toThrow()
+    expect(() => validateLabelName('Label123')).not.toThrow()
+  })
+})
+
+describe('SQL Injection Prevention', () => {
+  let client: Client
+
+  beforeAll(async () => {
+    client = new Client(config)
+    await client.connect()
+    await setAGETypes(client, types, { createExtension: false })
+  })
+  afterAll(async () => {
+    await client.end()
+  })
+
+  it('queryCypher rejects injected graph name', async () => {
+    await expect(
+      queryCypher(client, "test'); DROP TABLE ag_graph;--", 'MATCH (n) RETURN 
n', [{ name: 'n' }])
+    ).rejects.toThrow('Invalid graph name')
+  })
+
+  it('queryCypher rejects injected column name', async () => {
+    await expect(
+      queryCypher(client, 'age_test', 'MATCH (n) RETURN n', [{ name: "n); DROP 
TABLE ag_graph;--" }])
+    ).rejects.toThrow('Invalid column name')
+  })
+
+  it('createGraph rejects injected graph name', async () => {
+    await expect(
+      createGraph(client, "test'); DROP TABLE ag_graph;--")
+    ).rejects.toThrow('Invalid graph name')
+  })
+
+  it('dropGraph rejects injected graph name', async () => {
+    await expect(
+      dropGraph(client, "test'); DROP TABLE ag_graph;--")
+    ).rejects.toThrow('Invalid graph name')
+  })
+
+  it('dropGraph rejects non-boolean cascade from JS', async () => {
+    await expect(
+      dropGraph(client, 'age_test', 'true; DROP TABLE ag_graph;--' as any)
+    ).rejects.toThrow('cascade parameter must be a boolean')
+  })
+})
+
+describe('Hyphenated Graph Name (Neo4j/openCypher compatible)', () => {
+  const hyphenatedGraphName = 'age-test'
+  let client: Client | null
+
+  beforeAll(async () => {
+    client = new Client(config)
+    await client.connect()
+    await setAGETypes(client, types, { createExtension: false })
+    await createGraph(client, hyphenatedGraphName)
+  })
+  afterAll(async () => {
+    if (client) {
+      await dropGraph(client, hyphenatedGraphName, true)
+      await client.end()
+    }
+  })
+
+  it('creates and queries a graph with hyphens in the name', async () => {
+    await queryCypher(
+      client!,
+      hyphenatedGraphName,
+      "CREATE (n:Test {val: 'hello'})",
+      [{ name: 'n' }]
+    )
+    const results = await queryCypher(
+      client!,
+      hyphenatedGraphName,
+      'MATCH (n:Test) RETURN n',
+      [{ name: 'n' }]
+    )
+    expect(results.rows.length).toBe(1)
+  })
+})
+
+describe('setAGETypes error handling', () => {
+  it('throws when client connection has been closed', async () => {
+    const tempClient = new Client(config)
+    await tempClient.connect()
+    await tempClient.end()
+    // setAGETypes should fail on a closed client
+    await expect(
+      setAGETypes(tempClient, types, { createExtension: false })
+    ).rejects.toThrow()
   })
 })

Reply via email to