diff --git a/HISTORY.asc b/HISTORY.asc index 7e6369e..31138df 100644 --- a/HISTORY.asc +++ b/HISTORY.asc @@ -6,6 +6,27 @@ extension_drop depends on cat_tools, which already requires PostgreSQL too (a cat_tools function it calls needs PG11+ internally) -- this just makes the declared floor honest. +== Run drop commands when `DROP SCHEMA ... CASCADE` or `DROP OWNED BY` drops an extension +The event trigger now fires for `DROP SCHEMA` and `DROP OWNED` as well as +`DROP EXTENSION`, and returns immediately unless an extension is among the +dropped objects, so other drops are unaffected. Previously the other two +commands skipped the registered SQL and left a stale registration that made +every later `DROP EXTENSION` fail with `XD001`; remove any existing stale +registration with `extension_drop__repair()`. + +Dropping an unregistered extension (by any of the three commands) needs only +USAGE on the extension's schema, to resolve the new `SECURITY DEFINER` +function `extension_drop__registered()` (executable by `PUBLIC`), which the +trigger asks which of the dropped extensions are registered; it needs no +privileges on `extension_drop__commands`. Dropping a registered extension +additionally needs SELECT and DELETE on that table. A role without the access +gets an `insufficient_privilege` error that names the table, its owner and +the extensions being dropped, with the underlying error as DETAIL and a HINT +giving the grants that fix it, instead of `permission denied for table +extension_drop__commands` or `type "extension_drop__commands" does not +exist`. The registered SQL runs as the caller and may need further +privileges. + == Remove redundant client_min_messages handling from install script `CREATE EXTENSION` already raises `client_min_messages` to `WARNING` for the install script (only-raising, so a stricter caller is respected) and diff --git a/README.md b/README.md index 13aecec..5106949 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,13 @@ arbitrary SQL to run later. Grant them to specific roles as needed. Look up the row (`extension_name`, `sql`) registered for `extension_name`. Granted to `PUBLIC` by default. +- `extension_drop__registered(extension_names name[]) RETURNS name[]` + Returns those of `extension_names` that have registered drop commands, and + asserts the table is sane when there are none. `SECURITY DEFINER` (with + `search_path` pinned to the extension's schema, then `pg_temp`); used by + the event trigger so dropping an unregistered extension needs no table + access. Granted to `PUBLIC` by default. + - `extension_drop__sanity_check(ignore name DEFAULT NULL) RETURNS name[]` Returns the names of extensions that have registered drop commands but no longer exist. Should always return an empty array; pass an extension name @@ -42,6 +49,19 @@ arbitrary SQL to run later. Grant them to specific roles as needed. ## How it works An event trigger (`extension_drop`, on `sql_drop`) fires whenever an -extension is dropped, runs any SQL registered for it via -`extension_drop__add`/`extension_drop__update`, and removes the registration -afterward. +extension is dropped -- by `DROP EXTENSION`, `DROP SCHEMA ... CASCADE` or +`DROP OWNED BY` -- removes its registration from `extension_drop__commands` +and runs the SQL registered for it via +`extension_drop__add`/`extension_drop__update`. Those commands fire the +trigger for every drop, but it returns immediately unless an extension is +among the dropped objects. + +Dropping an unregistered extension needs only USAGE on the extension's +schema (to resolve `extension_drop__registered()`, a `SECURITY DEFINER` +function executable by `PUBLIC`, which the trigger asks which of the dropped +extensions are registered); it needs no privileges on `extension_drop__commands`. +Dropping a registered extension additionally needs SELECT and DELETE on that +table. A role without the access gets an `insufficient_privilege` error that +names the table, its owner and the extensions being dropped, with the +underlying error as DETAIL and a HINT giving the grants that fix it. The +registered SQL runs as the caller and may need further privileges. diff --git a/sql/extension_drop--0.1.1--stable.sql b/sql/extension_drop--0.1.1--stable.sql index b011c62..bd552f0 100644 --- a/sql/extension_drop--0.1.1--stable.sql +++ b/sql/extension_drop--0.1.1--stable.sql @@ -1,20 +1,117 @@ +/* + * The install script's create_function doesn't exist here, so build the helper directly; see + * extension_drop.sql for why it is SECURITY DEFINER with pg_temp last on its search_path. + */ +DO $do$ +DECLARE + sp text; +BEGIN + SELECT pg_catalog.format('%I', n.nspname) INTO sp + FROM pg_catalog.pg_extension e JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'extension_drop'; + EXECUTE pg_catalog.format($f$ +CREATE OR REPLACE FUNCTION extension_drop__registered( + extension_names name[] +) RETURNS name[] LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = %s, pg_temp AS +$body$ +DECLARE + ret name[] := array( + SELECT extension_name FROM extension_drop__commands WHERE extension_name = ANY(extension_names) ORDER BY 1 + ); +BEGIN + -- With nothing registered the trigger returns without its own assert, so any stray row is a stale one. + IF ret = '{}'::name[] THEN + PERFORM extension_drop__sanity_assert(); + END IF; + RETURN ret; +END +$body$ +$f$, sp); +END +$do$; +REVOKE ALL ON FUNCTION extension_drop__registered(name[]) FROM public; +GRANT EXECUTE ON FUNCTION extension_drop__registered(name[]) TO public; +COMMENT ON FUNCTION extension_drop__registered(name[]) IS + $$Returns the given extensions that have drop commands, after asserting the table is sane if there are none.$$; + CREATE OR REPLACE FUNCTION extension_drop__event_trigger( ) RETURNS event_trigger LANGUAGE plpgsql SET search_path FROM CURRENT AS $body$ DECLARE - r extension_drop__commands; + t record; + dropping text; + dropped name[]; + registered name[]; + names text[]; + cmds text[]; + i int; + msg text; BEGIN RAISE DEBUG 'extension_drop event trigger entry: tg_event %, tg_tag %', tg_event, tg_tag; - FOR r IN - SELECT c.* - FROM extension_drop__commands c - JOIN pg_event_trigger_dropped_objects() d - ON c.extension_name = d.object_name - AND d.object_type = 'extension' - LOOP - RAISE DEBUG E'extension "%" is being dropped; executing SQL:\n%', r.extension_name, r.sql; - EXECUTE r.sql; - DELETE FROM extension_drop__commands WHERE extension_name = r.extension_name; + + -- Most drops involve no extension; don't touch our table (or assert) for them. + SELECT pg_catalog.string_agg(pg_catalog.quote_ident(object_name), ', ' ORDER BY object_name) + , pg_catalog.array_agg(object_name::name ORDER BY object_name) + INTO dropping, dropped + FROM pg_catalog.pg_event_trigger_dropped_objects() + WHERE object_type = 'extension'; + IF dropping IS NULL THEN + RETURN; + END IF; + + /* + * Callers without table access can still drop unregistered extensions. Without USAGE on our schema the + * helper isn't found; fall through to the DELETE below for its clearer error. + */ + BEGIN + registered := extension_drop__registered(dropped); + EXCEPTION WHEN undefined_function OR insufficient_privilege THEN + registered := NULL; + END; + IF registered = '{}'::name[] THEN + RETURN; + END IF; + + /* + * One statement touches the table, so a privilege error here is ours; registered SQL runs after the + * handler so its own errors pass through untouched. + */ + BEGIN + WITH del AS ( + DELETE FROM extension_drop__commands c + USING pg_catalog.pg_event_trigger_dropped_objects() d + WHERE c.extension_name = d.object_name AND d.object_type = 'extension' + RETURNING c.extension_name, c.sql + ) + SELECT pg_catalog.array_agg(extension_name), pg_catalog.array_agg(sql) INTO names, cmds FROM del; + EXCEPTION WHEN insufficient_privilege OR undefined_table THEN + -- undefined_table: name lookup skips a schema the caller lacks USAGE on, so the table "doesn't exist". + GET STACKED DIAGNOSTICS msg = MESSAGE_TEXT; + + -- Look up the table by OID path, not by name, for the same reason. + SELECT n.nspname, c.relname, pg_catalog.format('%I.%I', n.nspname, c.relname) AS qname, pg_catalog.pg_get_userbyid(c.relowner) AS owner + INTO t + FROM pg_catalog.pg_extension e + JOIN pg_catalog.pg_class c + ON c.relnamespace = e.extnamespace AND c.relname = 'extension_drop__commands' + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE e.extname = 'extension_drop'; + IF NOT FOUND THEN + RAISE; + END IF; + + RAISE EXCEPTION 'permission denied for table "%"."%" (owned by role "%"), which extension_drop needs to drop extension(s) %' + , t.nspname, t.relname, t.owner, dropping + USING ERRCODE = 'insufficient_privilege' + , DETAIL = msg + , HINT = pg_catalog.format( + 'Run the drop as role %s or a superuser, or grant USAGE on schema %s and SELECT, DELETE on %s to the current role.' + , pg_catalog.quote_ident(t.owner), pg_catalog.quote_ident(t.nspname), t.qname); + END; + + FOR i IN 1 .. COALESCE(pg_catalog.array_length(cmds, 1), 0) LOOP + RAISE DEBUG E'extension "%" is being dropped; executing SQL:\n%', names[i], cmds[i]; + EXECUTE cmds[i]; END LOOP; /* @@ -25,4 +122,12 @@ BEGIN END $body$; +-- ALTER EVENT TRIGGER can't change WHEN +DROP EVENT TRIGGER extension_drop; +CREATE EVENT TRIGGER extension_drop + ON sql_drop + WHEN tag IN( 'DROP EXTENSION', 'DROP SCHEMA', 'DROP OWNED' ) -- NOTE! This MUST be IN + EXECUTE PROCEDURE extension_drop__event_trigger() +; + -- vim: sw=2 ts=2 expandtab diff --git a/sql/extension_drop.sql b/sql/extension_drop.sql index 75319be..5987f05 100644 --- a/sql/extension_drop.sql +++ b/sql/extension_drop.sql @@ -37,10 +37,24 @@ CREATE FUNCTION __extension_drop.create_function( DECLARE c_clean_args text := cat_tools.routine__parse_arg_types_text(args); + -- A definer function must not search pg_temp ahead of its own schema, so pin the path explicitly. + search_path_clause text := CASE + WHEN options ~* 'security\s+definer' THEN COALESCE( + ( + SELECT pg_catalog.format('= %I, pg_temp', n.nspname) + FROM pg_catalog.pg_extension e JOIN pg_catalog.pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'extension_drop' + ) + -- Not running as an extension script (e.g. the build smoke test) + , pg_catalog.format('= %I, pg_temp', pg_catalog.current_schema()) + ) + ELSE 'FROM CURRENT' + END; + create_template CONSTANT text := $template$ CREATE OR REPLACE FUNCTION %s( %s -) RETURNS %s SET search_path FROM CURRENT AS +) RETURNS %s SET search_path %s AS %L $template$ ; @@ -71,7 +85,8 @@ BEGIN create_template , function_name , args - , options -- TODO: Force search_path if options ~* 'definer' + , options + , search_path_clause , body ) ) ; @@ -255,6 +270,37 @@ $body$ , '' -- Just revoke all access ); +/* + * REGISTERED + */ +/* + * SECURITY DEFINER so the trigger can tell whether a dropped extension is registered without the caller + * needing table access; reads only, never runs registered SQL. The path is pinned with pg_temp last + * (see create_function) so a caller's temp table can't shadow ours. + */ +SELECT __extension_drop.create_function( + 'extension_drop__registered' + , $$ + extension_names name[] +$$ + , 'name[] LANGUAGE plpgsql STABLE SECURITY DEFINER' + , $body$ +DECLARE + ret name[] := array( + SELECT extension_name FROM extension_drop__commands WHERE extension_name = ANY(extension_names) ORDER BY 1 + ); +BEGIN + -- With nothing registered the trigger returns without its own assert, so any stray row is a stale one. + IF ret = '{}'::name[] THEN + PERFORM extension_drop__sanity_assert(); + END IF; + RETURN ret; +END +$body$ + , $$Returns the given extensions that have drop commands, after asserting the table is sane if there are none.$$ + , 'public' +); + /* * TRIGGER FUNCTION */ @@ -264,19 +310,80 @@ SELECT __extension_drop.create_function( , 'event_trigger LANGUAGE plpgsql' , $body$ DECLARE - r extension_drop__commands; + t record; + dropping text; + dropped name[]; + registered name[]; + names text[]; + cmds text[]; + i int; + msg text; BEGIN RAISE DEBUG 'extension_drop event trigger entry: tg_event %, tg_tag %', tg_event, tg_tag; - FOR r IN - SELECT c.* - FROM extension_drop__commands c - JOIN pg_event_trigger_dropped_objects() d - ON c.extension_name = d.object_name - AND d.object_type = 'extension' - LOOP - RAISE DEBUG E'extension "%" is being dropped; executing SQL:\n%', r.extension_name, r.sql; - EXECUTE r.sql; - DELETE FROM extension_drop__commands WHERE extension_name = r.extension_name; + + -- Most drops involve no extension; don't touch our table (or assert) for them. + SELECT pg_catalog.string_agg(pg_catalog.quote_ident(object_name), ', ' ORDER BY object_name) + , pg_catalog.array_agg(object_name::name ORDER BY object_name) + INTO dropping, dropped + FROM pg_catalog.pg_event_trigger_dropped_objects() + WHERE object_type = 'extension'; + IF dropping IS NULL THEN + RETURN; + END IF; + + /* + * Callers without table access can still drop unregistered extensions. Without USAGE on our schema the + * helper isn't found; fall through to the DELETE below for its clearer error. + */ + BEGIN + registered := extension_drop__registered(dropped); + EXCEPTION WHEN undefined_function OR insufficient_privilege THEN + registered := NULL; + END; + IF registered = '{}'::name[] THEN + RETURN; + END IF; + + /* + * One statement touches the table, so a privilege error here is ours; registered SQL runs after the + * handler so its own errors pass through untouched. + */ + BEGIN + WITH del AS ( + DELETE FROM extension_drop__commands c + USING pg_catalog.pg_event_trigger_dropped_objects() d + WHERE c.extension_name = d.object_name AND d.object_type = 'extension' + RETURNING c.extension_name, c.sql + ) + SELECT pg_catalog.array_agg(extension_name), pg_catalog.array_agg(sql) INTO names, cmds FROM del; + EXCEPTION WHEN insufficient_privilege OR undefined_table THEN + -- undefined_table: name lookup skips a schema the caller lacks USAGE on, so the table "doesn't exist". + GET STACKED DIAGNOSTICS msg = MESSAGE_TEXT; + + -- Look up the table by OID path, not by name, for the same reason. + SELECT n.nspname, c.relname, pg_catalog.format('%I.%I', n.nspname, c.relname) AS qname, pg_catalog.pg_get_userbyid(c.relowner) AS owner + INTO t + FROM pg_catalog.pg_extension e + JOIN pg_catalog.pg_class c + ON c.relnamespace = e.extnamespace AND c.relname = 'extension_drop__commands' + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE e.extname = 'extension_drop'; + IF NOT FOUND THEN + RAISE; + END IF; + + RAISE EXCEPTION 'permission denied for table "%"."%" (owned by role "%"), which extension_drop needs to drop extension(s) %' + , t.nspname, t.relname, t.owner, dropping + USING ERRCODE = 'insufficient_privilege' + , DETAIL = msg + , HINT = pg_catalog.format( + 'Run the drop as role %s or a superuser, or grant USAGE on schema %s and SELECT, DELETE on %s to the current role.' + , pg_catalog.quote_ident(t.owner), pg_catalog.quote_ident(t.nspname), t.qname); + END; + + FOR i IN 1 .. COALESCE(pg_catalog.array_length(cmds, 1), 0) LOOP + RAISE DEBUG E'extension "%" is being dropped; executing SQL:\n%', names[i], cmds[i]; + EXECUTE cmds[i]; END LOOP; /* @@ -291,7 +398,8 @@ $body$ CREATE EVENT TRIGGER extension_drop ON sql_drop - WHEN tag IN( 'DROP EXTENSION' ) -- NOTE! This MUST be IN + -- DROP SCHEMA CASCADE and DROP OWNED BY also drop extensions. + WHEN tag IN( 'DROP EXTENSION', 'DROP SCHEMA', 'DROP OWNED' ) -- NOTE! This MUST be IN EXECUTE PROCEDURE extension_drop__event_trigger() ; diff --git a/test/build/expected/build.out b/test/build/expected/build.out index 44215eb..c66ee3c 100644 --- a/test/build/expected/build.out +++ b/test/build/expected/build.out @@ -19,4 +19,6 @@ INSTALL + + # TRANSACTION INTENTIONALLY LEFT OPEN diff --git a/test/expected/drop_denied.out b/test/expected/drop_denied.out new file mode 100644 index 0000000..25f61f4 --- /dev/null +++ b/test/expected/drop_denied.out @@ -0,0 +1,63 @@ +\set ECHO none +1..60 +ok 1 - Create non-superuser role +ok 2 - Create schema owned by role containing a registered extension +ok 3 - Registered: DROP SCHEMA CASCADE without USAGE on extension_drop's schema +ok 4 - Capture DETAIL and HINT +ok 5 - DETAIL +ok 6 - HINT +ok 7 - Grant USAGE on extension_drop's schema +ok 8 - Registered: DROP SCHEMA CASCADE with USAGE but no table privileges +ok 9 - Capture DETAIL +ok 10 - DETAIL is the original error +ok 11 - Grant SELECT +ok 12 - Registered: DROP SCHEMA CASCADE with SELECT but not DELETE +ok 13 - Grant DELETE, register SQL that needs CREATEROLE +ok 14 - Registered SQL's own privilege error surfaces unchanged +ok 15 - Register harmless SQL +ok 16 - Registered: DROP SCHEMA CASCADE with the privileges the HINT names +ok 17 - No stale registration +ok 18 - Registration removed +ok 19 - Recreate the registered extension +ok 20 - Move the grants from the role to a group it belongs to +ok 21 - Registered: DROP SCHEMA CASCADE with the privileges held through a group role +ok 22 - Remove all of the role's access +ok 23 - Recreate the extension +ok 24 - Unregister the extension +ok 25 - Unregistered: DROP SCHEMA CASCADE without USAGE on extension_drop's schema +ok 26 - Grant USAGE on extension_drop's schema +ok 27 - Unregistered: DROP SCHEMA CASCADE with USAGE but no table privileges +ok 28 - Recreate the extension +ok 29 - Unregister the extension +ok 30 - Unregistered: DROP OWNED BY with USAGE but no table privileges +ok 31 - Recreate the extension +ok 32 - Unregister the extension +ok 33 - Grant SELECT +ok 34 - Unregistered: DROP SCHEMA CASCADE with SELECT but not DELETE +ok 35 - Revoke SELECT +ok 36 - Recreate the extension +ok 37 - Unregister the extension +ok 38 - Revoke EXECUTE on the helper +ok 39 - Unregistered: DROP SCHEMA CASCADE without EXECUTE on the helper or table privileges +ok 40 - Restore EXECUTE on the helper +ok 41 - Superuser drop schema +ok 42 - Recreate the registered extension +ok 43 - Registered extension is still found when the caller has a temp table of the same name +ok 44 - Unregister the extension +ok 45 - Superuser drop schema +ok 46 - Recreate the extension +ok 47 - Unregister the extension +ok 48 - Insert a stale registration +ok 49 - Unregistered: DROP SCHEMA CASCADE with a stale registration present +ok 50 - Repair +ok 51 - Superuser drop schema +ok 52 - Create role +ok 53 - Create test extension as role +ok 54 - Demote role +ok 55 - DROP EXTENSION by the extension's non-superuser owner +ok 56 - Unregister the extension +ok 57 - Grant USAGE on extension_drop's schema +ok 58 - Unregistered: DROP EXTENSION by the extension's non-superuser owner with USAGE but no table privileges +ok 59 - No stale registration +ok 60 - extension_drop's schema should not be reachable via search_path +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/drop_denied_1.out b/test/expected/drop_denied_1.out new file mode 100644 index 0000000..741f476 --- /dev/null +++ b/test/expected/drop_denied_1.out @@ -0,0 +1,63 @@ +\set ECHO none +1..60 +ok 1 - Create non-superuser role +ok 2 - Create schema owned by role containing a registered extension +ok 3 - Registered: DROP SCHEMA CASCADE without USAGE on extension_drop's schema +ok 4 - Capture DETAIL and HINT +ok 5 - DETAIL +ok 6 - HINT +ok 7 - Grant USAGE on extension_drop's schema +ok 8 - Registered: DROP SCHEMA CASCADE with USAGE but no table privileges +ok 9 - Capture DETAIL +ok 10 - DETAIL is the original error +ok 11 - Grant SELECT +ok 12 - Registered: DROP SCHEMA CASCADE with SELECT but not DELETE +ok 13 - Grant DELETE, register SQL that needs CREATEROLE +ok 14 - Registered SQL's own privilege error surfaces unchanged +ok 15 - Register harmless SQL +ok 16 - Registered: DROP SCHEMA CASCADE with the privileges the HINT names +ok 17 - No stale registration +ok 18 - Registration removed +ok 19 - Recreate the registered extension +ok 20 - Move the grants from the role to a group it belongs to +ok 21 - Registered: DROP SCHEMA CASCADE with the privileges held through a group role +ok 22 - Remove all of the role's access +ok 23 - Recreate the extension +ok 24 - Unregister the extension +ok 25 # SKIP existing mode: the role can use extension_drop's schema +ok 26 - Grant USAGE on extension_drop's schema +ok 27 - Unregistered: DROP SCHEMA CASCADE with USAGE but no table privileges +ok 28 - Recreate the extension +ok 29 - Unregister the extension +ok 30 - Unregistered: DROP OWNED BY with USAGE but no table privileges +ok 31 - Recreate the extension +ok 32 - Unregister the extension +ok 33 - Grant SELECT +ok 34 - Unregistered: DROP SCHEMA CASCADE with SELECT but not DELETE +ok 35 - Revoke SELECT +ok 36 - Recreate the extension +ok 37 - Unregister the extension +ok 38 - Revoke EXECUTE on the helper +ok 39 - Unregistered: DROP SCHEMA CASCADE without EXECUTE on the helper or table privileges +ok 40 - Restore EXECUTE on the helper +ok 41 - Superuser drop schema +ok 42 - Recreate the registered extension +ok 43 - Registered extension is still found when the caller has a temp table of the same name +ok 44 - Unregister the extension +ok 45 - Superuser drop schema +ok 46 - Recreate the extension +ok 47 - Unregister the extension +ok 48 - Insert a stale registration +ok 49 - Unregistered: DROP SCHEMA CASCADE with a stale registration present +ok 50 - Repair +ok 51 - Superuser drop schema +ok 52 - Create role +ok 53 - Create test extension as role +ok 54 - Demote role +ok 55 - DROP EXTENSION by the extension's non-superuser owner +ok 56 - Unregister the extension +ok 57 - Grant USAGE on extension_drop's schema +ok 58 - Unregistered: DROP EXTENSION by the extension's non-superuser owner with USAGE but no table privileges +ok 59 - No stale registration +ok 60 # SKIP existing mode: extension_drop's placement is outside this suite's control +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/drop_owned.out b/test/expected/drop_owned.out new file mode 100644 index 0000000..36d377c --- /dev/null +++ b/test/expected/drop_owned.out @@ -0,0 +1,16 @@ +\set ECHO none +1..13 +ok 1 - Insert into extension_drop_test_table +ok 2 - Create role +ok 3 - Create test extension as role +ok 4 - Verify extension_drop__get() +ok 5 - Drop owned +ok 6 - Test extension does not exist +ok 7 - extension_drop_test_table is empty +ok 8 - No stale registration +ok 9 - Create test extension +ok 10 - Test extension exists +ok 11 - Drop test extension +ok 12 - Test extension does not exist +ok 13 - extension_drop's schema should not be reachable via search_path +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/drop_owned_1.out b/test/expected/drop_owned_1.out new file mode 100644 index 0000000..3801831 --- /dev/null +++ b/test/expected/drop_owned_1.out @@ -0,0 +1,16 @@ +\set ECHO none +1..13 +ok 1 - Insert into extension_drop_test_table +ok 2 - Create role +ok 3 - Create test extension as role +ok 4 - Verify extension_drop__get() +ok 5 - Drop owned +ok 6 - Test extension does not exist +ok 7 - extension_drop_test_table is empty +ok 8 - No stale registration +ok 9 - Create test extension +ok 10 - Test extension exists +ok 11 - Drop test extension +ok 12 - Test extension does not exist +ok 13 # SKIP existing mode: extension_drop's placement is outside this suite's control +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/drop_schema_cascade.out b/test/expected/drop_schema_cascade.out new file mode 100644 index 0000000..e280978 --- /dev/null +++ b/test/expected/drop_schema_cascade.out @@ -0,0 +1,16 @@ +\set ECHO none +1..13 +ok 1 - Insert into extension_drop_test_table +ok 2 - Create schema +ok 3 - Create test extension in schema +ok 4 - Verify extension_drop__get() +ok 5 - Drop schema cascade +ok 6 - Test extension does not exist +ok 7 - extension_drop_test_table is empty +ok 8 - No stale registration +ok 9 - Create test extension +ok 10 - Test extension exists +ok 11 - Drop test extension +ok 12 - Test extension does not exist +ok 13 - extension_drop's schema should not be reachable via search_path +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/drop_schema_cascade_1.out b/test/expected/drop_schema_cascade_1.out new file mode 100644 index 0000000..a634d57 --- /dev/null +++ b/test/expected/drop_schema_cascade_1.out @@ -0,0 +1,16 @@ +\set ECHO none +1..13 +ok 1 - Insert into extension_drop_test_table +ok 2 - Create schema +ok 3 - Create test extension in schema +ok 4 - Verify extension_drop__get() +ok 5 - Drop schema cascade +ok 6 - Test extension does not exist +ok 7 - extension_drop_test_table is empty +ok 8 - No stale registration +ok 9 - Create test extension +ok 10 - Test extension exists +ok 11 - Drop test extension +ok 12 - Test extension does not exist +ok 13 # SKIP existing mode: extension_drop's placement is outside this suite's control +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/unrelated_drops.out b/test/expected/unrelated_drops.out new file mode 100644 index 0000000..fdf0059 --- /dev/null +++ b/test/expected/unrelated_drops.out @@ -0,0 +1,10 @@ +\set ECHO none +1..7 +ok 1 - Create non-superuser role +ok 2 - Non-superuser DROP SCHEMA CASCADE +ok 3 - Non-superuser DROP OWNED BY +ok 4 - Insert stale registration +ok 5 - Stale registration is present +ok 6 - Superuser DROP SCHEMA with stale registration +ok 7 - extension_drop's schema should not be reachable via search_path +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/expected/unrelated_drops_1.out b/test/expected/unrelated_drops_1.out new file mode 100644 index 0000000..19ec9b0 --- /dev/null +++ b/test/expected/unrelated_drops_1.out @@ -0,0 +1,10 @@ +\set ECHO none +1..7 +ok 1 - Create non-superuser role +ok 2 - Non-superuser DROP SCHEMA CASCADE +ok 3 - Non-superuser DROP OWNED BY +ok 4 - Insert stale registration +ok 5 - Stale registration is present +ok 6 - Superuser DROP SCHEMA with stale registration +ok 7 # SKIP existing mode: extension_drop's placement is outside this suite's control +# TRANSACTION INTENTIONALLY LEFT OPEN! diff --git a/test/helpers/test_ext__verify_dropped.sql b/test/helpers/test_ext__verify_dropped.sql new file mode 100644 index 0000000..a86f134 --- /dev/null +++ b/test/helpers/test_ext__verify_dropped.sql @@ -0,0 +1,11 @@ +SELECT hasnt_extension('extension_drop_test', 'Test extension does not exist'); +SELECT is_empty( + $$SELECT * FROM $$ || :'TT' + , :'TT' || ' is empty' +); +SELECT is_empty( + format($$SELECT unnest(%I.extension_drop__sanity_check())$$, :'extension_drop_schema') + , 'No stale registration' +); + +-- vi: expandtab sw=2 ts=2 diff --git a/test/sql/drop_denied.sql b/test/sql/drop_denied.sql new file mode 100644 index 0000000..ac3af46 --- /dev/null +++ b/test/sql/drop_denied.sql @@ -0,0 +1,378 @@ +\set ECHO none +\i test/pgxntool/setup.sql + +/* + * Dropping an extension that is registered with extension_drop needs access to + * extension_drop's registration table; without it the caller must get an actionable + * insufficient_privilege error, not a raw "permission denied for table". Dropping an + * unregistered extension needs no grants at all, but a stale registration still fails. + * Covers the three commands the trigger fires for. + */ +SELECT plan( + 2 -- Setup: role, schema with extension + + 1 -- DROP SCHEMA CASCADE without USAGE on extension_drop's schema + + 3 -- DETAIL and HINT + + 2 -- USAGE on extension_drop's schema, but no table privileges + + 2 -- DETAIL + + 2 -- SELECT but not DELETE + + 2 -- SELECT and DELETE, registered SQL fails with its own privilege error + + 4 -- SELECT and DELETE, registered SQL succeeds + + 1 -- Registration removed + + 4 -- The same privileges held through a group role + + 3 -- Unregistered, no USAGE: recreate, unregister, DROP SCHEMA CASCADE + + 2 -- Unregistered, USAGE only: DROP SCHEMA CASCADE + + 3 -- Unregistered, USAGE only: DROP OWNED BY + + 5 -- Unregistered, SELECT but not DELETE + + 6 -- Helper not executable: falls through to the caller's DELETE + + 4 -- Temp table shadowing extension_drop__commands + + 6 -- Stale registration + + 4 -- DROP EXTENSION by a role that was a superuser when it created it + + 3 -- DROP EXTENSION of an unregistered extension by that role + + 1 -- extension_drop's schema should not be on search_path (test/finish.sql) +); + +-- Hide version-specific NOTICE/WARNING text from the drops +SET client_min_messages = ERROR; + +\set DROP_SCHEMA 'DO $$ BEGIN SET LOCAL ROLE extension_drop_test_plain; DROP SCHEMA extension_drop_test_plain_schema CASCADE; RESET ROLE; END $$' +\set DROP_OWNED 'DO $$ BEGIN SET LOCAL ROLE extension_drop_test_plain; DROP OWNED BY extension_drop_test_plain CASCADE; RESET ROLE; END $$' +\set RECREATE 'CREATE SCHEMA extension_drop_test_plain_schema AUTHORIZATION extension_drop_test_plain; CREATE EXTENSION extension_drop_test SCHEMA extension_drop_test_plain_schema' +\set UNREGISTER 'SELECT ' :"extension_drop_schema" '.extension_drop__remove(''extension_drop_test'')' + +-- The primary message names the table, its owner and the extensions being dropped +SELECT format( + 'permission denied for table "%s"."extension_drop__commands" (owned by role "%s"), which extension_drop needs to drop extension(s) extension_drop_test' + , :'extension_drop_schema' + , (SELECT pg_get_userbyid(relowner) FROM pg_class WHERE oid = format('%I.extension_drop__commands', :'extension_drop_schema')::regclass) +) AS "MSG" \gset + +SELECT lives_ok( + 'CREATE ROLE extension_drop_test_plain' + , 'Create non-superuser role' +); +-- Created as superuser, so the role only owns the schema +SELECT lives_ok( + :'RECREATE' + , 'Create schema owned by role containing a registered extension' +); + +SELECT throws_ok( + :'DROP_SCHEMA' + , '42501' + , :'MSG' + , 'Registered: DROP SCHEMA CASCADE without USAGE on extension_drop''s schema' +); + +-- The error text reaches the caller as DETAIL and HINT; stash them in GUCs to compare +SELECT lives_ok( + $q$DO $$ + DECLARE d text; h text; + BEGIN + SET LOCAL ROLE extension_drop_test_plain; + DROP SCHEMA extension_drop_test_plain_schema CASCADE; + EXCEPTION WHEN insufficient_privilege THEN + GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL, h = PG_EXCEPTION_HINT; + PERFORM set_config('extension_drop_test.detail', d, false), set_config('extension_drop_test.hint', h, false); + END $$$q$ + , 'Capture DETAIL and HINT' +); +/* + * Without USAGE the table can't be found by name ("does not exist"); in existing mode the schema is + * public, which everyone can use, so it is "permission denied" instead. + */ +SELECT matches( + current_setting('extension_drop_test.detail') + , '^(relation "extension_drop__commands" does not exist|permission denied for table extension_drop__commands)$' + , 'DETAIL' +); +SELECT is( + current_setting('extension_drop_test.hint') + , format( + 'Run the drop as role %1$I or a superuser, or grant USAGE on schema %2$I and SELECT, DELETE on %2$I.extension_drop__commands to the current role.' + , current_user, :'extension_drop_schema') + , 'HINT' +); + +SELECT lives_ok( + format('GRANT USAGE ON SCHEMA %I TO extension_drop_test_plain', :'extension_drop_schema') + , 'Grant USAGE on extension_drop''s schema' +); +SELECT throws_ok( + :'DROP_SCHEMA' + , '42501' + , :'MSG' + , 'Registered: DROP SCHEMA CASCADE with USAGE but no table privileges' +); +SELECT lives_ok( + $q$DO $$ + DECLARE d text; + BEGIN + SET LOCAL ROLE extension_drop_test_plain; + DROP SCHEMA extension_drop_test_plain_schema CASCADE; + EXCEPTION WHEN insufficient_privilege THEN + GET STACKED DIAGNOSTICS d = PG_EXCEPTION_DETAIL; + PERFORM set_config('extension_drop_test.detail', d, false); + END $$$q$ + , 'Capture DETAIL' +); +SELECT is( + current_setting('extension_drop_test.detail') + , 'permission denied for table extension_drop__commands' + , 'DETAIL is the original error' +); + +-- SELECT alone is not enough for a registered extension: the trigger deletes the registration +SELECT lives_ok( + format('GRANT SELECT ON %I.extension_drop__commands TO extension_drop_test_plain', :'extension_drop_schema') + , 'Grant SELECT' +); +SELECT throws_ok( + :'DROP_SCHEMA' + , '42501' + , :'MSG' + , 'Registered: DROP SCHEMA CASCADE with SELECT but not DELETE' +); + +-- A privilege error from the registered SQL itself is not extension_drop's to translate +SELECT lives_ok( + format($$GRANT DELETE ON %1$I.extension_drop__commands TO extension_drop_test_plain; + SELECT %1$I.extension_drop__update('extension_drop_test', 'CREATE ROLE extension_drop_test_never')$$ + , :'extension_drop_schema') + , 'Grant DELETE, register SQL that needs CREATEROLE' +); +SELECT throws_ok( + :'DROP_SCHEMA' + , '42501' + , 'permission denied to create role' + , 'Registered SQL''s own privilege error surfaces unchanged' +); + +SELECT lives_ok( + format($$SELECT %I.extension_drop__update('extension_drop_test', 'SELECT 1')$$, :'extension_drop_schema') + , 'Register harmless SQL' +); +SELECT lives_ok( + :'DROP_SCHEMA' + , 'Registered: DROP SCHEMA CASCADE with the privileges the HINT names' +); +SELECT is_empty( + format($$SELECT unnest(%I.extension_drop__sanity_check())$$, :'extension_drop_schema') + , 'No stale registration' +); +SELECT is_empty( + format($$SELECT 1 FROM %I.extension_drop__commands WHERE extension_name = 'extension_drop_test'$$, :'extension_drop_schema') + , 'Registration removed' +); + +-- Privileges held only through a group role the caller is a member of +SELECT lives_ok( + :'RECREATE' + , 'Recreate the registered extension' +); +SELECT lives_ok( + format( + $$SELECT %1$I.extension_drop__update('extension_drop_test', 'SELECT 1'); + REVOKE SELECT, DELETE ON %1$I.extension_drop__commands FROM extension_drop_test_plain; + REVOKE USAGE ON SCHEMA %1$I FROM extension_drop_test_plain; + CREATE ROLE extension_drop_test_group; + GRANT USAGE ON SCHEMA %1$I TO extension_drop_test_group; + GRANT SELECT, DELETE ON %1$I.extension_drop__commands TO extension_drop_test_group; + GRANT extension_drop_test_group TO extension_drop_test_plain$$ + , :'extension_drop_schema') + , 'Move the grants from the role to a group it belongs to' +); +SELECT lives_ok( + :'DROP_SCHEMA' + , 'Registered: DROP SCHEMA CASCADE with the privileges held through a group role' +); +SELECT lives_ok( + 'REVOKE extension_drop_test_group FROM extension_drop_test_plain' + , 'Remove all of the role''s access' +); + +-- Unregistered extensions need no grants, but still need to be findable: no USAGE means the helper isn't +SELECT lives_ok( + :'RECREATE' + , 'Recreate the extension' +); +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +-- In existing mode extension_drop is in public, which every role can use, so there is no "no USAGE" case +SELECT has_schema_privilege('extension_drop_test_plain', :'extension_drop_schema', 'USAGE') AS plain_has_usage \gset +\if :plain_has_usage + SELECT skip('existing mode: the role can use extension_drop''s schema', 1); +\else + SELECT throws_ok( + :'DROP_SCHEMA' + , '42501' + , :'MSG' + , 'Unregistered: DROP SCHEMA CASCADE without USAGE on extension_drop''s schema' + ); +\endif + +SELECT lives_ok( + format('GRANT USAGE ON SCHEMA %I TO extension_drop_test_plain', :'extension_drop_schema') + , 'Grant USAGE on extension_drop''s schema' +); +SELECT lives_ok( + :'DROP_SCHEMA' + , 'Unregistered: DROP SCHEMA CASCADE with USAGE but no table privileges' +); + +SELECT lives_ok( + :'RECREATE' + , 'Recreate the extension' +); +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +SELECT lives_ok( + :'DROP_OWNED' + , 'Unregistered: DROP OWNED BY with USAGE but no table privileges' +); + +SELECT lives_ok( + :'RECREATE' + , 'Recreate the extension' +); +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +SELECT lives_ok( + format('GRANT SELECT ON %I.extension_drop__commands TO extension_drop_test_plain', :'extension_drop_schema') + , 'Grant SELECT' +); +SELECT lives_ok( + :'DROP_SCHEMA' + , 'Unregistered: DROP SCHEMA CASCADE with SELECT but not DELETE' +); +SELECT lives_ok( + format('REVOKE SELECT ON %I.extension_drop__commands FROM extension_drop_test_plain', :'extension_drop_schema') + , 'Revoke SELECT' +); + +-- With EXECUTE on the helper revoked, the trigger falls through to the caller's own DELETE +SELECT lives_ok( + :'RECREATE' + , 'Recreate the extension' +); +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +SELECT lives_ok( + format('REVOKE EXECUTE ON FUNCTION %I.extension_drop__registered(name[]) FROM PUBLIC', :'extension_drop_schema') + , 'Revoke EXECUTE on the helper' +); +SELECT throws_ok( + :'DROP_SCHEMA' + , '42501' + , :'MSG' + , 'Unregistered: DROP SCHEMA CASCADE without EXECUTE on the helper or table privileges' +); +SELECT lives_ok( + format('GRANT EXECUTE ON FUNCTION %I.extension_drop__registered(name[]) TO PUBLIC', :'extension_drop_schema') + , 'Restore EXECUTE on the helper' +); +SELECT lives_ok( + 'DROP SCHEMA extension_drop_test_plain_schema CASCADE' + , 'Superuser drop schema' +); + +-- A temp table named like ours must not hide the real registration from the helper +SELECT lives_ok( + :'RECREATE' + , 'Recreate the registered extension' +); +SELECT throws_ok( + $q$DO $$ + BEGIN + SET LOCAL ROLE extension_drop_test_plain; + CREATE TEMP TABLE extension_drop__commands(extension_name name, sql text); + DROP SCHEMA extension_drop_test_plain_schema CASCADE; + END $$$q$ + , '42501' + , :'MSG' + , 'Registered extension is still found when the caller has a temp table of the same name' +); +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +SELECT lives_ok( + 'DROP SCHEMA extension_drop_test_plain_schema CASCADE' + , 'Superuser drop schema' +); + +-- A stale registration must still fail the drop of an unregistered extension +SELECT lives_ok( + :'RECREATE' + , 'Recreate the extension' +); +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +SELECT lives_ok( + format($$INSERT INTO %I.extension_drop__commands VALUES('extension_drop_test_stale', 'SELECT 1')$$, :'extension_drop_schema') + , 'Insert a stale registration' +); +SELECT throws_ok( + :'DROP_SCHEMA' + , 'XD001' + , 'unexpected drop commands' + , 'Unregistered: DROP SCHEMA CASCADE with a stale registration present' +); +SELECT lives_ok( + format('SELECT %I.extension_drop__repair()', :'extension_drop_schema') + , 'Repair' +); +SELECT lives_ok( + 'DROP SCHEMA extension_drop_test_plain_schema CASCADE' + , 'Superuser drop schema' +); + +-- ALTER EXTENSION can't change the owner, so create it as a role that is then demoted +SELECT lives_ok( + 'CREATE ROLE extension_drop_test_owner SUPERUSER' + , 'Create role' +); +SELECT lives_ok( + $q$DO $$ BEGIN SET LOCAL ROLE extension_drop_test_owner; CREATE EXTENSION extension_drop_test; RESET ROLE; END $$$q$ + , 'Create test extension as role' +); +SELECT lives_ok( + 'ALTER ROLE extension_drop_test_owner NOSUPERUSER' + , 'Demote role' +); +SELECT throws_ok( + $q$DO $$ BEGIN SET LOCAL ROLE extension_drop_test_owner; DROP EXTENSION extension_drop_test; END $$$q$ + , '42501' + , :'MSG' + , 'DROP EXTENSION by the extension''s non-superuser owner' +); + +SELECT lives_ok( + :'UNREGISTER' + , 'Unregister the extension' +); +SELECT lives_ok( + format('GRANT USAGE ON SCHEMA %I TO extension_drop_test_owner', :'extension_drop_schema') + , 'Grant USAGE on extension_drop''s schema' +); +SELECT lives_ok( + $q$DO $$ BEGIN SET LOCAL ROLE extension_drop_test_owner; DROP EXTENSION extension_drop_test; RESET ROLE; END $$$q$ + , 'Unregistered: DROP EXTENSION by the extension''s non-superuser owner with USAGE but no table privileges' +); +SELECT is_empty( + format($$SELECT unnest(%I.extension_drop__sanity_check())$$, :'extension_drop_schema') + , 'No stale registration' +); + +\i test/finish.sql + +-- vi: expandtab sw=2 ts=2 diff --git a/test/sql/drop_owned.sql b/test/sql/drop_owned.sql new file mode 100644 index 0000000..495ee04 --- /dev/null +++ b/test/sql/drop_owned.sql @@ -0,0 +1,48 @@ +\set ECHO none +\i test/pgxntool/setup.sql + +/* + * DROP OWNED BY can drop an extension; its registered command must run and + * the registration must go away, or the stale row makes every later DROP + * EXTENSION fail. + */ +SELECT plan( + 1 -- test/helpers/test_ext__insert.sql + + 4 -- DROP OWNED BY drops the extension + + 3 -- test/helpers/test_ext__verify_dropped.sql + + 4 -- test/helpers/test_ext__create_drop.sql + + 1 -- extension_drop's schema should not be on search_path (test/finish.sql) +); + +\i test/helpers/test_ext__insert.sql + +SELECT lives_ok( + 'CREATE ROLE extension_drop_test_owner SUPERUSER' + , 'Create role' +); +-- ALTER EXTENSION can't change the owner, so create it as that role +SELECT lives_ok( + $q$DO $$ BEGIN + SET LOCAL ROLE extension_drop_test_owner; + CREATE EXTENSION extension_drop_test; + RESET ROLE; + END $$$q$ + , 'Create test extension as role' +); +SELECT bag_eq( + format($$SELECT * FROM %I.extension_drop__get('extension_drop_test')$$, :'extension_drop_schema') + , $$SELECT 'extension_drop_test'::name, 'DELETE FROM extension_drop_test_table'::text$$ + , 'Verify extension_drop__get()' +); + +SELECT lives_ok( + 'DROP OWNED BY extension_drop_test_owner' + , 'Drop owned' +); +\i test/helpers/test_ext__verify_dropped.sql + +\i test/helpers/test_ext__create_drop.sql + +\i test/finish.sql + +-- vi: expandtab sw=2 ts=2 diff --git a/test/sql/drop_schema_cascade.sql b/test/sql/drop_schema_cascade.sql new file mode 100644 index 0000000..2cec30d --- /dev/null +++ b/test/sql/drop_schema_cascade.sql @@ -0,0 +1,46 @@ +\set ECHO none +\i test/pgxntool/setup.sql + +/* + * DROP SCHEMA ... CASCADE can drop an extension; its registered command must + * run and the registration must go away, or the stale row makes every later + * DROP EXTENSION fail. + */ +SELECT plan( + 1 -- test/helpers/test_ext__insert.sql + + 4 -- DROP SCHEMA CASCADE drops the extension + + 3 -- test/helpers/test_ext__verify_dropped.sql + + 4 -- test/helpers/test_ext__create_drop.sql + + 1 -- extension_drop's schema should not be on search_path (test/finish.sql) +); + +-- Hide the version-specific "drop cascades to" NOTICE +SET client_min_messages = WARNING; + +\i test/helpers/test_ext__insert.sql + +SELECT lives_ok( + 'CREATE SCHEMA extension_drop_test_schema' + , 'Create schema' +); +SELECT lives_ok( + 'CREATE EXTENSION extension_drop_test SCHEMA extension_drop_test_schema' + , 'Create test extension in schema' +); +SELECT bag_eq( + format($$SELECT * FROM %I.extension_drop__get('extension_drop_test')$$, :'extension_drop_schema') + , $$SELECT 'extension_drop_test'::name, 'DELETE FROM extension_drop_test_table'::text$$ + , 'Verify extension_drop__get()' +); + +SELECT lives_ok( + 'DROP SCHEMA extension_drop_test_schema CASCADE' + , 'Drop schema cascade' +); +\i test/helpers/test_ext__verify_dropped.sql + +\i test/helpers/test_ext__create_drop.sql + +\i test/finish.sql + +-- vi: expandtab sw=2 ts=2 diff --git a/test/sql/unrelated_drops.sql b/test/sql/unrelated_drops.sql new file mode 100644 index 0000000..e6a3515 --- /dev/null +++ b/test/sql/unrelated_drops.sql @@ -0,0 +1,64 @@ +\set ECHO none +\i test/pgxntool/setup.sql + +/* + * DROP SCHEMA and DROP OWNED fire extension_drop's event trigger, so drops + * that involve no extension must not depend on the caller's privileges or on + * the state of extension_drop__commands. + */ +SELECT plan( + 3 -- Non-superuser drops + + 3 -- Stale registration + + 1 -- extension_drop's schema should not be on search_path (test/finish.sql) +); + +-- Hide version- and database-specific NOTICE/WARNING text from the drops +SET client_min_messages = ERROR; + +SELECT lives_ok( + 'CREATE ROLE extension_drop_test_plain' + , 'Create non-superuser role' +); + +/* + * Schemas are created as superuser but owned by the role, so it needs no + * CREATE privilege on the database. + */ +SELECT lives_ok( + $q$DO $$ BEGIN + CREATE SCHEMA extension_drop_test_plain_schema AUTHORIZATION extension_drop_test_plain + CREATE TABLE t(i int); + SET LOCAL ROLE extension_drop_test_plain; + DROP SCHEMA extension_drop_test_plain_schema CASCADE; + RESET ROLE; + END $$$q$ + , 'Non-superuser DROP SCHEMA CASCADE' +); +SELECT lives_ok( + $q$DO $$ BEGIN + CREATE SCHEMA extension_drop_test_plain_schema AUTHORIZATION extension_drop_test_plain + CREATE TABLE t(i int); + SET LOCAL ROLE extension_drop_test_plain; + DROP OWNED BY extension_drop_test_plain; + RESET ROLE; + END $$$q$ + , 'Non-superuser DROP OWNED BY' +); + +SELECT lives_ok( + format($$INSERT INTO %I.extension_drop__commands VALUES('extension_drop_test_stale', 'SELECT 1')$$, :'extension_drop_schema') + , 'Insert stale registration' +); +SELECT results_eq( + format($$SELECT %I.extension_drop__sanity_check()$$, :'extension_drop_schema') + , $$SELECT '{extension_drop_test_stale}'::name[]$$ + , 'Stale registration is present' +); +SELECT lives_ok( + $$CREATE SCHEMA extension_drop_test_su_schema; DROP SCHEMA extension_drop_test_su_schema$$ + , 'Superuser DROP SCHEMA with stale registration' +); + +\i test/finish.sql + +-- vi: expandtab sw=2 ts=2