mirror of
https://github.com/supabase/supabase.git
synced 2026-05-06 08:56:46 -04:00
2555e81dde
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>
537 lines
18 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
})
|