Files
supabase/apps/studio/lib/sql-event-parser.test.ts
T
Alaister Young 2555e81dde fix(studio): don't scan dollar-quoted bodies for DDL in SQL editor (#45050)
Fixes a false positive in the CREATE-TABLE-without-RLS warning modal
added in #45008. The warning was firing on `CREATE FUNCTION` statements
because the `SELECT..INTO` detector was matching plpgsql variable
assignments inside `$$…$$` function bodies.

Reported example that triggered the modal with no table actually being
created:

```sql
create or replace function schema_checks()
returns jsonb
language plpgsql
as $$
declare
  ret jsonb;
begin
  select jsonb_build_object('value', 'ok') into ret;
  return ret;
end;
$$;
```

**Changed:**
- `SQLEventParser.match()` now strips the body of `$tag$…$tag$` blocks
before running detectors. Tags are kept as markers; content is blanked
out so function bodies, DO blocks, and dollar-quoted string literals are
never scanned as DDL.
- Updated a pre-existing parser test that asserted the buggy behaviour
(it expected `CREATE TABLE fake` inside a `$$…$$` string literal to be
detected — `$$…$$` is a string literal in Postgres, not DDL).

**Added:**
- Regression tests in `SQLEditor.utils.test.ts` covering: the exact
reported function, DO blocks with `select into`, `create table` text
inside a function body, mixed top-level `CREATE TABLE` + function with
`INTO` assignments, and custom `$body$…$body$` tags.
- Parser-level regression test in `sql-event-parser.test.ts`.

## To test

- In the SQL editor, paste the function from the Slack report and run it
— the RLS warning modal should not appear.
- Run `create table foo (id int8 primary key);` on its own — modal still
appears as before.
- Run `create table foo (id int8); create or replace function bar()
returns int language plpgsql as $$ declare v int; begin select 1 into v;
return v; end; $$;` — modal should flag only `foo`, not `v`.
- Run an existing destructive query (`drop table x`) — unaffected, modal
still works.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Parser no longer treats DDL/DML-like text inside PL/pgSQL functions,
DO blocks, or dollar-quoted bodies (including nested/custom tags) as
top-level CREATE TABLE/SELECT INTO, preventing false detections and UI
warnings.

* **Tests**
* Added unit and e2e regression tests covering dollar-quoted blocks,
nested dollar tags, DO blocks, SELECT INTO inside functions, and
positive controls with a real top-level CREATE TABLE.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-04-20 21:19:28 +08:00

537 lines
18 KiB
TypeScript

