Files
supabase/apps/studio/lib/sql-event-parser.test.ts
Alaister Young 3036bb103b [FE-3134] fix(studio): handle ALTER TABLE IF EXISTS in RLS detection (#45493)
The SQL Editor was warning about missing RLS even when the same query
enabled it, if the user wrote `ALTER TABLE IF EXISTS ...`. The parser
regex didn't recognise `IF EXISTS` and was capturing `IF` as the table
name, so the RLS event never matched the `CREATE TABLE`.

**Changed:**
- `ALTER TABLE` regex in `sql-event-parser.ts` now accepts the optional
`IF EXISTS` and `ONLY` modifiers, matching Postgres's `ALTER TABLE [ IF
EXISTS ] [ ONLY ] name` grammar.

**Added:**
- Unit tests for `IF EXISTS`, `ONLY`, and both combined.
- Regression test in `SQLEditor.utils.test.ts` using the customer's
exact SQL.

## To test

1. Open the SQL Editor and paste:

```sql
CREATE TABLE IF NOT EXISTS public."Conversations" (id int8 primary key);
ALTER TABLE IF EXISTS public."Conversations" ENABLE ROW LEVEL SECURITY;
```

2. Hit Run – the "table will not have RLS" warning should **not**
appear.
3. Sanity check: a `CREATE TABLE` without any matching `ENABLE ROW LEVEL
SECURITY` still triggers the warning.

Addresses
[FE-3134](https://linear.app/supabase/issue/FE-3134/sql-editor-warns-about-missing-rls-policy-incorrectly).

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

## Summary by CodeRabbit

* **Tests**
* Added comprehensive test coverage for Row Level Security detection
across different SQL syntax patterns and clause combinations

* **Bug Fixes**
* Enhanced Row Level Security detection capabilities in the SQL editor
by extending support for additional ALTER TABLE statement syntax
variations, improving the accuracy and completeness of security
configuration recognition

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-05-04 16:21:47 +08:00

573 lines
19 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)
})
it('detects ALTER TABLE IF EXISTS ENABLE ROW LEVEL SECURITY', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE IF EXISTS public."Conversations" ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: 'public',
tableName: 'Conversations',
})
})
it('detects ALTER TABLE ONLY ENABLE ROW LEVEL SECURITY', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE ONLY public.users ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: 'public',
tableName: 'users',
})
})
it('detects ALTER TABLE IF EXISTS ONLY ENABLE ROW LEVEL SECURITY', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE IF EXISTS ONLY public.users ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: 'public',
tableName: 'users',
})
})
})
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)
})
})
})