diff --git a/Makefile b/Makefile index 9bc4fd6..96f8589 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,31 @@ include pgxntool/base.mk +PGXNTOOL_ENABLE_TEST_INSTALL = yes + +# ------------------------------------------------------------------------------ +# TEST_LOAD_SOURCE: how test/install/load.sql gets the extension to its +# target state (fresh/update/existing). See test/install/load.sql for what +# each mode actually does. +# ------------------------------------------------------------------------------ +TEST_LOAD_SOURCE ?= fresh +ifeq ($(filter $(TEST_LOAD_SOURCE),fresh update existing),) +$(error TEST_LOAD_SOURCE must be 'fresh', 'update' or 'existing', got '$(TEST_LOAD_SOURCE)') +endif + +# Only meaningful in 'update' mode; empty TO means "update to current". +TEST_UPDATE_FROM ?= 0.5.0 +TEST_UPDATE_TO ?= + +# Export unconditionally -- load.sql must never treat "absent" as "fresh". +export PGOPTIONS := $(PGOPTIONS) -c test_factory.test_load_mode=$(TEST_LOAD_SOURCE) -c test_factory.test_update_from=$(TEST_UPDATE_FROM) -c test_factory.test_update_to=$(TEST_UPDATE_TO) + +# make test-update: convenience wrapper. Must re-invoke $(MAKE) (not just +# depend on test) so the parse-time TEST_LOAD_SOURCE validation above +# re-evaluates for the child invocation. +.PHONY: test-update +test-update: + $(MAKE) test TEST_LOAD_SOURCE=update + # Hook for test to ensure dependencies in control file are set correctly testdeps: check_control diff --git a/test/CLAUDE.md b/test/CLAUDE.md index ac12354..c61b88d 100644 --- a/test/CLAUDE.md +++ b/test/CLAUDE.md @@ -20,10 +20,61 @@ The test_factory extension uses **pgTAP** (PostgreSQL's unit testing framework) ### Test Helpers - `test/helpers/setup.sql` - Test environment initialization and pgTAP setup - `test/helpers/create.sql` - Test data registration and security validation -- `test/helpers/create_extension.sql` - Extension creation wrapper -- `test/helpers/deps.sql` - Test dependency management +- `test/helpers/create_extension.sql` - Extension creation wrapper; skips + `CREATE EXTENSION` when `test/install/load.sql` already installed it + (`test_load_mode` is not `fresh`) +- `test/helpers/deps.sql` - Test dependency management (`\i`'s `test/roles.sql`) +- `test/roles.sql` - Single source of truth for test-only role names - Other helper files for role management and pgTAP integration +## Load Modes (`TEST_LOAD_SOURCE`) + +`test/install/load.sql` runs once, committed, before the regular test files +(pgxntool's `PGXNTOOL_ENABLE_TEST_INSTALL` feature), so its state survives +into every test file. `TEST_LOAD_SOURCE` (default `fresh`) picks how the +extension gets to its target state: + +- **fresh** (default) - `load.sql` does nothing extra; `test/sql/*.sql` + install the extension themselves, exactly as before this feature existed. +- **update** - `load.sql` does `CREATE EXTENSION test_factory VERSION + :from` then `ALTER EXTENSION UPDATE` (`TEST_UPDATE_FROM`/`TEST_UPDATE_TO` + make vars). test_factory has only ever shipped one version (0.5.0), so + this is a no-op today -- the mechanism exists for when a second version + ships, but no CI job drives it yet. `make test-update` is a shorthand for + `make test TEST_LOAD_SOURCE=update`. +- **existing** - the extension is already installed (a real `pg_upgrade` + target, or an out-of-band update) -- `load.sql` only asserts it's present + at the current version, plants a dependency guard (see below), and + proves it. `test/sql/install.sql` and the install/dependency-order part of + `test/sql/pgtap.sql` are skipped in this mode (see the `\if :is_existing` + branches in those files) since they'd otherwise defeat the guard by doing + their own from-scratch drop/recreate. + + Run against a real pre-existing install with: + ``` + make test TEST_LOAD_SOURCE=existing CONTRIB_TESTDB= \ + EXTRA_REGRESS_OPTS=--use-existing PGXNTOOL_ENABLE_TEST_BUILD=no + ``` + + `existing` mode legitimately produces different (but equally valid) + output than `fresh`/`update` for `base`/`install`/`pgtap` (skipped + sections, a skipped role-restore check), so it has alternate expected + files: `test/expected/{base,install,pgtap}_1.out` (pg_regress's numbered + alternate-expected-file convention). + +### Dependency Guard + +Planted only in `existing` mode (see `load.sql`): a view in schema +`test_factory_drop_guard` depending on `tf.tap(text,text)` blocks a +non-CASCADE `DROP EXTENSION test_factory_pgtap`. `test_factory` itself +doesn't need an artificial guard -- `test_factory_pgtap`'s own control file +(`requires = 'pgtap, test_factory'`) already blocks a non-CASCADE +`DROP EXTENSION test_factory` as long as `test_factory_pgtap` is installed; +`load.sql` proves that natural protection too. The point of the guard: in +`existing` mode, nothing else stops a stray drop (or a logic bug that falls +through to the fresh/update branch) from silently destroying the real +upgraded/updated objects this mode exists to test. + ## Test Coverage Analysis ### Core Functionality Tests (`base.sql`) diff --git a/test/expected/base_1.out b/test/expected/base_1.out new file mode 100644 index 0000000..0076bd0 --- /dev/null +++ b/test/expected/base_1.out @@ -0,0 +1,25 @@ +\set ECHO none +Creating extension test_factory +test_factory already installed -- skipping CREATE EXTENSION (test_load_mode is not fresh) +ok 1 - Register test customers +ok 2 - Create function customer__add +ok 3 - Register test invoices +ok 4 - Ensure original_role temp table was dropped +ok 5 # SKIP role-restore check only applies when this session ran CREATE EXTENSION itself (test_load_mode=fresh) +ok 6 - Security definer function _tf.schema__getsert has search_path=pg_catalog +ok 7 - Security definer function _tf.test_factory__get has search_path=pg_catalog +ok 8 - Security definer function _tf.test_factory__set has search_path=pg_catalog +ok 9 - Security definer function _tf.table_create has search_path=pg_catalog +ok 10 - Security definer function _tf.get has search_path=pg_catalog +ok 11 - customer table is empty +ok 12 - invoice table is empty +ok 13 - invoice factory output +ok 14 - invoice table content +ok 15 - customer table content +ok 16 - invoice factory second call +ok 17 - invoice table content stayed constant +ok 18 - customer table content stayed constant +ok 19 - Test function factory +ok 20 - customer table has new row +ok 21 - truncate invoice +ok 22 - invoice factory get remains the same after truncate diff --git a/test/expected/install_1.out b/test/expected/install_1.out new file mode 100644 index 0000000..374c987 --- /dev/null +++ b/test/expected/install_1.out @@ -0,0 +1,2 @@ +\set ECHO none +ok 1 - install.sql skipped under test_load_mode=existing -- see comment in test/sql/install.sql diff --git a/test/expected/pgtap_1.out b/test/expected/pgtap_1.out new file mode 100644 index 0000000..e7c2b20 --- /dev/null +++ b/test/expected/pgtap_1.out @@ -0,0 +1,14 @@ +\set ECHO none +ok 1 - Register test customers +ok 2 - Create function customer__add +ok 3 - Register test invoices +ok 4 - Ensure original_role temp table was dropped +ok 5 # SKIP role-restore check only applies when this session ran CREATE EXTENSION itself (test_load_mode=fresh) +ok 6 - Security definer function _tf.schema__getsert has search_path=pg_catalog +ok 7 - Security definer function _tf.test_factory__get has search_path=pg_catalog +ok 8 - Security definer function _tf.test_factory__set has search_path=pg_catalog +ok 9 - Security definer function _tf.table_create has search_path=pg_catalog +ok 10 - Security definer function _tf.get has search_path=pg_catalog +ok 11 - Get test data set "base" for table invoice +ok 12 - Get test data set "base" for table invoice +ok 13 - Ensure we get sane error for a non-existent table diff --git a/test/helpers/create.sql b/test/helpers/create.sql index 9897f75..87b0f15 100644 --- a/test/helpers/create.sql +++ b/test/helpers/create.sql @@ -1,13 +1,17 @@ SET ROLE = DEFAULT; -CREATE ROLE test_role; -GRANT USAGE ON SCHEMA tap TO test_role; +/* + * test_role itself is created once, idempotently, by test/install/load.sql + * (test/roles.sql is the single source of truth for the name; \i'd via + * test/helpers/deps.sql). + */ +GRANT USAGE ON SCHEMA tap TO :test_role; /* * DO NOT GRANT test_role TO test_factory__owner; the whole point test_role is * to check for security problems. */ -CREATE SCHEMA test AUTHORIZATION test_role; -SET ROLE = test_role; +CREATE SCHEMA test AUTHORIZATION :test_role; +SET ROLE = :test_role; SET search_path = test, tap; CREATE TABLE customer( @@ -79,11 +83,21 @@ SELECT hasnt_table( , 'Ensure original_role temp table was dropped' ); +/* + * Only meaningful when this session actually ran CREATE EXTENSION itself + * (test_load_mode=fresh; the tables don't exist under update/existing, + * where test/install/load.sql installed/updated the extension earlier). + */ +SELECT to_regclass('pg_temp.pre_install_role') IS NOT NULL AS has_role_capture \gset +\if :has_role_capture SELECT is( (SELECT * FROM post_install_role) , (SELECT * FROM pre_install_role) , 'Ensure role is put back after install' ); +\else +SELECT skip('role-restore check only applies when this session ran CREATE EXTENSION itself (test_load_mode=fresh)', 1); +\endif SELECT cmp_ok( proconfig diff --git a/test/helpers/create_extension.sql b/test/helpers/create_extension.sql index 1fd2308..8dbe0f9 100644 --- a/test/helpers/create_extension.sql +++ b/test/helpers/create_extension.sql @@ -1,7 +1,24 @@ \echo Creating extension :extension_name --- No IF NOT EXISTS because we'll be confused if we're not loading the new stuff +/* + * In 'update'/'existing' mode, test/install/load.sql already installed (and, + * for 'update', updated) the extensions in its own earlier committed + * session -- skip re-creating here instead of erroring or, worse, silently + * replacing the state those modes exist to test. In 'fresh' mode (the only + * mode test/install/load.sql leaves untouched), keep the original + * behavior: no IF NOT EXISTS, so we're confused loudly if something's + * already there instead of silently testing stale state. + */ +SELECT + current_setting('test_factory.test_load_mode') <> 'fresh' + AND EXISTS (SELECT 1 FROM pg_extension WHERE extname = :'extension_name') + AS already_installed +\gset +\if :already_installed +\echo :extension_name already installed -- skipping CREATE EXTENSION (test_load_mode is not fresh) +\else CREATE TEMP TABLE pre_install_role AS SELECT current_user; GRANT SELECT ON pre_install_role TO public; -- In case role is different CREATE EXTENSION :extension_name; CREATE TEMP TABLE post_install_role AS SELECT current_user; GRANT SELECT ON post_install_role TO public; -- In case role is different +\endif diff --git a/test/helpers/deps.sql b/test/helpers/deps.sql index e69de29..455686f 100644 --- a/test/helpers/deps.sql +++ b/test/helpers/deps.sql @@ -0,0 +1 @@ +\i test/roles.sql diff --git a/test/install/load.out b/test/install/load.out new file mode 100644 index 0000000..25fdbb1 --- /dev/null +++ b/test/install/load.out @@ -0,0 +1 @@ +\set ECHO none diff --git a/test/install/load.sql b/test/install/load.sql new file mode 100644 index 0000000..f2fa03f --- /dev/null +++ b/test/install/load.sql @@ -0,0 +1,201 @@ +\set ECHO none +/* + * Foundation installer for the whole regression run. pgxntool's + * PGXNTOOL_ENABLE_TEST_INSTALL feature runs this file, committed, in its + * own pg_regress session before the regular test SQL files run -- so + * whatever it commits here survives into every test file, instead of each + * one creating its own install from scratch (which is still what the + * regular test files do in the default 'fresh' mode, unchanged). NOTE: this + * comment deliberately never spells out a bare wildcard glob right after a + * slash -- slash-star is a comment opener, and Postgres nests block + * comments, so an unbalanced extra opener earlier in a comment silently + * swallows the rest of the file instead of erroring where you'd notice (hit + * this for real while writing this file -- see the PR description). + * + * TEST_LOAD_SOURCE (make var -> test_factory.test_load_mode GUC, see + * Makefile) picks how the extension gets to its target state: + * fresh (default) - do nothing here; the regular test files still + * install it themselves, exactly as before this + * feature existed. + * update - CREATE EXTENSION VERSION :from, then ALTER EXTENSION UPDATE. + * existing - extension is already installed (a real pg_upgrade target, or + * an out-of-band update) -- assert-only, never drop/create. + */ +\i test/helpers/psql.sql +\i test/roles.sql + +SET client_min_messages = WARNING; + +/* + * Read unconditionally, without missing_ok: the Makefile always exports + * test_factory.test_load_mode, so an unset GUC here means the harness + * itself is broken, not "assume fresh". + */ +SELECT current_setting('test_factory.test_load_mode') AS load_mode \gset +SELECT current_setting('test_factory.test_update_from') AS update_from \gset +SELECT current_setting('test_factory.test_update_to') AS update_to \gset +SELECT :'update_to' <> '' AS has_update_to \gset + +DO $$ +BEGIN + IF current_setting('test_factory.test_load_mode') NOT IN ('fresh', 'update', 'existing') THEN + RAISE EXCEPTION 'test_factory.test_load_mode must be fresh, update or existing, got %', current_setting('test_factory.test_load_mode'); + END IF; +END $$; + +/* + * test_role is test infrastructure, not part of what fresh/update/existing + * describe -- (re)create it idempotently in every mode. A real pg_upgrade + * target (existing mode) carries global objects like roles over, but don't + * assume that; a from-scratch "existing" target might not have it. + */ +SELECT NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'test_role') AS need_role \gset +\if :need_role +CREATE ROLE :test_role; +\endif + +/* + * psql's \if only accepts a plain boolean token, not a comparison + * expression -- compute it via SQL first (\if :load_mode = 'existing' would + * silently misparse instead of erroring). + */ +SELECT :'load_mode' = 'existing' AS is_existing \gset +\if :is_existing + + /* + * existing: never drop/create/update -- only assert the extensions are + * actually there, at the version this build considers current. Never + * hardcode the expected version (see doc's CI dynamic-version-assertion + * guidance); default_version comes from the same control file `make` + * itself builds from. + */ + DO $$ + DECLARE + v_installed text := (SELECT extversion FROM pg_extension WHERE extname = 'test_factory'); + v_default text := (SELECT default_version FROM pg_available_extensions WHERE name = 'test_factory'); + BEGIN + IF v_installed IS NULL THEN + RAISE EXCEPTION 'test_load_mode=existing but test_factory is not installed'; + END IF; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'test_factory installed at % but default_version is %', v_installed, v_default; + END IF; + END $$; + + DO $$ + DECLARE + v_installed text := (SELECT extversion FROM pg_extension WHERE extname = 'test_factory_pgtap'); + v_default text := (SELECT default_version FROM pg_available_extensions WHERE name = 'test_factory_pgtap'); + BEGIN + IF v_installed IS NULL THEN + RAISE EXCEPTION 'test_load_mode=existing but test_factory_pgtap is not installed'; + END IF; + IF v_installed IS DISTINCT FROM v_default THEN + RAISE EXCEPTION 'test_factory_pgtap installed at % but default_version is %', v_installed, v_default; + END IF; + END $$; + + /* + * Dependency guard (advanced-extension-testing checklist item 6): nothing + * else stops a stray CASCADE drop, or a logic bug that falls through to + * the fresh/update branch below, from silently destroying the real + * upgraded/updated objects this mode exists to test -- the suite would + * then quietly pass against a fresh reinstall instead. + * + * Only test_factory_pgtap needs an artificial guard here. test_factory + * already has a natural one for free: test_factory_pgtap's own control + * file (`requires = 'pgtap, test_factory'`) already makes a non-CASCADE + * DROP EXTENSION test_factory fail on its own, as long as + * test_factory_pgtap is still installed -- proved below too, so a future + * change that weakens that dependency doesn't go unnoticed. Nothing + * depends on test_factory_pgtap itself, so it gets an explicit guard. + * + * Only planted in existing mode, not fresh/update: test/sql/install.sql's + * own non-CASCADE DROP EXTENSION is a deliberate, self-contained test of + * drop/recreate -- that test is skipped under existing mode (see + * install.sql) precisely because it's incompatible with this guard. + */ + CREATE SCHEMA IF NOT EXISTS test_factory_drop_guard; + CREATE OR REPLACE VIEW test_factory_drop_guard.guard AS + SELECT 'tf.tap(text,text)'::regprocedure AS guarded_member; + + DO $$ + BEGIN + BEGIN + DROP EXTENSION test_factory_pgtap; + RAISE EXCEPTION 'dependency guard is not working: non-CASCADE DROP EXTENSION test_factory_pgtap succeeded'; + EXCEPTION + WHEN dependent_objects_still_exist THEN + NULL; -- expected: the guard view blocked it + END; + END $$; + + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'test_factory_pgtap') THEN + RAISE EXCEPTION 'dependency guard proof left test_factory_pgtap dropped'; + END IF; + IF NOT EXISTS ( + SELECT 1 FROM pg_class + WHERE relname = 'guard' AND relnamespace = 'test_factory_drop_guard'::regnamespace + ) THEN + RAISE EXCEPTION 'dependency guard proof left the guard view itself missing'; + END IF; + END $$; + + DO $$ + BEGIN + BEGIN + DROP EXTENSION test_factory; + RAISE EXCEPTION 'test_factory''s natural drop-guard (test_factory_pgtap''s requires clause) is not working: non-CASCADE DROP EXTENSION test_factory succeeded'; + EXCEPTION + WHEN dependent_objects_still_exist THEN + NULL; -- expected + END; + END $$; + +\else + + /* + * fresh/update: drop-first reset, so a re-run against a non-fresh DB + * (e.g. local dev) starts from a known state. test_factory's own role + * bootstrapping (CREATE ROLE test_factory__owner, guarded by WHEN + * duplicate_object in sql/test_factory.sql) already tolerates being + * re-run, so unlike pgxntool's own drop-first example there's no + * separate role-drop step needed here. + */ + DROP EXTENSION IF EXISTS test_factory_pgtap CASCADE; + DROP EXTENSION IF EXISTS test_factory CASCADE; + + SELECT :'load_mode' = 'update' AS is_update \gset + \if :is_update + + CREATE EXTENSION test_factory VERSION :'update_from'; + SET client_min_messages = ERROR; -- suppress update-script deprecation NOTICEs + \if :has_update_to + ALTER EXTENSION test_factory UPDATE TO :'update_to'; + \else + ALTER EXTENSION test_factory UPDATE; + \endif + SET client_min_messages = WARNING; + + \endif + /* + * fresh: nothing else to do here -- the regular test files install the + * extension themselves, same as before this feature existed (see + * test/helpers/create_extension.sql). + * + * test_factory has only ever shipped one version (0.5.0), so 'update' + * mode above has no real historical update script to exercise yet -- + * CREATE EXTENSION VERSION '0.5.0' + ALTER EXTENSION UPDATE is a no-op + * today. The mechanism is built now per the advanced-extension-testing + * checklist (items 3-5); no CI job drives TEST_LOAD_SOURCE=update yet, + * since doing so wouldn't prove anything fresh-mode CI doesn't already + * cover. See the PR description for this reasoning. + */ + +\endif + +SET client_min_messages = NOTICE; + +-- vi: expandtab ts=2 sw=2 diff --git a/test/roles.sql b/test/roles.sql new file mode 100644 index 0000000..a48f56f --- /dev/null +++ b/test/roles.sql @@ -0,0 +1,10 @@ +/* + * Single source of truth for test-only role names. Roles are global (not + * schema- or session-scoped), so this can't be a table constant -- it's a + * psql variable, \i'd from any session that needs to reference the name: + * test/install/load.sql, and test/helpers/create.sql (via + * test/helpers/deps.sql) for the *.sql files under test/sql/. + */ +\set test_role test_role + +-- vi: expandtab ts=2 sw=2 diff --git a/test/sql/install.sql b/test/sql/install.sql index ae58bf3..130e4ba 100644 --- a/test/sql/install.sql +++ b/test/sql/install.sql @@ -3,6 +3,28 @@ SET client_min_messages = WARNING; +/* + * psql's \if only accepts a plain boolean token, not a comparison + * expression -- compute it via SQL first. + */ +SELECT current_setting('test_factory.test_load_mode') = 'existing' AS is_existing \gset + +\if :is_existing + +/* + * existing mode: test/install/load.sql already asserted both extensions are + * present and planted+proved the dependency guard. This file's whole job is + * normally "prove drop+recreate works cleanly" -- which is exactly the kind + * of non-CASCADE drop the guard exists to catch. Running it here would + * either fail loudly against the guard (not what this file is testing) or, + * without the guard, silently replace the real existing-mode install with a + * fresh one. Skip it; fresh/update-mode CI already exercises this file's + * actual content. + */ +SELECT pass('install.sql skipped under test_load_mode=existing -- see comment in test/sql/install.sql'); + +\else + /* * DO NOT use CASCADE here; we want this to fail if there's anything installed * that depends on it. @@ -26,5 +48,6 @@ SELECT lives_ok($$DROP EXTENSION IF EXISTS test_factory$$, 'clean-up test_factor * Arguably we should cleanup pgtap and the tap schema... */ --- vi: expandtab ts=2 sw=2 +\endif +-- vi: expandtab ts=2 sw=2 diff --git a/test/sql/pgtap.sql b/test/sql/pgtap.sql index ba0c4b3..fda29dc 100644 --- a/test/sql/pgtap.sql +++ b/test/sql/pgtap.sql @@ -2,6 +2,36 @@ \i test/helpers/setup.sql SET search_path = tap; + +/* + * psql's \if only accepts a plain boolean token, not a comparison + * expression -- compute it via SQL first. + */ +SELECT current_setting('test_factory.test_load_mode') = 'existing' AS is_existing \gset + +\if :is_existing + +/* + * existing mode: both extensions are already installed (see + * test/install/load.sql) -- don't touch install/dependency ordering here; + * that's what the \else branch below tests, and it assumes both extensions + * start absent (install.sql's cleanup normally guarantees that, but is + * itself skipped under existing mode -- see test/sql/install.sql). Just + * exercise tf.tap() against what's already there. + */ +\i test/helpers/create.sql + +SELECT tf.tap( 'invoice' ); +SELECT tf.tap( 'invoice', 'base' ); +SELECT throws_ok( + $$SELECT tf.tap( '"non-existent table"' )$$ + , '42P01' + , 'relation "non-existent table" does not exist' + , 'Ensure we get sane error for a non-existent table' +); + +\else + -- IF YOU GET A "schema tf does not exist" error here then the dependency is missing! SELECT throws_ok( $$CREATE EXTENSION test_factory_pgtap$$ @@ -30,6 +60,8 @@ SELECT throws_ok( , 'Ensure we get sane error for a non-existent table' ); +\endif + ROLLBACK; -- vi: expandtab ts=2 sw=2