import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants'
import { describe, expect, it } from 'vitest'
import { sqlEventParser } from './sql-event-parser'
describe('SQL Event Parser', () => {
describe('CREATE TABLE detection', () => {
it('detects basic CREATE TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
it('detects CREATE TABLE with schema', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'users',
})
})
it('detects CREATE TABLE IF NOT EXISTS', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'user_table',
})
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
it('detects CREATE TEMPORARY TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
it('detects CREATE TEMP TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
it('detects CREATE UNLOGGED TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'fast_table',
})
})
it('detects CREATE TEMP TABLE IF NOT EXISTS', () => {
const results = sqlEventParser.getTableEvents(
'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
})
describe('INSERT detection', () => {
it('detects basic INSERT INTO', () => {
const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')")
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: undefined,
tableName: 'users',
})
})
it('detects INSERT with schema', () => {
const results = sqlEventParser.getTableEvents(
"INSERT INTO public.users (name) VALUES ('John')"
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'public',
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'auth',
tableName: 'users',
})
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"')
expect(results).toHaveLength(0)
})
})
describe('COPY detection', () => {
it('detects basic COPY FROM', () => {
const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'")
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: undefined,
tableName: 'users',
})
})
it('detects COPY with schema', () => {
const results = sqlEventParser.getTableEvents(
"COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER"
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'public',
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'auth',
tableName: 'users',
})
})
it('returns empty array for COPY TO', () => {
const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'")
expect(results).toHaveLength(0)
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
})
describe('SELECT INTO detection', () => {
it('detects SELECT INTO', () => {
const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('detects SELECT INTO with schema', () => {
const results = sqlEventParser.getTableEvents(
'SELECT id, name INTO public.new_users FROM users'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'new_users',
})
})
it('detects CREATE TABLE AS SELECT', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => {
const results = sqlEventParser.getTableEvents(
'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents(
'SELECT * INTO "backup"."users_2024" FROM users'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'backup',
tableName: 'users_2024',
})
})
it('returns empty array for regular SELECT', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
})
describe('RLS detection', () => {
it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('detects short form ENABLE RLS', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('detects with schema', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: 'public',
tableName: 'users',
})
})
it('handles other ALTER TABLE statements in between', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('returns empty array for disabling RLS', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY')
expect(results).toHaveLength(0)
})
})
describe('ReDoS protection', () => {
it('handles extremely long identifier names efficiently', () => {
const longIdentifier = 'a'.repeat(10000)
const sql = `CREATE TABLE ${longIdentifier} (id INT)`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: longIdentifier,
})
})
it('handles nested dots in schema names without catastrophic backtracking', () => {
const maliciousInput = 'a.'.repeat(1000) + 'table'
const sql = `CREATE TABLE ${maliciousInput} (id INT)`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results.length).toBeGreaterThan(0)
})
it('handles pathological SELECT INTO patterns', () => {
const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users'
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(maliciousSQL)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'table',
})
})
it('handles ALTER TABLE with many operations between', () => {
const manyOperations = 'ADD COLUMN test INT, '.repeat(100)
const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('handles mixed quotes and backticks efficiently', () => {
const mixedQuotes = '`"`.'.repeat(100) + 'tablename'
const sql = `CREATE TABLE ${mixedQuotes} (id INT)`
const startTime = Date.now()
sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
})
})
describe('Edge cases and special characters', () => {
it('handles Unicode identifiers', () => {
const sql = 'CREATE TABLE 用户表 (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(0)
})
it('handles identifiers with numbers', () => {
const sql = 'CREATE TABLE table123 (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'table123',
})
})
it('handles identifiers with underscores', () => {
const sql = 'CREATE TABLE user_accounts (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'user_accounts',
})
})
it('handles escaped quotes in identifiers', () => {
const sql = 'CREATE TABLE "user""table" (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'usertable',
})
})
it('does not scan inside dollar-quoted string literals', () => {
// $$...$$ is a string literal in Postgres — its contents must not be
// parsed as DDL, otherwise a user inserting SQL-shaped text into a log
// table would trigger false-positive table-created events.
const sql = `
CREATE TABLE users (id INT);
INSERT INTO logs VALUES ($$CREATE TABLE fake$$);
INSERT INTO users VALUES (1);
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(3)
expect(results[0]).toMatchObject({
type: TABLE_EVENT_ACTIONS.TableCreated,
tableName: 'users',
})
expect(results[1]).toMatchObject({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
tableName: 'logs',
})
expect(results[2]).toMatchObject({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
tableName: 'users',
})
})
it('does not treat SELECT..INTO inside a plpgsql body as table creation', () => {
// Regression for the RLS warning modal false-positive: variable
// assignment inside a function body must not be reported as a new table.
const sql = `
create or replace function schema_checks()
returns jsonb
language plpgsql
as $$
declare
ret jsonb;
begin
select jsonb_build_object('value', 'ok') into ret;
return ret;
end;
$$;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toEqual([])
})
it('does not leak nested dollar-quoted dynamic SQL through statement splitting', () => {
// Regression: an inner $sql$...$sql$ tag used inside an outer $fn$...$fn$
// body was pairing with the outer opening tag (the splitStatements regex
// doesn't enforce matching tags), which caused the inner semicolon to
// split the statement and exposed `create table fake` to the detectors.
// The fix is that stripDollarQuoteBodies runs before splitStatements and
// uses a backreference to require matching tags.
const sql = `
create function f()
returns void
language plpgsql
as $fn$
begin
execute $sql$create table fake(id int);$sql$;
end;
$fn$;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toEqual([])
})
it('still detects a real top-level CREATE TABLE next to a function with nested dollar tags', () => {
const sql = `
create table public.real_table(id int);
create function f()
returns void
language plpgsql
as $fn$
begin
execute $sql$create table fake(id int);$sql$;
end;
$fn$;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toEqual([
{
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'real_table',
},
])
})
it('handles SQL injection attempts safely', () => {
const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)"
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
})
describe('getTableEvents', () => {
it('filters only table-related events', () => {
const sql = `
CREATE TABLE users (id INT);
CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
INSERT INTO users (id) VALUES (1);
ALTER TABLE users ENABLE RLS;
CREATE VIEW user_view AS SELECT * FROM users;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(3)
expect(results.map((r) => r.type)).toEqual([
TABLE_EVENT_ACTIONS.TableCreated,
TABLE_EVENT_ACTIONS.TableDataAdded,
TABLE_EVENT_ACTIONS.TableRLSEnabled,
])
})
it('returns empty array for non-table SQL', () => {
const sql = `
CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
CREATE VIEW user_view AS SELECT * FROM users;
SELECT * FROM users;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(0)
})
})
})