SQLEditor.utils.test.ts726 lines · main
| 1 | import { safeSql } from '@supabase/pg-meta' |
| 2 | import { stripIndent } from 'common-tags' |
| 3 | import { describe, expect, it, test } from 'vitest' |
| 4 | |
| 5 | import { |
| 6 | appendEnableRLSStatements, |
| 7 | checkAlterDatabaseConnection, |
| 8 | checkDestructiveQuery, |
| 9 | checkIfAppendLimitRequired, |
| 10 | filterTablesCoveredByEnsureRLSTrigger, |
| 11 | getCreateTablesMissingRLS, |
| 12 | hasActiveEnsureRLSTrigger, |
| 13 | isUpdateWithoutWhere, |
| 14 | suffixWithLimit, |
| 15 | } from './SQLEditor.utils' |
| 16 | import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query' |
| 17 | |
| 18 | const buildTrigger = (overrides: Partial<DatabaseEventTrigger> = {}): DatabaseEventTrigger => ({ |
| 19 | oid: 1, |
| 20 | name: 'ensure_rls', |
| 21 | event: 'ddl_command_end', |
| 22 | enabled_mode: 'ORIGIN', |
| 23 | tags: ['CREATE TABLE'], |
| 24 | function_name: 'rls_auto_enable', |
| 25 | function_schema: 'public', |
| 26 | owner: 'postgres', |
| 27 | function_definition: null, |
| 28 | ...overrides, |
| 29 | }) |
| 30 | |
| 31 | describe('SQLEditor.utils.ts:checkIfAppendLimitRequired', () => { |
| 32 | test('Should return false if limit passed is <= 0', () => { |
| 33 | const sql = 'select * from countries;' |
| 34 | const limit = -1 |
| 35 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 36 | expect(appendAutoLimit).toBe(false) |
| 37 | }) |
| 38 | test('Should return true if limit passed is > 0', () => { |
| 39 | const sql = 'select * from countries;' |
| 40 | const limit = 100 |
| 41 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 42 | expect(appendAutoLimit).toBe(true) |
| 43 | }) |
| 44 | test('Should return false if query already has a limit', () => { |
| 45 | const sql = 'select * from countries limit 10;' |
| 46 | const limit = 100 |
| 47 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 48 | expect(appendAutoLimit).toBe(false) |
| 49 | }) |
| 50 | test('Should return false if query already has a limit (check for case-insensitiveness)', () => { |
| 51 | const sql = 'SELECT * FROM countries LIMIT 10;' |
| 52 | const limit = 100 |
| 53 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 54 | expect(appendAutoLimit).toBe(false) |
| 55 | }) |
| 56 | test('Should return false if query already has a limit and offset', () => { |
| 57 | const sql = 'select * from countries limit 10 offset 0;' |
| 58 | const limit = 100 |
| 59 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 60 | expect(appendAutoLimit).toBe(false) |
| 61 | }) |
| 62 | test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => { |
| 63 | const sql = 'select * from countries offset 0 limit 1;' |
| 64 | const limit = 100 |
| 65 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 66 | expect(appendAutoLimit).toBe(false) |
| 67 | }) |
| 68 | test('Should return false if query already has a limit, even if no value provided for limit', () => { |
| 69 | const sql = 'select * from countries limit' |
| 70 | const limit = 100 |
| 71 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 72 | expect(appendAutoLimit).toBe(false) |
| 73 | }) |
| 74 | test('Should return false if query uses `FETCH FIRST` instead of limit ', () => { |
| 75 | const sql = 'select * from countries FETCH FIRST 5 rows only' |
| 76 | const limit = 100 |
| 77 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 78 | expect(appendAutoLimit).toBe(false) |
| 79 | }) |
| 80 | test('Should return false if query uses `fetch first` instead of limit ', () => { |
| 81 | const sql = 'select * from countries fetch first 5 rows only' |
| 82 | const limit = 100 |
| 83 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 84 | expect(appendAutoLimit).toBe(false) |
| 85 | }) |
| 86 | test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => { |
| 87 | const sql = 'select * from countries FETCH FIRST 5 rows only' |
| 88 | const limit = 100 |
| 89 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 90 | expect(appendAutoLimit).toBe(false) |
| 91 | }) |
| 92 | test('Should return false if query is not a select statement', () => { |
| 93 | const sql = 'create table test (id int8 primary key, name varchar);' |
| 94 | const limit = 100 |
| 95 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 96 | expect(appendAutoLimit).toBe(false) |
| 97 | }) |
| 98 | test('Should return false if there are multiple queries I', () => { |
| 99 | const sql1 = ` |
| 100 | select * from countries; |
| 101 | select * from cities; |
| 102 | `.trim() |
| 103 | const limit = 100 |
| 104 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql1, limit) |
| 105 | expect(appendAutoLimit).toBe(false) |
| 106 | }) |
| 107 | test('Should return false if there are multiple queries II', () => { |
| 108 | const sql1 = ` |
| 109 | select * from countries; |
| 110 | select * from cities |
| 111 | `.trim() |
| 112 | const limit = 100 |
| 113 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql1, limit) |
| 114 | expect(appendAutoLimit).toBe(false) |
| 115 | }) |
| 116 | // [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm |
| 117 | test('Should return false if query has with a comment I', () => { |
| 118 | const sql = ` |
| 119 | -- This is a comment |
| 120 | select * from cities |
| 121 | `.trim() |
| 122 | const limit = 100 |
| 123 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 124 | expect(appendAutoLimit).toBe(false) |
| 125 | }) |
| 126 | test('Should return false if query has with a comment II', () => { |
| 127 | const sql = ` |
| 128 | select * from cities |
| 129 | -- This is a comment |
| 130 | `.trim() |
| 131 | const limit = 100 |
| 132 | const { appendAutoLimit } = checkIfAppendLimitRequired(sql, limit) |
| 133 | expect(appendAutoLimit).toBe(false) |
| 134 | }) |
| 135 | }) |
| 136 | |
| 137 | // [Joshen] These will just need to test the cases when appendAutoLimit returns true then |
| 138 | describe('SQLEditor.utils.ts:suffixWithLimit', () => { |
| 139 | test('Should add the limit param properly if query ends without a semi colon', () => { |
| 140 | const sql = safeSql`select * from countries` |
| 141 | const limit = 100 |
| 142 | const formattedSql = suffixWithLimit(sql, limit) |
| 143 | expect(formattedSql).toBe('select * from countries limit 100;') |
| 144 | }) |
| 145 | test('Should add the limit param properly if query ends with a semi colon', () => { |
| 146 | const sql = safeSql`select * from countries;` |
| 147 | const limit = 100 |
| 148 | const formattedSql = suffixWithLimit(sql, limit) |
| 149 | expect(formattedSql).toBe('select * from countries limit 100;') |
| 150 | }) |
| 151 | test('Should add the limit param properly if query ends with multiple semi colon', () => { |
| 152 | const sql = safeSql`select * from countries;;;;;;;` |
| 153 | const limit = 100 |
| 154 | const formattedSql = suffixWithLimit(sql, limit) |
| 155 | expect(formattedSql).toBe('select * from countries limit 100;') |
| 156 | }) |
| 157 | }) |
| 158 | |
| 159 | describe(`SQLEditor.utils.ts:checkDestructiveQuery`, () => { |
| 160 | it('drop statement matches', () => { |
| 161 | const match = checkDestructiveQuery('drop table films, distributors;') |
| 162 | |
| 163 | expect(match).toBe(true) |
| 164 | }) |
| 165 | |
| 166 | it('truncate statement matches', () => { |
| 167 | const match = checkDestructiveQuery('truncate films;') |
| 168 | |
| 169 | expect(match).toBe(true) |
| 170 | }) |
| 171 | |
| 172 | it('delete statement matches', () => { |
| 173 | const match = checkDestructiveQuery("delete from films where kind <> 'Musical';") |
| 174 | |
| 175 | expect(match).toBe(true) |
| 176 | }) |
| 177 | |
| 178 | it('delete statement after another statement matches', () => { |
| 179 | const match = checkDestructiveQuery(stripIndent` |
| 180 | select * from films; |
| 181 | |
| 182 | delete from films where kind <> 'Musical'; |
| 183 | `) |
| 184 | |
| 185 | expect(match).toBe(true) |
| 186 | }) |
| 187 | |
| 188 | it("rls policy containing delete doesn't match", () => { |
| 189 | const match = checkDestructiveQuery(stripIndent` |
| 190 | create policy "Users can delete their own files" |
| 191 | on storage.objects for delete to authenticated using ( |
| 192 | bucket id = 'files' and (select auth.uid()) = owner |
| 193 | ); |
| 194 | `) |
| 195 | |
| 196 | expect(match).toBe(false) |
| 197 | }) |
| 198 | |
| 199 | it('capitalized statement matches', () => { |
| 200 | const match = checkDestructiveQuery("DELETE FROM films WHERE kind <> 'Musical';") |
| 201 | |
| 202 | expect(match).toBe(true) |
| 203 | }) |
| 204 | |
| 205 | it("comment containing keyword doesn't match", () => { |
| 206 | const match = checkDestructiveQuery(stripIndent` |
| 207 | -- Going to drop this in here, might delete later |
| 208 | select * from films; |
| 209 | `) |
| 210 | |
| 211 | expect(match).toBe(false) |
| 212 | }) |
| 213 | }) |
| 214 | |
| 215 | describe('SQLEditor.utils:updateWithoutWhere', () => { |
| 216 | it('contains an update query with a where clause', () => { |
| 217 | const match = isUpdateWithoutWhere(stripIndent` |
| 218 | UPDATE public.countries SET name = 'New Name' WHERE id = 1; |
| 219 | `) |
| 220 | |
| 221 | expect(match).toBe(false) |
| 222 | }) |
| 223 | |
| 224 | it('contains an update query without a where clause', () => { |
| 225 | const match = isUpdateWithoutWhere(stripIndent` |
| 226 | UPDATE public.countries SET name = 'New Name'; |
| 227 | `) |
| 228 | |
| 229 | expect(match).toBe(true) |
| 230 | }) |
| 231 | |
| 232 | it('contains an update query, with quoted identifiers with a where clause', () => { |
| 233 | const match = isUpdateWithoutWhere(stripIndent` |
| 234 | UPDATE "public"."countries" SET name = 'New Name' WHERE id = 1; |
| 235 | `) |
| 236 | |
| 237 | expect(match).toBe(false) |
| 238 | }) |
| 239 | |
| 240 | it('contains an update query, with quoted identifiers without a where clause', () => { |
| 241 | const match = isUpdateWithoutWhere(stripIndent` |
| 242 | UPDATE "public"."countries" SET name = 'New Name'; |
| 243 | `) |
| 244 | |
| 245 | expect(match).toBe(true) |
| 246 | }) |
| 247 | |
| 248 | it('catches update on a single quoted table name without a where clause', () => { |
| 249 | const match = isUpdateWithoutWhere(`UPDATE "messages" SET id = 1;`) |
| 250 | expect(match).toBe(true) |
| 251 | }) |
| 252 | |
| 253 | it('does not flag update on a single quoted table name with a where clause', () => { |
| 254 | const match = isUpdateWithoutWhere(`UPDATE "messages" SET id = 1 WHERE id = 2;`) |
| 255 | expect(match).toBe(false) |
| 256 | }) |
| 257 | |
| 258 | it('catches update on a quoted schema with a bareword table without a where clause', () => { |
| 259 | const match = isUpdateWithoutWhere(`UPDATE "public".messages SET id = 1;`) |
| 260 | expect(match).toBe(true) |
| 261 | }) |
| 262 | |
| 263 | it('catches update on a bareword schema with a quoted table without a where clause', () => { |
| 264 | const match = isUpdateWithoutWhere(`UPDATE public."messages" SET id = 1;`) |
| 265 | expect(match).toBe(true) |
| 266 | }) |
| 267 | |
| 268 | it('catches update on a quoted table name containing a space without a where clause', () => { |
| 269 | const match = isUpdateWithoutWhere(`UPDATE "my table" SET id = 1;`) |
| 270 | expect(match).toBe(true) |
| 271 | }) |
| 272 | |
| 273 | it('catches update on a quoted table name containing escaped quotes without a where clause', () => { |
| 274 | const match = isUpdateWithoutWhere(`UPDATE "weird""name" SET id = 1;`) |
| 275 | expect(match).toBe(true) |
| 276 | }) |
| 277 | |
| 278 | it('catches update where a quoted identifier contains the word where', () => { |
| 279 | const match = isUpdateWithoutWhere(`UPDATE "where table" SET id = 1;`) |
| 280 | expect(match).toBe(true) |
| 281 | }) |
| 282 | |
| 283 | it('catches update where a string literal contains the word where', () => { |
| 284 | const match = isUpdateWithoutWhere(`UPDATE messages SET name = 'where x';`) |
| 285 | expect(match).toBe(true) |
| 286 | }) |
| 287 | |
| 288 | it('does not flag update where the only "where" sits inside a string literal but a real where clause exists', () => { |
| 289 | const match = isUpdateWithoutWhere(`UPDATE messages SET name = 'where x' WHERE id = 1;`) |
| 290 | expect(match).toBe(false) |
| 291 | }) |
| 292 | |
| 293 | it('contains both an update query and a delete query, triggers destructive', () => { |
| 294 | const match = checkDestructiveQuery(stripIndent` |
| 295 | delete from countries; update countries set name = 'hello'; |
| 296 | `) |
| 297 | |
| 298 | expect(match).toBe(true) |
| 299 | }) |
| 300 | |
| 301 | it('contains both an update query and a delete query, triggers no where', () => { |
| 302 | const match = isUpdateWithoutWhere(stripIndent` |
| 303 | delete from countries; update countries set name = 'hello'; |
| 304 | `) |
| 305 | |
| 306 | expect(match).toBe(true) |
| 307 | }) |
| 308 | it('contains both an update query and a delete query, triggers no where', () => { |
| 309 | const match = isUpdateWithoutWhere(stripIndent` |
| 310 | delete from countries; update countries set name = 'hello'; |
| 311 | `) |
| 312 | |
| 313 | expect(match).toBe(true) |
| 314 | }) |
| 315 | |
| 316 | it('should catch potential destructive queries', () => { |
| 317 | const DESTRUCTIVE_QUERIES = [ |
| 318 | `ALTER TABLE test DROP COLUMN test;`, |
| 319 | `DELETE FROM test;`, |
| 320 | `DROP TABLE test;`, |
| 321 | `TRUNCATE TABLE test;`, |
| 322 | ] |
| 323 | |
| 324 | DESTRUCTIVE_QUERIES.forEach((query) => { |
| 325 | expect(checkDestructiveQuery(query), `Query ${query} should be destructive`).toBe(true) |
| 326 | }) |
| 327 | }) |
| 328 | }) |
| 329 | |
| 330 | describe('SQLEditor.utils:getCreateTablesMissingRLS', () => { |
| 331 | it('flags a basic CREATE TABLE without RLS', () => { |
| 332 | const result = getCreateTablesMissingRLS('create table foo (id int8 primary key);') |
| 333 | expect(result).toEqual([{ schema: undefined, tableName: 'foo' }]) |
| 334 | }) |
| 335 | |
| 336 | it('flags CREATE TABLE IF NOT EXISTS', () => { |
| 337 | const result = getCreateTablesMissingRLS( |
| 338 | 'create table if not exists foo (id int8 primary key);' |
| 339 | ) |
| 340 | expect(result).toHaveLength(1) |
| 341 | expect(result[0].tableName).toBe('foo') |
| 342 | }) |
| 343 | |
| 344 | it('flags schema-qualified CREATE TABLE', () => { |
| 345 | const result = getCreateTablesMissingRLS('create table public.foo (id int8 primary key);') |
| 346 | expect(result).toEqual([{ schema: 'public', tableName: 'foo' }]) |
| 347 | }) |
| 348 | |
| 349 | it('flags quoted identifiers', () => { |
| 350 | const result = getCreateTablesMissingRLS( |
| 351 | 'create table "public"."user_table" (id int8 primary key);' |
| 352 | ) |
| 353 | expect(result).toEqual([{ schema: 'public', tableName: 'user_table' }]) |
| 354 | }) |
| 355 | |
| 356 | it('flags quoted identifiers containing spaces', () => { |
| 357 | const result = getCreateTablesMissingRLS( |
| 358 | 'create table "public"."My Table" (id int8 primary key);' |
| 359 | ) |
| 360 | expect(result).toEqual([{ schema: 'public', tableName: 'My Table' }]) |
| 361 | }) |
| 362 | |
| 363 | it('matches RLS to a table whose name contains spaces', () => { |
| 364 | const sql = stripIndent` |
| 365 | create table "My Table" (id int8 primary key); |
| 366 | alter table "My Table" enable row level security; |
| 367 | ` |
| 368 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 369 | }) |
| 370 | |
| 371 | it('does not flag when ENABLE ROW LEVEL SECURITY is in the same SQL', () => { |
| 372 | const sql = stripIndent` |
| 373 | create table foo (id int8 primary key); |
| 374 | alter table foo enable row level security; |
| 375 | ` |
| 376 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 377 | }) |
| 378 | |
| 379 | it('does not flag when ENABLE RLS shorthand is in the same SQL', () => { |
| 380 | const sql = stripIndent` |
| 381 | create table foo (id int8 primary key); |
| 382 | alter table foo enable rls; |
| 383 | ` |
| 384 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 385 | }) |
| 386 | |
| 387 | it('matches RLS to the right table when multiple tables created', () => { |
| 388 | const sql = stripIndent` |
| 389 | create table foo (id int8 primary key); |
| 390 | create table bar (id int8 primary key); |
| 391 | alter table foo enable row level security; |
| 392 | ` |
| 393 | const result = getCreateTablesMissingRLS(sql) |
| 394 | expect(result).toHaveLength(1) |
| 395 | expect(result[0].tableName).toBe('bar') |
| 396 | }) |
| 397 | |
| 398 | it('does not flag when CREATE TABLE is inside a comment', () => { |
| 399 | const sql = stripIndent` |
| 400 | -- create table foo (id int8 primary key); |
| 401 | select 1; |
| 402 | ` |
| 403 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 404 | }) |
| 405 | |
| 406 | it('does not flag when there is no CREATE TABLE at all', () => { |
| 407 | expect(getCreateTablesMissingRLS('select * from foo;')).toEqual([]) |
| 408 | }) |
| 409 | |
| 410 | it('schema-qualified RLS matches schema-qualified CREATE', () => { |
| 411 | const sql = stripIndent` |
| 412 | create table public.foo (id int8 primary key); |
| 413 | alter table public.foo enable row level security; |
| 414 | ` |
| 415 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 416 | }) |
| 417 | |
| 418 | it('does not flag when ALTER TABLE IF EXISTS enables RLS', () => { |
| 419 | const sql = stripIndent` |
| 420 | CREATE TABLE IF NOT EXISTS public."Conversations" (id int8 primary key); |
| 421 | ALTER TABLE IF EXISTS public."Conversations" ENABLE ROW LEVEL SECURITY; |
| 422 | GRANT ALL ON TABLE public."Conversations" TO postgres, anon, authenticated, service_role; |
| 423 | ` |
| 424 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 425 | }) |
| 426 | |
| 427 | it('flags CREATE TEMP TABLE', () => { |
| 428 | const result = getCreateTablesMissingRLS('create temp table foo (id int8 primary key);') |
| 429 | expect(result).toHaveLength(1) |
| 430 | expect(result[0].tableName).toBe('foo') |
| 431 | }) |
| 432 | |
| 433 | it('does not flag `select ... into var` inside a plpgsql function body', () => { |
| 434 | // Regression: the SELECT..INTO detector used to match variable assignments |
| 435 | // inside $$...$$ function bodies and surface them as \"new tables\". |
| 436 | const sql = stripIndent` |
| 437 | create or replace function schema_checks() |
| 438 | returns jsonb |
| 439 | language plpgsql |
| 440 | as $$ |
| 441 | declare |
| 442 | ret jsonb; |
| 443 | begin |
| 444 | select jsonb_build_object('value', 'ok') |
| 445 | into ret; |
| 446 | return ret; |
| 447 | end; |
| 448 | $$; |
| 449 | ` |
| 450 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 451 | }) |
| 452 | |
| 453 | it('does not flag `select ... into var` inside a DO block', () => { |
| 454 | const sql = stripIndent` |
| 455 | do $$ |
| 456 | declare |
| 457 | result int; |
| 458 | begin |
| 459 | select count(*) into result from information_schema.tables; |
| 460 | end |
| 461 | $$; |
| 462 | ` |
| 463 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 464 | }) |
| 465 | |
| 466 | it('does not flag CREATE TABLE text that appears inside a function body', () => { |
| 467 | const sql = stripIndent` |
| 468 | create or replace function noop() |
| 469 | returns void |
| 470 | language plpgsql |
| 471 | as $$ |
| 472 | begin |
| 473 | -- create table foo (id int); |
| 474 | perform 1; |
| 475 | end; |
| 476 | $$; |
| 477 | ` |
| 478 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 479 | }) |
| 480 | |
| 481 | it('flags top-level CREATE TABLE alongside a function with INTO assignments', () => { |
| 482 | const sql = stripIndent` |
| 483 | create table public.foo (id int8 primary key); |
| 484 | create or replace function bar() |
| 485 | returns int |
| 486 | language plpgsql |
| 487 | as $$ |
| 488 | declare |
| 489 | v int; |
| 490 | begin |
| 491 | select 1 into v; |
| 492 | return v; |
| 493 | end; |
| 494 | $$; |
| 495 | ` |
| 496 | const result = getCreateTablesMissingRLS(sql) |
| 497 | expect(result).toEqual([{ schema: 'public', tableName: 'foo' }]) |
| 498 | }) |
| 499 | |
| 500 | it('does not flag CREATE TABLE inside nested dollar-quoted dynamic SQL', () => { |
| 501 | // Regression: the `$sql$...$sql$` block inside the outer `$fn$...$fn$` |
| 502 | // body was previously pairing with the outer tag, letting the inner |
| 503 | // semicolon split the statement and exposing `create table fake` to the |
| 504 | // RLS warning. |
| 505 | const sql = stripIndent` |
| 506 | create function f() |
| 507 | returns void |
| 508 | language plpgsql |
| 509 | as $fn$ |
| 510 | begin |
| 511 | execute $sql$create table fake(id int);$sql$; |
| 512 | end; |
| 513 | $fn$; |
| 514 | ` |
| 515 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 516 | }) |
| 517 | |
| 518 | it('handles custom dollar-quote tags (e.g. $body$...$body$)', () => { |
| 519 | const sql = stripIndent` |
| 520 | create or replace function f() |
| 521 | returns int |
| 522 | language plpgsql |
| 523 | as $body$ |
| 524 | declare |
| 525 | v int; |
| 526 | begin |
| 527 | select 1 into v; |
| 528 | return v; |
| 529 | end; |
| 530 | $body$; |
| 531 | ` |
| 532 | expect(getCreateTablesMissingRLS(sql)).toEqual([]) |
| 533 | }) |
| 534 | |
| 535 | it('does not collide quoted identifiers that differ only by case', () => { |
| 536 | // "MyTable" and "mytable" are distinct tables in Postgres, so the ALTER |
| 537 | // here targets a different table than the CREATE — the warning must fire. |
| 538 | const sql = stripIndent` |
| 539 | create table "MyTable" (id int8 primary key); |
| 540 | alter table "mytable" enable row level security; |
| 541 | ` |
| 542 | const result = getCreateTablesMissingRLS(sql) |
| 543 | expect(result).toHaveLength(1) |
| 544 | expect(result[0].tableName).toBe('MyTable') |
| 545 | }) |
| 546 | }) |
| 547 | |
| 548 | describe('SQLEditor.utils:appendEnableRLSStatements', () => { |
| 549 | it('appends a single ALTER TABLE ENABLE RLS statement', () => { |
| 550 | const result = appendEnableRLSStatements('create table foo (id int8 primary key);', [ |
| 551 | { tableName: 'foo' }, |
| 552 | ]) |
| 553 | expect(result).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;') |
| 554 | }) |
| 555 | |
| 556 | it('appends one ALTER per table', () => { |
| 557 | const result = appendEnableRLSStatements( |
| 558 | 'create table foo (id int8); create table bar (id int8);', |
| 559 | [{ tableName: 'foo' }, { tableName: 'bar' }] |
| 560 | ) |
| 561 | expect(result).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;') |
| 562 | expect(result).toContain('ALTER TABLE bar ENABLE ROW LEVEL SECURITY;') |
| 563 | }) |
| 564 | |
| 565 | it('schema-qualifies the table when schema is provided', () => { |
| 566 | const result = appendEnableRLSStatements('create table public.foo (id int8);', [ |
| 567 | { schema: 'public', tableName: 'foo' }, |
| 568 | ]) |
| 569 | expect(result).toContain('ALTER TABLE public.foo ENABLE ROW LEVEL SECURITY;') |
| 570 | }) |
| 571 | |
| 572 | it('quotes identifiers that are not simple', () => { |
| 573 | const result = appendEnableRLSStatements('create table "My Table" (id int8);', [ |
| 574 | { tableName: 'My Table' }, |
| 575 | ]) |
| 576 | expect(result).toContain('ALTER TABLE "My Table" ENABLE ROW LEVEL SECURITY;') |
| 577 | }) |
| 578 | |
| 579 | it('quotes mixed-case identifiers so Postgres does not fold them to lowercase', () => { |
| 580 | const result = appendEnableRLSStatements('create table "MyTable" (id int8);', [ |
| 581 | { tableName: 'MyTable' }, |
| 582 | ]) |
| 583 | expect(result).toContain('ALTER TABLE "MyTable" ENABLE ROW LEVEL SECURITY;') |
| 584 | }) |
| 585 | |
| 586 | it('quotes mixed-case schema and table identifiers', () => { |
| 587 | const result = appendEnableRLSStatements('create table "MySchema"."MyTable" (id int8);', [ |
| 588 | { schema: 'MySchema', tableName: 'MyTable' }, |
| 589 | ]) |
| 590 | expect(result).toContain('ALTER TABLE "MySchema"."MyTable" ENABLE ROW LEVEL SECURITY;') |
| 591 | }) |
| 592 | |
| 593 | it('returns the original SQL unchanged when there are no tables', () => { |
| 594 | const sql = 'select 1;' |
| 595 | expect(appendEnableRLSStatements(sql, [])).toBe(sql) |
| 596 | }) |
| 597 | |
| 598 | it('puts the terminator on its own line when SQL ends with a line comment', () => { |
| 599 | // Without this, the appended ';' would be swallowed by the line comment and |
| 600 | // the following ALTER TABLE would be parsed as part of the CREATE TABLE. |
| 601 | const sql = stripIndent` |
| 602 | create table foo (id int) |
| 603 | -- forgot the semicolon |
| 604 | ` |
| 605 | const result = appendEnableRLSStatements(sql, [{ tableName: 'foo' }]) |
| 606 | expect(result).toMatch(/-- forgot the semicolon\n;\n/) |
| 607 | expect(result).toContain('ALTER TABLE foo ENABLE ROW LEVEL SECURITY;') |
| 608 | }) |
| 609 | }) |
| 610 | |
| 611 | describe('SQLEditor.utils:hasActiveEnsureRLSTrigger', () => { |
| 612 | it('returns false for undefined triggers', () => { |
| 613 | expect(hasActiveEnsureRLSTrigger(undefined)).toBe(false) |
| 614 | }) |
| 615 | |
| 616 | it('returns false for an empty list', () => { |
| 617 | expect(hasActiveEnsureRLSTrigger([])).toBe(false) |
| 618 | }) |
| 619 | |
| 620 | it('returns true when a trigger named "ensure_rls" is active', () => { |
| 621 | expect(hasActiveEnsureRLSTrigger([buildTrigger()])).toBe(true) |
| 622 | }) |
| 623 | |
| 624 | it('returns true when a trigger uses the rls_auto_enable function (renamed trigger)', () => { |
| 625 | expect( |
| 626 | hasActiveEnsureRLSTrigger([ |
| 627 | buildTrigger({ name: 'something_else', function_name: 'rls_auto_enable' }), |
| 628 | ]) |
| 629 | ).toBe(true) |
| 630 | }) |
| 631 | |
| 632 | it('returns false when the matching trigger is DISABLED', () => { |
| 633 | expect(hasActiveEnsureRLSTrigger([buildTrigger({ enabled_mode: 'DISABLED' })])).toBe(false) |
| 634 | }) |
| 635 | |
| 636 | it('ignores unrelated triggers', () => { |
| 637 | expect( |
| 638 | hasActiveEnsureRLSTrigger([buildTrigger({ name: 'audit_log', function_name: 'log_changes' })]) |
| 639 | ).toBe(false) |
| 640 | }) |
| 641 | }) |
| 642 | |
| 643 | describe('SQLEditor.utils:filterTablesCoveredByEnsureRLSTrigger', () => { |
| 644 | it('returns the input unchanged when the trigger is not present', () => { |
| 645 | const tables = [{ tableName: 'foo' }, { schema: 'private', tableName: 'bar' }] |
| 646 | expect(filterTablesCoveredByEnsureRLSTrigger(tables, false)).toEqual(tables) |
| 647 | }) |
| 648 | |
| 649 | it('drops public-schema tables when the trigger is present', () => { |
| 650 | const tables = [ |
| 651 | { schema: 'public', tableName: 'foo' }, |
| 652 | { tableName: 'bar' }, // no schema → defaults to public |
| 653 | ] |
| 654 | expect(filterTablesCoveredByEnsureRLSTrigger(tables, true)).toEqual([]) |
| 655 | }) |
| 656 | |
| 657 | it('keeps tables in non-public schemas when the trigger is present', () => { |
| 658 | const tables = [ |
| 659 | { schema: 'public', tableName: 'foo' }, |
| 660 | { schema: 'private', tableName: 'bar' }, |
| 661 | { schema: 'app', tableName: 'baz' }, |
| 662 | ] |
| 663 | expect(filterTablesCoveredByEnsureRLSTrigger(tables, true)).toEqual([ |
| 664 | { schema: 'private', tableName: 'bar' }, |
| 665 | { schema: 'app', tableName: 'baz' }, |
| 666 | ]) |
| 667 | }) |
| 668 | |
| 669 | it('matches the public schema case-insensitively', () => { |
| 670 | const tables = [{ schema: 'PUBLIC', tableName: 'foo' }] |
| 671 | expect(filterTablesCoveredByEnsureRLSTrigger(tables, true)).toEqual([]) |
| 672 | }) |
| 673 | }) |
| 674 | |
| 675 | describe('SQLEditor.utils:checkAlterDatabaseConnection', () => { |
| 676 | it('detects connection limit 0', () => { |
| 677 | const match = checkAlterDatabaseConnection('alter database postgres connection limit 0;') |
| 678 | expect(match).toBe(true) |
| 679 | }) |
| 680 | |
| 681 | it('detects allow_connections false', () => { |
| 682 | const match = checkAlterDatabaseConnection('alter database postgres allow_connections false;') |
| 683 | expect(match).toBe(true) |
| 684 | }) |
| 685 | |
| 686 | it('detects case-insensitive match', () => { |
| 687 | const match = checkAlterDatabaseConnection('ALTER DATABASE postgres CONNECTION LIMIT 0;') |
| 688 | expect(match).toBe(true) |
| 689 | }) |
| 690 | |
| 691 | it('detects statement among multiple statements', () => { |
| 692 | const match = checkAlterDatabaseConnection(stripIndent` |
| 693 | select * from countries; |
| 694 | alter database postgres connection limit 0; |
| 695 | `) |
| 696 | expect(match).toBe(true) |
| 697 | }) |
| 698 | |
| 699 | it('does not flag unrelated alter database statement', () => { |
| 700 | const match = checkAlterDatabaseConnection( |
| 701 | 'alter database postgres set statement_timeout = 60000;' |
| 702 | ) |
| 703 | expect(match).toBe(false) |
| 704 | }) |
| 705 | |
| 706 | it('does not flag non-alter statements', () => { |
| 707 | const match = checkAlterDatabaseConnection('select * from countries;') |
| 708 | expect(match).toBe(false) |
| 709 | }) |
| 710 | |
| 711 | it('ignores statements inside comments', () => { |
| 712 | const match = checkAlterDatabaseConnection(stripIndent` |
| 713 | -- alter database postgres connection limit 0; |
| 714 | select 1; |
| 715 | `) |
| 716 | expect(match).toBe(false) |
| 717 | }) |
| 718 | |
| 719 | it('detects both dangerous statements in same query', () => { |
| 720 | const match = checkAlterDatabaseConnection(stripIndent` |
| 721 | alter database postgres connection limit 0; |
| 722 | alter database postgres allow_connections false; |
| 723 | `) |
| 724 | expect(match).toBe(true) |
| 725 | }) |
| 726 | }) |