diff --git a/test/io/test_auth.py b/test/io/test_auth.py index cec575b10..dfb6621e4 100644 --- a/test/io/test_auth.py +++ b/test/io/test_auth.py @@ -1,15 +1,12 @@ "Auth related IO tests for PostgREST" from operator import attrgetter -import signal import pytest -from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET +from config import BASEDIR, FIXTURES, SECRET from util import authheader, jwtauthheader from postgrest import ( run, - sleep_until_postgrest_config_reload, - sleep_until_postgrest_scache_reload, wait_until_exit, ) @@ -111,111 +108,3 @@ def test_jwt_aud_in_role_claim_key(jwtaudroleclaim, defaultenv): with run(env=env) as postgrest: response = postgrest.session.get("/authors_only", headers=headers) assert response.status_code == jwtaudroleclaim["expected_status"] - - -def test_jwt_secret_reload(tmp_path, defaultenv): - "JWT secret should be reloaded from file when PostgREST is sent SIGUSR2." - config = (CONFIGSDIR / "sigusr2-settings.config").read_text() - configfile = tmp_path / "test.config" - configfile.write_text(config) - - headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) - - with run(configfile, env=defaultenv) as postgrest: - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 401 - - # change setting - configfile.write_text(config.replace("invalid" * 5, SECRET)) - - # reload config - postgrest.process.send_signal(signal.SIGUSR2) - - sleep_until_postgrest_config_reload() - - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 200 - - -def test_jwt_secret_external_file_reload(tmp_path, defaultenv): - "JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY." - headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) - - external_secret_file = tmp_path / "jwt-secret-config" - external_secret_file.write_text("invalid" * 5) - - env = { - **defaultenv, - "PGRST_JWT_SECRET": f"@{external_secret_file}", - "PGRST_DB_CHANNEL_ENABLED": "true", - "PGRST_DB_CONFIG": "false", - "PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY - } - - with run(env=env) as postgrest: - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 401 - - # change external file - external_secret_file.write_text(SECRET) - - # SIGUSR1 doesn't reload external files, at least when db-config=false - postgrest.process.send_signal(signal.SIGUSR1) - sleep_until_postgrest_scache_reload() - - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 401 - - # reload config and external file with SIGUSR2 - postgrest.process.send_signal(signal.SIGUSR2) - sleep_until_postgrest_config_reload() - - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 200 - - # change external file to wrong value again - external_secret_file.write_text("invalid" * 5) - - # reload config and external file with NOTIFY - response = postgrest.session.post("/rpc/reload_pgrst_config") - assert response.text == "" - assert response.status_code == 204 - sleep_until_postgrest_config_reload() - - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 401 - - -def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv): - "JWT cache should be emptied after jwt-secret is changed in a config reload" - - headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) - - external_secret_file = tmp_path / "jwt-secret-config" - external_secret_file.write_text(SECRET) - - env = { - **defaultenv, - "PGRST_JWT_SECRET": f"@{external_secret_file}", - "PGRST_DB_CHANNEL_ENABLED": "true", - "PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache - "PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY - } - - with run(env=env) as postgrest: - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 200 # jwt gets cached - - # change external file - external_secret_file.write_text("invalid" * 5) - - # reload config and external file with NOTIFY - # jwt-cache should get empty - response = postgrest.session.post("/rpc/reload_pgrst_config") - assert response.text == "" - assert response.status_code == 204 - sleep_until_postgrest_config_reload() - - # now the request should fail because the cached token is removed - response = postgrest.session.get("/authors_only", headers=headers) - assert response.status_code == 401 diff --git a/test/io/test_io.py b/test/io/test_io.py index 33cc1346a..57fd53542 100644 --- a/test/io/test_io.py +++ b/test/io/test_io.py @@ -7,7 +7,7 @@ import time import pytest import requests -from config import CONFIGSDIR, SECRET +from config import SECRET from util import ( Thread, jwtauthheader, @@ -25,9 +25,6 @@ from postgrest import ( run, run_pgproxy, set_statement_timeout, - sleep_until_postgrest_config_reload, - sleep_until_postgrest_full_reload, - sleep_until_postgrest_scache_reload, wait_until_exit, ) @@ -229,161 +226,6 @@ def test_so_reuseport_defaults_to_false(defaultenv): pass -def test_db_schema_reload(tmp_path, defaultenv): - "DB schema should be reloaded from file when PostgREST is sent SIGUSR2." - config = (CONFIGSDIR / "sigusr2-settings.config").read_text() - configfile = tmp_path / "test.config" - configfile.write_text(config) - - with run(configfile, env=defaultenv) as postgrest: - response = postgrest.session.get("/rpc/get_guc_value?name=search_path") - assert response.text == '"\\"public\\", \\"public\\""' - - # change setting - configfile.write_text( - config.replace('db-schemas = "public"', 'db-schemas = "v1"') - ) - - # reload config - postgrest.process.send_signal(signal.SIGUSR2) - sleep_until_postgrest_config_reload() - - # reload schema cache to verify that the config reload actually happened - postgrest.process.send_signal(signal.SIGUSR1) - sleep_until_postgrest_scache_reload() - - response = postgrest.session.get("/rpc/get_guc_value?name=search_path") - assert response.text == '"\\"v1\\", \\"public\\""' - - -def test_db_schema_notify_reload(defaultenv): - "DB schema and config should be reloaded when PostgREST is sent a NOTIFY" - - env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_DB_CHANNEL_ENABLED": "true"} - - with run(env=env) as postgrest: - response = postgrest.session.get("/rpc/get_guc_value?name=search_path") - assert response.text == '"\\"public\\", \\"public\\""' - - # change db-schemas config on the db and reload config and cache with notify - postgrest.session.post( - "/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"} - ) - - sleep_until_postgrest_full_reload() - - response = postgrest.session.get("/rpc/get_guc_value?name=search_path") - assert response.text == '"\\"v1\\", \\"public\\""' - - # reset db-schemas config on the db - response = postgrest.session.post("/rpc/reset_db_schema_config") - assert response.text == "" - assert response.status_code == 204 - - -def test_max_rows_reload(defaultenv): - "max-rows should be reloaded from role settings when PostgREST receives a SIGUSR2." - env = { - **defaultenv, - "PGRST_DB_CONFIG": "true", - } - - with run(env=env) as postgrest: - response = postgrest.session.head("/projects") - assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-4/*" - - # change max-rows config on the db - postgrest.session.post("/rpc/change_max_rows_config", data={"val": 1}) - - # reload config - postgrest.process.send_signal(signal.SIGUSR2) - - sleep_until_postgrest_config_reload() - - response = postgrest.session.head("/projects") - assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-0/*" - - # reset max-rows config on the db - response = postgrest.session.post("/rpc/reset_max_rows_config") - assert response.text == "" - assert response.status_code == 204 - - -def test_max_rows_notify_reload(defaultenv): - "max-rows should be reloaded from role settings when PostgREST receives a NOTIFY" - - env = { - **defaultenv, - "PGRST_DB_CONFIG": "true", - "PGRST_DB_CHANNEL_ENABLED": "true", - } - - with run(env=env) as postgrest: - response = postgrest.session.head("/projects") - assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-4/*" - - # change max-rows config on the db and reload with notify - postgrest.session.post( - "/rpc/change_max_rows_config", data={"val": 1, "notify": True} - ) - - sleep_until_postgrest_config_reload() - - response = postgrest.session.head("/projects") - assert response.status_code == 200 - assert response.headers["Content-Range"] == "0-0/*" - - # reset max-rows config on the db - response = postgrest.session.post("/rpc/reset_max_rows_config") - assert response.text == "" - assert response.status_code == 204 - - -def test_invalid_role_claim_key_notify_reload(defaultenv): - "NOTIFY reload config should show an error if role-claim-key is invalid" - - env = { - **defaultenv, - "PGRST_DB_CONFIG": "true", - "PGRST_DB_CHANNEL_ENABLED": "true", - "PGRST_LOG_LEVEL": "crit", - } - - with run(env=env) as postgrest: - postgrest.session.post("/rpc/invalid_role_claim_key_reload") - - output = postgrest.read_stdout() - assert 'Received a config reload message on the "pgrst" channel' in output[0] - output = postgrest.read_stdout() - assert "failed to parse role-claim-key value" in output[0] - - response = postgrest.session.post("/rpc/reset_invalid_role_claim_key") - assert response.text == "" - assert response.status_code == 204 - - -def test_notify_do_nothing(defaultenv): - "NOTIFY with unknown message should do nothing" - - env = { - **defaultenv, - "PGRST_DB_CONFIG": "true", - "PGRST_DB_CHANNEL_ENABLED": "true", - "PGRST_LOG_LEVEL": "crit", - } - - with run(env=env) as postgrest: - response = postgrest.session.post("/rpc/notify_do_nothing") - assert response.text == "" - assert response.status_code == 204 - - output = postgrest.read_stdout() - assert output == [] - - def test_pool_size(defaultenv, metapostgrest): "Verify that PGRST_DB_POOL setting allows the correct number of parallel requests" @@ -862,113 +704,6 @@ def test_no_pool_connection_required_on_bad_embedding(defaultenv): assert response.status_code == 400 -# https://github.com/PostgREST/postgrest/issues/2620 -def test_notify_reloading_catalog_cache(defaultenv): - "notify should reload the connection catalog cache" - - with run(env=defaultenv) as postgrest: - # first the id col is an uuid - response = postgrest.session.get( - "/cats?id=eq.dea27321-f988-4a57-93e4-8eeb38f3cf1e" - ) - assert response.status_code == 200 - - # change it to a bigint - response = postgrest.session.post("/rpc/drop_change_cats") - assert response.text == "" - assert response.status_code == 204 - sleep_until_postgrest_scache_reload() - - # next request should succeed with a bigint value - response = postgrest.session.get("/cats?id=eq.1") - assert response.status_code == 200 - - -def test_stale_schema_cache_dropped_table_returns_database_error(defaultenv): - "dropped table should return a database error while schema cache is stale" - - internal_sleep = 2 - env = { - **defaultenv, - "PGRST_DB_POOL": "2", - "PGRST_DB_CHANNEL_ENABLED": "true", - "PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP": str(internal_sleep * 1000), - } - - try: - psql_as_superuser(""" - drop table if exists stale_schema_cache_items; - create table stale_schema_cache_items(id int primary key); - insert into stale_schema_cache_items values (1); - grant select on stale_schema_cache_items to postgrest_test_anonymous; - """) - - with run(env=env, wait_max_seconds=10) as postgrest: - response = postgrest.session.get("/stale_schema_cache_items") - assert response.status_code == 200 - - psql_as_superuser(""" - drop table stale_schema_cache_items; - notify pgrst, 'reload schema'; - """) - - response = postgrest.session.get("/stale_schema_cache_items") - payload = response.json() - assert response.status_code == 404 - assert payload["code"] == "42P01" - assert ( - payload["message"] - == 'relation "public.stale_schema_cache_items" does not exist' - ) - - time.sleep(internal_sleep + 0.3) - - response = postgrest.session.get("/stale_schema_cache_items") - payload = response.json() - assert response.status_code == 404 - assert payload["code"] == "PGRST205" - assert ( - payload["message"] - == "Could not find the table 'public.stale_schema_cache_items' in the schema cache" - ) - finally: - psql_as_superuser("drop table if exists stale_schema_cache_items;") - - -def test_schema_cache_concurrent_notifications(slow_schema_cache_env): - "schema cache should be up-to-date whenever a notification is sent while another reload is in progress, see https://github.com/PostgREST/postgrest/issues/2791" - - internal_sleep = ( - int(slow_schema_cache_env["PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP"]) / 1000 - ) - - with run(env=slow_schema_cache_env, wait_for=None) as postgrest: - time.sleep(2 * internal_sleep + 0.1) # wait for readiness manually - - # first request, create a function and set a schema cache reload in progress - response = postgrest.session.post("/rpc/create_function") - assert response.text == "" - assert response.status_code == 204 - - time.sleep( - internal_sleep / 2 - ) # wait to be inside the schema cache reload process - - # second request, change the same function and do another schema cache reload - response = postgrest.session.post("/rpc/migrate_function") - assert response.text == "" - assert response.status_code == 204 - - time.sleep( - 2 * internal_sleep - ) # wait enough time to get the final schema cache state - - # confirm the schema cache is up-to-date and the 2nd reload wasn't lost - response = postgrest.session.get("/rpc/mult_them?c=3&d=4") - assert response.text == "12" - assert response.status_code == 200 - - def test_schema_cache_query_sleep_logs(defaultenv): """Schema cache sleep should be reflected in the logged query duration.""" @@ -1268,31 +1003,6 @@ def test_log_error_when_schema_cache_load_error_on_startup_to_stderr(defaultenv) assert any(log_err_message in line for line in output_start) -def test_no_double_schema_cache_reload_on_empty_schema(defaultenv): - "Should only load the schema cache once when there's an empty schema cache on startup" - - env = { - **defaultenv, - "PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP": "300", - } - - with run(env=env, wait_for=None) as postgrest: - postgrest.wait_until_scache_starts_loading() - - with pytest.raises(requests.ConnectionError): - postgrest.session.get("/projects") - - # Should wait enough time to load the schema cache twice to guarantee that the test is valid - time.sleep(1) - - response = postgrest.session.get("/projects") - assert response.status_code == 200 - - response = postgrest.admin.get("/metrics") - assert response.status_code == 200 - assert 'pgrst_schema_cache_loads_total{status="SUCCESS"} 1.0' in response.text - - @pytest.mark.parametrize("level", ["crit", "error", "warn", "info", "debug"]) def test_log_pool_req_observation(level, defaultenv): "PostgREST should log PoolRequest and PoolRequestFullfilled observation when log-level=debug" @@ -1458,98 +1168,3 @@ def test_use_legacy_target_names(enabled, defaultenv): else: assert response.status_code == 400 assert not has_warning_log and not has_hint_log - - -def test_config_log_level_is_reloadable(tmp_path, defaultenv): - "Config log-level should be reloadable on SIGUSR2" - - config = (CONFIGSDIR / "sigusr2-settings.config").read_text() - configfile = tmp_path / "test.config" - configfile.write_text(config) - - # Delete the env variable for "log-level" so the config file value isn't overridden - del defaultenv["PGRST_LOG_LEVEL"] - - with run(configfile, env=defaultenv) as postgrest: - response = postgrest.session.get("/projects") - assert response.status_code == 200 - output = postgrest.read_stdout(nlines=5) - - # log-level = error, so this log line shouldn't be logged - assert not any( - "Trying to borrow a connection from pool" in line for line in output - ) - - # change setting - configfile.write_text( - config.replace('log-level = "error"', 'log-level = "debug"') - ) - # reload - postgrest.process.send_signal(signal.SIGUSR2) - - sleep_until_postgrest_config_reload() - - response = postgrest.session.get("/projects") - assert response.status_code == 200 - output = postgrest.read_stdout(nlines=5) - - # log-level = debug now, so this log line must be logged - assert any("Trying to borrow a connection from pool" in line for line in output) - - -def test_config_db_channel_enabled_is_reloadable(tmp_path, defaultenv): - "Config db-channel-enabled should be reloadable on SIGUSR2" - - config = (CONFIGSDIR / "sigusr2-settings.config").read_text() - configfile = tmp_path / "test.config" - configfile.write_text(config) - - with run(configfile, env=defaultenv, no_startup_stdout=False) as postgrest: - output = postgrest.read_stdout(nlines=7) - - # db-channel-enabled = false, so this shouldn't be logged - assert not any( - f'"{defaultenv["PGHOST"]}:5432" and listening for database notifications on the "pgrst" channel' - in line - for line in output - ) - - # change setting - configfile.write_text( - config.replace( - 'db-channel-enabled = "false"', 'db-channel-enabled = "true"' - ) - ) - - # reload - postgrest.process.send_signal(signal.SIGUSR2) - sleep_until_postgrest_config_reload() - - output = postgrest.read_stdout(nlines=7) - - # db-channel-enabled = true, so this logged - assert any( - f'"{defaultenv["PGHOST"]}:5432" and listening for database notifications on the "pgrst" channel' - in line - for line in output - ) - - # change setting back to false - configfile.write_text( - configfile.read_text().replace( - 'db-channel-enabled = "true"', 'db-channel-enabled = "false"' - ) - ) - - # reload - postgrest.process.send_signal(signal.SIGUSR2) - sleep_until_postgrest_config_reload() - - output = postgrest.read_stdout(nlines=7) - - # db-channel-enabled = false, so this shouldn't be logged - assert not any( - f'"{defaultenv["PGHOST"]}:5432" and listening for database notifications on the "pgrst" channel' - in line - for line in output - ) diff --git a/test/io/test_reloading.py b/test/io/test_reloading.py new file mode 100644 index 000000000..d4d5c753f --- /dev/null +++ b/test/io/test_reloading.py @@ -0,0 +1,508 @@ +"Test reloading behaviors in PostgREST" + +import signal +import time +import pytest +import requests + +from config import CONFIGSDIR, SECRET +from util import ( + jwtauthheader, + psql_as_superuser, +) +from postgrest import ( + run, + sleep_until_postgrest_config_reload, + sleep_until_postgrest_full_reload, + sleep_until_postgrest_scache_reload, +) + + +def test_db_schema_notify_reload(defaultenv): + "DB schema and config should be reloaded when PostgREST is sent a NOTIFY" + + env = {**defaultenv, "PGRST_DB_CONFIG": "true", "PGRST_DB_CHANNEL_ENABLED": "true"} + + with run(env=env) as postgrest: + response = postgrest.session.get("/rpc/get_guc_value?name=search_path") + assert response.text == '"\\"public\\", \\"public\\""' + + # change db-schemas config on the db and reload config and cache with notify + postgrest.session.post( + "/rpc/change_db_schema_and_full_reload", data={"schemas": "v1"} + ) + + sleep_until_postgrest_full_reload() + + response = postgrest.session.get("/rpc/get_guc_value?name=search_path") + assert response.text == '"\\"v1\\", \\"public\\""' + + # reset db-schemas config on the db + response = postgrest.session.post("/rpc/reset_db_schema_config") + assert response.text == "" + assert response.status_code == 204 + + +def test_db_schema_reload(tmp_path, defaultenv): + "DB schema should be reloaded from file when PostgREST is sent SIGUSR2." + config = (CONFIGSDIR / "sigusr2-settings.config").read_text() + configfile = tmp_path / "test.config" + configfile.write_text(config) + + with run(configfile, env=defaultenv) as postgrest: + response = postgrest.session.get("/rpc/get_guc_value?name=search_path") + assert response.text == '"\\"public\\", \\"public\\""' + + # change setting + configfile.write_text( + config.replace('db-schemas = "public"', 'db-schemas = "v1"') + ) + + # reload config + postgrest.process.send_signal(signal.SIGUSR2) + sleep_until_postgrest_config_reload() + + # reload schema cache to verify that the config reload actually happened + postgrest.process.send_signal(signal.SIGUSR1) + sleep_until_postgrest_scache_reload() + + response = postgrest.session.get("/rpc/get_guc_value?name=search_path") + assert response.text == '"\\"v1\\", \\"public\\""' + + +def test_invalid_role_claim_key_notify_reload(defaultenv): + "NOTIFY reload config should show an error if role-claim-key is invalid" + + env = { + **defaultenv, + "PGRST_DB_CONFIG": "true", + "PGRST_DB_CHANNEL_ENABLED": "true", + "PGRST_LOG_LEVEL": "crit", + } + + with run(env=env) as postgrest: + postgrest.session.post("/rpc/invalid_role_claim_key_reload") + + output = postgrest.read_stdout() + assert 'Received a config reload message on the "pgrst" channel' in output[0] + output = postgrest.read_stdout() + assert "failed to parse role-claim-key value" in output[0] + + response = postgrest.session.post("/rpc/reset_invalid_role_claim_key") + assert response.text == "" + assert response.status_code == 204 + + +def test_max_rows_reload(defaultenv): + "max-rows should be reloaded from role settings when PostgREST receives a SIGUSR2." + env = { + **defaultenv, + "PGRST_DB_CONFIG": "true", + } + + with run(env=env) as postgrest: + response = postgrest.session.head("/projects") + assert response.status_code == 200 + assert response.headers["Content-Range"] == "0-4/*" + + # change max-rows config on the db + postgrest.session.post("/rpc/change_max_rows_config", data={"val": 1}) + + # reload config + postgrest.process.send_signal(signal.SIGUSR2) + + sleep_until_postgrest_config_reload() + + response = postgrest.session.head("/projects") + assert response.status_code == 200 + assert response.headers["Content-Range"] == "0-0/*" + + # reset max-rows config on the db + response = postgrest.session.post("/rpc/reset_max_rows_config") + assert response.text == "" + assert response.status_code == 204 + + +def test_max_rows_notify_reload(defaultenv): + "max-rows should be reloaded from role settings when PostgREST receives a NOTIFY" + + env = { + **defaultenv, + "PGRST_DB_CONFIG": "true", + "PGRST_DB_CHANNEL_ENABLED": "true", + } + + with run(env=env) as postgrest: + response = postgrest.session.head("/projects") + assert response.status_code == 200 + assert response.headers["Content-Range"] == "0-4/*" + + # change max-rows config on the db and reload with notify + postgrest.session.post( + "/rpc/change_max_rows_config", data={"val": 1, "notify": True} + ) + + sleep_until_postgrest_config_reload() + + response = postgrest.session.head("/projects") + assert response.status_code == 200 + assert response.headers["Content-Range"] == "0-0/*" + + # reset max-rows config on the db + response = postgrest.session.post("/rpc/reset_max_rows_config") + assert response.text == "" + assert response.status_code == 204 + + +def test_no_double_schema_cache_reload_on_empty_schema(defaultenv): + "Should only load the schema cache once when there's an empty schema cache on startup" + + env = { + **defaultenv, + "PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP": "300", + } + + with run(env=env, wait_for=None) as postgrest: + postgrest.wait_until_scache_starts_loading() + + with pytest.raises(requests.ConnectionError): + postgrest.session.get("/projects") + + # Should wait enough time to load the schema cache twice to guarantee that the test is valid + time.sleep(1) + + response = postgrest.session.get("/projects") + assert response.status_code == 200 + + response = postgrest.admin.get("/metrics") + assert response.status_code == 200 + assert 'pgrst_schema_cache_loads_total{status="SUCCESS"} 1.0' in response.text + + +# https://github.com/PostgREST/postgrest/issues/2620 +def test_notify_reloading_catalog_cache(defaultenv): + "notify should reload the connection catalog cache" + + with run(env=defaultenv) as postgrest: + # first the id col is an uuid + response = postgrest.session.get( + "/cats?id=eq.dea27321-f988-4a57-93e4-8eeb38f3cf1e" + ) + assert response.status_code == 200 + + # change it to a bigint + response = postgrest.session.post("/rpc/drop_change_cats") + assert response.text == "" + assert response.status_code == 204 + sleep_until_postgrest_scache_reload() + + # next request should succeed with a bigint value + response = postgrest.session.get("/cats?id=eq.1") + assert response.status_code == 200 + + +def test_notify_do_nothing(defaultenv): + "NOTIFY with unknown message should do nothing" + + env = { + **defaultenv, + "PGRST_DB_CONFIG": "true", + "PGRST_DB_CHANNEL_ENABLED": "true", + "PGRST_LOG_LEVEL": "crit", + } + + with run(env=env) as postgrest: + response = postgrest.session.post("/rpc/notify_do_nothing") + assert response.text == "" + assert response.status_code == 204 + + output = postgrest.read_stdout() + assert output == [] + + +def test_schema_cache_concurrent_notifications(slow_schema_cache_env): + "schema cache should be up-to-date whenever a notification is sent while another reload is in progress, see https://github.com/PostgREST/postgrest/issues/2791" + + internal_sleep = ( + int(slow_schema_cache_env["PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP"]) / 1000 + ) + + with run(env=slow_schema_cache_env, wait_for=None) as postgrest: + time.sleep(2 * internal_sleep + 0.1) # wait for readiness manually + + # first request, create a function and set a schema cache reload in progress + response = postgrest.session.post("/rpc/create_function") + assert response.text == "" + assert response.status_code == 204 + + time.sleep( + internal_sleep / 2 + ) # wait to be inside the schema cache reload process + + # second request, change the same function and do another schema cache reload + response = postgrest.session.post("/rpc/migrate_function") + assert response.text == "" + assert response.status_code == 204 + + time.sleep( + 2 * internal_sleep + ) # wait enough time to get the final schema cache state + + # confirm the schema cache is up-to-date and the 2nd reload wasn't lost + response = postgrest.session.get("/rpc/mult_them?c=3&d=4") + assert response.text == "12" + assert response.status_code == 200 + + +def test_jwt_secret_reload(tmp_path, defaultenv): + "JWT secret should be reloaded from file when PostgREST is sent SIGUSR2." + config = (CONFIGSDIR / "sigusr2-settings.config").read_text() + configfile = tmp_path / "test.config" + configfile.write_text(config) + + headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) + + with run(configfile, env=defaultenv) as postgrest: + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + # change setting + configfile.write_text(config.replace("invalid" * 5, SECRET)) + + # reload config + postgrest.process.send_signal(signal.SIGUSR2) + + sleep_until_postgrest_config_reload() + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 200 + + +def test_jwt_secret_external_file_reload(tmp_path, defaultenv): + "JWT secret external file should be reloaded when PostgREST is sent a SIGUSR2 or a NOTIFY." + headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) + + external_secret_file = tmp_path / "jwt-secret-config" + external_secret_file.write_text("invalid" * 5) + + env = { + **defaultenv, + "PGRST_JWT_SECRET": f"@{external_secret_file}", + "PGRST_DB_CHANNEL_ENABLED": "true", + "PGRST_DB_CONFIG": "false", + "PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY + } + + with run(env=env) as postgrest: + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + # change external file + external_secret_file.write_text(SECRET) + + # SIGUSR1 doesn't reload external files, at least when db-config=false + postgrest.process.send_signal(signal.SIGUSR1) + sleep_until_postgrest_scache_reload() + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + # reload config and external file with SIGUSR2 + postgrest.process.send_signal(signal.SIGUSR2) + sleep_until_postgrest_config_reload() + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 200 + + # change external file to wrong value again + external_secret_file.write_text("invalid" * 5) + + # reload config and external file with NOTIFY + response = postgrest.session.post("/rpc/reload_pgrst_config") + assert response.text == "" + assert response.status_code == 204 + sleep_until_postgrest_config_reload() + + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + +def test_invalidate_jwt_cache_when_secret_changes(tmp_path, defaultenv): + "JWT cache should be emptied after jwt-secret is changed in a config reload" + + headers = jwtauthheader({"role": "postgrest_test_author"}, SECRET) + + external_secret_file = tmp_path / "jwt-secret-config" + external_secret_file.write_text(SECRET) + + env = { + **defaultenv, + "PGRST_JWT_SECRET": f"@{external_secret_file}", + "PGRST_DB_CHANNEL_ENABLED": "true", + "PGRST_JWT_CACHE_MAX_ENTRIES": "86400", # enable cache + "PGRST_DB_ANON_ROLE": "postgrest_test_anonymous", # required for NOTIFY + } + + with run(env=env) as postgrest: + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 200 # jwt gets cached + + # change external file + external_secret_file.write_text("invalid" * 5) + + # reload config and external file with NOTIFY + # jwt-cache should get empty + response = postgrest.session.post("/rpc/reload_pgrst_config") + assert response.text == "" + assert response.status_code == 204 + sleep_until_postgrest_config_reload() + + # now the request should fail because the cached token is removed + response = postgrest.session.get("/authors_only", headers=headers) + assert response.status_code == 401 + + +def test_stale_schema_cache_dropped_table_returns_database_error(defaultenv): + "dropped table should return a database error while schema cache is stale" + + internal_sleep = 2 + env = { + **defaultenv, + "PGRST_DB_POOL": "2", + "PGRST_DB_CHANNEL_ENABLED": "true", + "PGRST_INTERNAL_SCHEMA_CACHE_QUERY_SLEEP": str(internal_sleep * 1000), + } + + try: + psql_as_superuser(""" + drop table if exists stale_schema_cache_items; + create table stale_schema_cache_items(id int primary key); + insert into stale_schema_cache_items values (1); + grant select on stale_schema_cache_items to postgrest_test_anonymous; + """) + + with run(env=env, wait_max_seconds=10) as postgrest: + response = postgrest.session.get("/stale_schema_cache_items") + assert response.status_code == 200 + + psql_as_superuser(""" + drop table stale_schema_cache_items; + notify pgrst, 'reload schema'; + """) + + response = postgrest.session.get("/stale_schema_cache_items") + payload = response.json() + assert response.status_code == 404 + assert payload["code"] == "42P01" + assert ( + payload["message"] + == 'relation "public.stale_schema_cache_items" does not exist' + ) + + time.sleep(internal_sleep + 0.3) + + response = postgrest.session.get("/stale_schema_cache_items") + payload = response.json() + assert response.status_code == 404 + assert payload["code"] == "PGRST205" + assert ( + payload["message"] + == "Could not find the table 'public.stale_schema_cache_items' in the schema cache" + ) + finally: + psql_as_superuser("drop table if exists stale_schema_cache_items;") + + +def test_config_log_level_is_reloadable(tmp_path, defaultenv): + "Config log-level should be reloadable on SIGUSR2" + + config = (CONFIGSDIR / "sigusr2-settings.config").read_text() + configfile = tmp_path / "test.config" + configfile.write_text(config) + + # Delete the env variable for "log-level" so the config file value isn't overridden + del defaultenv["PGRST_LOG_LEVEL"] + + with run(configfile, env=defaultenv) as postgrest: + response = postgrest.session.get("/projects") + assert response.status_code == 200 + output = postgrest.read_stdout(nlines=5) + + # log-level = error, so this log line shouldn't be logged + assert not any( + "Trying to borrow a connection from pool" in line for line in output + ) + + # change setting + configfile.write_text( + config.replace('log-level = "error"', 'log-level = "debug"') + ) + # reload + postgrest.process.send_signal(signal.SIGUSR2) + + sleep_until_postgrest_config_reload() + + response = postgrest.session.get("/projects") + assert response.status_code == 200 + output = postgrest.read_stdout(nlines=5) + + # log-level = debug now, so this log line must be logged + assert any("Trying to borrow a connection from pool" in line for line in output) + + +def test_config_db_channel_enabled_is_reloadable(tmp_path, defaultenv): + "Config db-channel-enabled should be reloadable on SIGUSR2" + + config = (CONFIGSDIR / "sigusr2-settings.config").read_text() + configfile = tmp_path / "test.config" + configfile.write_text(config) + + with run(configfile, env=defaultenv, no_startup_stdout=False) as postgrest: + output = postgrest.read_stdout(nlines=7) + + # db-channel-enabled = false, so this shouldn't be logged + assert not any( + f'"{defaultenv["PGHOST"]}:5432" and listening for database notifications on the "pgrst" channel' + in line + for line in output + ) + + # change setting + configfile.write_text( + config.replace( + 'db-channel-enabled = "false"', 'db-channel-enabled = "true"' + ) + ) + + # reload + postgrest.process.send_signal(signal.SIGUSR2) + sleep_until_postgrest_config_reload() + + output = postgrest.read_stdout(nlines=7) + + # db-channel-enabled = true, so this logged + assert any( + f'"{defaultenv["PGHOST"]}:5432" and listening for database notifications on the "pgrst" channel' + in line + for line in output + ) + + # change setting back to false + configfile.write_text( + configfile.read_text().replace( + 'db-channel-enabled = "true"', 'db-channel-enabled = "false"' + ) + ) + + # reload + postgrest.process.send_signal(signal.SIGUSR2) + sleep_until_postgrest_config_reload() + + output = postgrest.read_stdout(nlines=7) + + # db-channel-enabled = false, so this shouldn't be logged + assert not any( + f'"{defaultenv["PGHOST"]}:5432" and listening for database notifications on the "pgrst" channel' + in line + for line in output + )